Skip to content

fix!: keep a security requirement naming no scheme - #1226

Merged
OmarAlJarrah merged 26 commits into
mainfrom
fix/openapi-unresolved-security
Nov 2, 2026
Merged

OmarAlJarrah merged 26 commits into
mainfrom
fix/openapi-unresolved-security

Conversation

@OmarAlJarrah

@OmarAlJarrah OmarAlJarrah commented Nov 1, 2026 •

Copy link
Copy Markdown
Member

Summary

A security requirement naming a scheme the document does not hold was dropped, and what was left said something else:

  • an operation whose every option named one had auth absent, which reads as inheriting the service's security, so an SDK would send the account's credentials to an endpoint that names its own;
  • a document whose every option named one had no Service.Auth, so every inheriting operation read as having no requirement;
  • a list holding other options lost that one: [{a: []}, {b: []}, {}] with nothing declared lowered to [{}], which says no auth is needed;
  • an option ANDing such a name with a declared scheme went whole;
  • a name whose value reads as no list of strings was never read at all: - key: with nothing after the colon lowered to [{}], no auth needed, with key declared, and an AND lost that member;
  • a list of scopes holding a plain date, which OpenAPI reads as a string, went the same way with nothing reported: {key: [read, 2021-01-01]} lowered to [{}].

Now every option lowers whole, with its other schemes, its scopes and its place in the list, and the name lowers to a scheme of the new kind unresolved, one per name, in a namespace of its own:

{"schemes": [{"scheme": "auth/unresolved/components/securitySchemes/pages_upload_token"}]}

No field is added, so OR-of-ANDs, priority order and scopes need no new reading. The kind says what the compiler holds, which is no mechanism, and not that the source states none: docs/ir/auth.md records that, the premise as measured, and each design rejected with the input it fails on. irverify holds the kind and the namespace together both ways, for documents no compiler here wrote as well, and reports such a scheme that states how to authenticate. The dangling-reference check stands: a minted scheme is a real entry. AuthKindUnresolved and the namespace sit in ir and compilers/compile because a second format needs them: AsyncAPI 2's Security Requirement names a scheme by its key under components the same way.

The finding stays an error. The specification requires the name to correspond to a scheme, and docs/compilers/openapi.md#validation makes a finding an error where the document's meaning is missing, as it is for a tag's parent or a path template variable that names nothing. So a document that drew this exits 1 as before, and a run a caller has not told otherwise still starts no emitter. The code changes, from openapi/unresolved-ref to openapi/unresolved-security-scheme, because after an unresolved reference what it named is gone, and here it is not: a caller accepting this finding need not accept a lost reference with it, which is what the per-code override #691 proposes.

An entry naming no mechanism keeps what it wrote. An entry with no type, an http entry with no scheme, and an entry resolving to no object lower to the same kind at their own pointer, whether or not a requirement names them. This departs from what #294 settled, on purpose: it dropped such an entry's text because no node could hold it, and now one does. The description and deprecated reach the scheme, its type and each mechanism field stay verbatim under Unmodeled, a $ref that reached nothing stays there too with only the error it already drew, and the error at the entry now says that the entry and its requirements are kept. An entry written as no object holds no field to keep. A $ref the model does not decode, such as a plain date, read as no reference and lowered to nothing with no report at all; it now draws openapi/unresolved-ref at the entry, so no scheme of this kind goes unreported, and the summary and description beside it stay, which the model does not hold either.

auth.Registry holds the schemes, the way servers.Registry holds servers, so the lowering context no longer carries them and Ctx.WithAuth and Ctx.DeclaresAuth go.

The emitter contract gains what an SDK does (docs/emitters/operations/auth.md#unresolved-schemes, T-28): the option stays in place, its credential comes from an Authenticator the caller writes, and unconfigured it is an unsatisfied credential, so the selector takes the next option and with none left refuses the request unsent, naming the scheme. It never sends the service's credentials in its place, and a request goes with none only where the list itself offers {}.

Left as they are, each stated where it lives:

Landing beside other PRs. #1219 took IRVersion 0.24.0, so this change moved to 0.25.0, after main's row and history entry. #1229 and #1302 also bump, and conflict with main at the version already: whichever lands after this keeps this change's row and entry and takes the next version. Every golden declares idSpaces.auth, so a golden an open PR adds merges clean and then fails, as the ones #1229, #1269, #1277, #1278, #1302 and #1307 add do: whichever lands after this reruns go test ./compilers/openapi/e2e -run 'TestGolden|TestConformance' -update, and the diff is that one line in each, as it was for the five goldens #1217 and #1219 added when main was merged here. #1227, #1229, #1269, #1278 and #1302 conflict with main already, and this change adds to that only in #1229, at the promotion call in lowerSecurityScheme, where the resolution keeps both sides. #1199, #1233, #1235, #1277, #1301, #1304, #1307, #1311 and #1313 merge clean.

Closes #633

Breaking

  • IRVersion moves to 0.25.0, and a consumer built against 0.24.0 is refused the document. It has no reading for the kind, on operations it read as inheriting.
  • Every OpenAPI document declares unresolved under idSpaces.auth, whether or not it mints there: 136 goldens change by that one line and nothing else.
  • A requirement naming no mechanism draws openapi/unresolved-security-scheme where it drew openapi/unresolved-ref, so a consumer keyed on the old code at that site must move.
  • {key: null} names key, where it read as {}, and the warning beside it says it lists no scopes. A document writing it for a name it does not declare exits 1 where it exited 0, with the requirement's error.
  • A securitySchemes entry whose $ref is a plain date draws openapi/unresolved-ref, so a document writing one exits 1 where it exited 0.
  • Document.Auth can hold a scheme no declaration states, and one for an entry that used to lower to nothing.

Test plan

Golden spec, cloudflare/api-schemas @319dc756 (OpenAPI 3.0.3), main @39afce9b against this branch @0043549e:

$ morphic compile --verify openapi.json -o ir.json 2> diags.txt; echo $?
1                                                   # both builds
$ awk '{print $1, $2}' diags.txt | sort | uniq -c
   9 error openapi/unresolved-ref                   # main
   9 error openapi/unresolved-security-scheme       # branch
  34 info openapi/composition-lowering              # this and the rest, both builds
1439 info openapi/degraded-construct
 194 info openapi/inferred-nullable
  10 info openapi/validation-only-keyword
 121 warning openapi/degraded-construct
   3 warning openapi/inapplicable-location-keyword
   5 warning openapi/reserved-header-name
$ jq -c '.services[].groups[].operations[]? | select(.id == "op/openapi/paths/~1pages~1assets~1upload/post") | .auth' ir.json
null                                                                                                  # main
[{"schemes":[{"scheme":"auth/unresolved/components/securitySchemes/pages_upload_token"}]}]            # branch

The nine operations that name a scheme the spec does not declare change, and nothing else does. Of 3,763 operations, 43 have auth absent and 2 are public on both builds, and these nine go from absent to naming APIToken, assets_jwt or pages_upload_token, the three schemes that join Document.Auth. jq -cS .types and jq -cS .unmodeled hash alike on both builds, so the Unmodeled census and the empty models are unmoved. A recursive diff of the two documents finds the nine auth fields, the three schemes, idSpaces.auth, irVersion, and the nine diagnostics' code and text, and nothing else.

Corpus, the 1,954 specs .claude/skills/morphic-verifying-changes/scripts/sweep.py reads from the reference corpus, of which 1,862 lower to a document, main @608a5a46 against this branch @7561bcce, each by compile and by compile --verify. Merging main since changed no line of this change's production code but the version constant, so the two patches differ only there and the sweep was not rerun:

main branch
exit 0 / exit 1 / exit 2, in each mode 1,482 / 472 / 0 1,482 / 472 / 0
specs whose exit differs, in either mode 0
these requirements' errors 286 openapi/unresolved-ref 286 openapi/unresolved-security-scheme
every other diagnostic, by severity and code, spec by spec no difference
IR, beyond idSpaces.auth and irVersion the auth of the 57 specs that name such a scheme, those schemes and the 286 diagnostics

The 286 options sit in 57 specs, 272 in document lists and 14 on operations. 50 list scopes, and none ANDs with a declared scheme. In 45 specs the document's list also holds {}, and lowered to [{}] on main. No spec in the corpus or the golden spec writes a name whose value reads as no list of strings, or a date among its scopes, so those fixes move nothing measured here; a grid of 326 edge specs, 167 JSON and 159 YAML, and the tests hold it.

  • CI's gate passed on 0043549, which holds main @39afce9b, and go test ./... passes there locally.
  • At 7561bcc, morphic-harness over testdata: outcome by outcome the same as main, but for f30, whose first error moves to the new code. Over the reference corpus, 2,079 files: 1,481 ok, 511 stopping at an error and 87 unrecognized on both builds, and 6 of the 511 move their first error to the new code. Over the golden spec it stops at the first error, as main does, so harness.Check reaches irverify but not the order oracle. orderInvariant is therefore called directly, by TestOrderInvariant_ReachesARequirementNamingNoMechanism on the conformance case, and once by hand over the 57 specs, the golden spec and the edge specs: every one passes.
  • The oracle now compares Document.Auth, which it never read, and a flow's scopes as a set. A build in which the first requirement to reach a scheme writes into it goes red there. It reads no operation, so at 7561bcc 229 specs, the 167 JSON edge specs, the 57 specs, the golden spec and a two-entry $ref cycle (openapi: a reference on a failing chain is reported only if the walk reaches it first #767) among them, also went through a reversal of every mapping, comparing the registry, each operation's auth and the service's, and the diagnostics as a multiset. The registry and each auth show no difference; the diagnostics differ on two specs, each on main too: that cycle and the golden spec's keyword list in a message.
  • security-unresolved, a new conformance case, holds every shape: AND, OR, {}, scopes, names with no scopes written, document, operation, webhook, callback, an entry naming no mechanism, one no requirement names and a broken $ref, beside an operation that inherits and one that is public. It witnesses per-op-auth and joins invalidCases and knownInvalid.
  • A requirement in another document mints the source's scheme, in both declaration orders.
  • 80 defects planted one at a time in a throwaway worktree at 7561bcc, one for each branch of the mechanism, each field the verifier reads, each new read and each list that names the new vocabulary, each drew a named failing test, and a no-op control survived. A further plant, in which the first requirement to reach a scheme writes into it, turns TestSecurityUnresolved_ANameTwoDocumentsUseIsOneSchemeOfTheSource and TestOrderInvariant_ReachesARequirementNamingNoMechanism red. One more is equivalent on this compiler: moving the deprecation promotion back into the return leaves the provenance written, though Go does not promise that order, and losing the write goes red in TestConformance/extension-promotion.
  • At 0043549, 38 defects planted the same way across the requirement and scheme lowerings, the registry, nodeview.Strings, the rule table's null reading, the verifier, the namespace list, the run's order and the oracle each drew a named failing test, and a no-op control survived.

A security requirement can name a scheme the compiler holds no mechanism
for, and the IR has no way to say so: an absent Auth inherits, [] is
public, and custom claims a mechanism. AuthKindUnresolved says what the
compiler holds, which is a name and nothing to authenticate with.

Such a scheme lives in the unresolved namespace, reserved like prim, so a
consumer can tell it by its ID and no pointer reaches it. irverify holds
the kind and the namespace together, and reports one that states a
mechanism. The emitter contract says an SDK asks its caller for that
credential, and refuses a request it cannot satisfy rather than sending
the service's in its place.

BREAKING CHANGE: IRVersion moves to 0.23.0. A consumer built against
0.22.0 has no reading for the kind, and the decoder refuses its documents.
A requirement naming a scheme the document does not hold was dropped. An
operation left with no option read as inheriting the service's security,
so an SDK would send the account's credentials to an endpoint that names
its own; a document left with none read as having no requirement; and a
list holding other options lost that one, so [{a}, {b}, {}] with no
schemes declared lowered to [{}], which says no auth is needed.

Every option now lowers whole, with its other schemes, its scopes and its
place in the list, and the name lowers to a scheme of kind unresolved, one
per name. auth.Registry holds them beside the declared schemes, so the
lowering context no longer carries them. An entry naming no mechanism, or
resolving to no object, lowers to that kind at its own pointer, keeping
what it wrote where #294 had to drop it for want of a node to hold it.

The finding stays an error: the specification requires the name to
correspond to a scheme, and what it means is missing. It moves to a code
of its own, openapi/unresolved-security-scheme, because after an
openapi/unresolved-ref what was named is gone, so accepting this finding
need not accept a lost reference with it.

BREAKING CHANGE: every OpenAPI document declares the unresolved namespace
under idSpaces.auth. A requirement naming no mechanism draws
openapi/unresolved-security-scheme where it drew openapi/unresolved-ref.
The oracle compared the type and server registries and the diagnostics, so
a scheme whose content depended on declaration order passed unread. A
scheme minted for a name is such a node: it must not depend on which
requirement reached it first.

harness.Check stops at a document's first error, and every minted scheme
comes with one, so a test calls the oracle on the conformance case
directly. A flow's scopes follow the source's order, so they compare as a
set.
A name was read through the library's model, which holds a member only
when its value reads as a list of strings. `- key:` with nothing after
the colon therefore lowered to the empty option, which says no auth is
needed, though key is declared, and an AND lost that member. Names now
come from the core, so a name stands whatever is beside it, and the rule
table says its null lists no scopes rather than leaving a field out.

An entry naming no mechanism kept every field but its type, so an http
entry with no scheme said nothing of http. An entry whose $ref reached
nothing lost the $ref, the one thing it says of where its mechanism is.
Both now stay verbatim under Unmodeled.

lowerSecurityScheme wrote the scheme's provenance inside the return
statement that reads the scheme, which the language leaves unordered
once the results are unnamed, so that write is a statement of its own.
A scheme of kind unresolved names no mechanism and is a valid kind, so
the comments reading every kind as a mechanism no longer held. A request
does go with no credentials where the list offers {}, which the contract
denied. The corpus count is now the number of documents that lower.
The reproducer and f30 accepted any error beside a minted scheme, though
each says the name that reached no mechanism is what was reported. Both
now ask for that code.
A pinned consumer finds these operations naming a kind it cannot read,
not inheriting: the document has an Auth where it had none. An entry's
$ref keeps only the summary and description beside it, by
reference-object-siblings, not all a Reference Object writes. A list
with no option can be [], so the collapse names a list that writes
entries. LowerSecuritySchemes reports on schemes of no mechanism too, and
such an entry keeps every mechanism field, not only those its type leaves
undefined.
A name is read in the source whichever document holds the requirement,
which a test pinned and no rule said. A requirement whose only key is a
<< naming no mapping still reads as the empty option; #1244 tracks it,
and the rule and writtenAsObject now say so.
An entry that is no object lowers to no option, so what follows it sits
one place earlier in the IR than in the source. No test held the report
to the source's index: writing the option's IR index instead passed.
An ID is opaque to a consumer, so the namespace is no way to tell such a scheme: the kind is, and the namespace is what irverify holds the kind to. The identity document, IDSpaceUnresolved and the verifier's comments said otherwise. The version history now also says that an entry yielding no mechanism lowers to the kind, which a consumer sees whether or not a requirement names it.
… read it

The naming rule lists every position that spells a name, and a scheme a requirement names is one now that it lowers. An option "naming nothing" read as the entry two paragraphs on that is dropped for naming nothing, so the rule says which option it means. nullRead's comment left out the reading a security requirement takes.
The sentence added to nullRead's doc comment sat in the lines an open change rewrites. A comment on the branch says the same and stays out of its way.
messageCases reaches every message that interpolates text from the document, and ir/unresolved-auth-space quotes a kind and a space. Unquoting either left every test green.
The model decodes a string only from a scalar the YAML decoder takes for one, and a plain date is not: OpenAPI reads it as a string, while the parser tags it a timestamp. So a requirement's scopes were lost with nothing reported when a list held a date, and every other scope with them, and a $ref written as a date read as no reference, which lowered to a scheme of kind unresolved with no error at all when no requirement named it. That broke what the kind promises a consumer: a document holding such a scheme fails a run unless its caller says otherwise.

Scopes now come from the list's node, read as the rule table reads it, and the report of a broken $ref from the $ref's. The report that names a $ref it cannot keep verbatim had no test either; it has one now.
The entry said a date at a string field draws no finding, which is true of the compiler and false of the library's validator, so it now says which is which and the canary pins the two findings. Strings cited the check that reads mapping keys rather than the one that reads a list, and the rule's status names the issue that takes the rest of the mechanism. The verifier's message cases move clear of an open change that adds a case in the same place.
A Reference Object whose $ref the model does not decode, such as a plain
date, leaves the model with no summary or description either, so a broken
security scheme lost the text the rule says it keeps. Read both from the
core, as the $ref is, and record what the library does in the canary.

A security list under a local tag still arrives nil with no finding; the
rule's status and the lowering's comment now say so.
The file-body change took 0.23.0, so this change moves to 0.24.0, keeping
that row and history entry.
#1219 landed 0.24.0 first, so this change keeps the row and the history
entry main holds and appends its own under 0.25.0, with the names hash
the pin main added requires. Its history entry now names 0.24.0 as the
generation a pinned consumer holds. The conformance and golden cases
#1217 and #1219 added each declare the unresolved auth namespace.
@OmarAlJarrah
OmarAlJarrah merged commit 172a9f0 into main Nov 2, 2026
2 checks passed
@OmarAlJarrah
OmarAlJarrah deleted the fix/openapi-unresolved-security branch November 2, 2026 01:12
OmarAlJarrah added a commit that referenced this pull request Nov 2, 2026
#1226 took IRVersion 0.25.0 for a shape change, so this change moves to
0.26.0 on that shape, with its own generations row and history entry.
The new case's golden gains the unresolved auth namespace every document
now declares.
OmarAlJarrah added a commit that referenced this pull request Nov 2, 2026
Brings in #1226 and #1307.

- #1226 took IRVersion 0.25.0, so this branch's bump moves to 0.26.0,
  its row keeping 0.25.0's shape. Its new unresolved auth namespace
  reaches allof-refinement.golden.json's idSpaces, and lowering.Ctx
  lost WithAuth, so TestRemembered_IsReadOnceAcrossCopies derives its
  copy with InScope.
- #1307 made the constraint merge a meet: each ordered keyword takes
  the tighter value. Its body is kept under the exported name this
  branch gives it, merge.Constraints, and the tests named after the old
  name follow it. Voices that write one bound alike still meet to that
  bound; bounds that differ still keep their branches, which the next
  commit changes.
OmarAlJarrah added a commit that referenced this pull request Nov 2, 2026
#1219 took 0.24.0 and #1226 0.25.0, so this change moves to 0.26.0,
keeps both rows and history entries, and appends its own.

#1219 turned the field list of docs/ir/unmodeled.md#promotion into a
table held to the code both ways. It gains the fields this change
fills: a Deprecation itself at each carrier, Operation.Stability and
Parameter.Secret, with TypeCommon named among the nodes keeping a
deprecation's or a Secret's extension, as a $ref's target lends both
to a use. TestExtensionTarget_EveryTargetIsInThePromotionTable now
names every field a target fills rather than one, since TargetRedaction
fills three. OperationGroup joins Variant and EnumMember as a node rule
4 names, and TestPromotionTable_EveryNodeCanRecord holds it there.

#1219's rule 3 records a promotion on the node keeping the extension.
A field a $ref's target lends a use is the one case this change records
twice: the target keeps the entry and its marker, and the use lists the
marker too, since a use is read without resolving its target and its
type reaches the entry. Rule 3, extension-promotion and the 0.26.0
history entry say so.

#1217 ended fillPropertyAnnotations in PreserveCarriedKeywords; the
promotion this change moved to PromoteUse stays out of it. #1226
rewrote lowerSecurityScheme's return, which now calls the Promoter.
The two new conformance goldens gain the unresolved auth namespace
every OpenAPI document declares since #1226.
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: an operation whose security names an unknown scheme lowers as inheriting the service's security

1 participant