Skip to content

fix(spectral): catch oneOf without discriminator at all schema depths#521

Merged
pengying merged 1 commit into
mainfrom
05-27-fix_spectral_catch_oneof_without_discriminator_at_all_schema_depths
May 29, 2026
Merged

fix(spectral): catch oneOf without discriminator at all schema depths#521
pengying merged 1 commit into
mainfrom
05-27-fix_spectral_catch_oneof_without_discriminator_at_all_schema_depths

Conversation

@pengying
Copy link
Copy Markdown
Contributor

@pengying pengying commented May 28, 2026

Summary

Two changes that together close a hole in our spec hygiene: a Spectral rule that catches every oneOf missing a discriminator (not just top-level schemas), and the one existing violation (Card.stateReason).

Why

Stainless's Java codegen emitted the diagnostic:

Java/SchemaUnionDiscriminatorMissing — Union has object variants but no discriminator. Deserialization may be inefficient or ambiguous without a discriminator because each object variant must be tried in sequence.

Tracking it down found a single offender: Card.stateReason, which used oneOf: [$ref CardStateReason, type: 'null']. oneOf implies a tagged union — meaningful only with a discriminator — whereas this is really just a nullable enum. The idiomatic primitive for that is anyOf.

The existing spectral rule (oneOf-must-have-discriminator) didn't catch it for two reasons:

  1. Scope: it only inspected $.components.schemas[?(@.oneOf)] — i.e. top-level schemas. Nested oneOf (inside properties, allOf, etc.) was invisible.
  2. Severity: it was set to warn, so wouldn't have failed CI even if it had matched.

Changes

openapi/components/schemas/cards/Card.yaml

Change stateReason from oneOfanyOf. No semantic change for consumers; just signals to codegen that this is a nullable value, not a tagged union.

 stateReason:
-  oneOf:
+  anyOf:
     - $ref: ./CardStateReason.yaml
     - type: 'null'

.spectral.yaml

Broaden oneOf-must-have-discriminator:

  • Given path: $.components.schemas[?(@.oneOf)]$..*[?(@ && @.oneOf)]. Recursive descent with a null-safe filter so it catches oneOf at any depth (nested properties, allOf members, array items, etc.) without crashing on JSON null literals (type: 'null' etc.).
  • Severity: warnerror. The only existing violation is fixed in this PR, so this won't regress CI.
  • Message: updated to point devs at anyOf for nullable-value cases.

Validation

  • make lint exits 0 with the fix applied.
  • Temporarily regressing Card.yaml back to oneOf makes spectral fail with:
    error  oneOf-must-have-discriminator
    components.schemas.Card.properties.stateReason
    

Stack

Stacked on top of #520.

@vercel
Copy link
Copy Markdown

vercel Bot commented May 28, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
grid-flow-builder Ready Ready Preview, Comment May 29, 2026 12:19am

Request Review

Copy link
Copy Markdown
Contributor Author

pengying commented May 28, 2026

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 28, 2026

✱ Stainless preview builds for grid

This PR will update the grid SDKs with the following commit messages.

cli

fix(spectral): catch oneOf without discriminator at all schema depths

csharp

fix(spectral): catch oneOf without discriminator at all schema depths

go

fix(spectral): catch oneOf without discriminator at all schema depths

kotlin

fix(spectral): catch oneOf without discriminator at all schema depths

openapi

fix(spectral): catch oneOf without discriminator at all schema depths

php

fix(spectral): catch oneOf without discriminator at all schema depths

python

fix(spectral): catch oneOf without discriminator at all schema depths

ruby

fix(spectral): catch oneOf without discriminator at all schema depths

typescript

fix(spectral): catch oneOf without discriminator at all schema depths
grid-typescript studio · code

Your SDK build had at least one note diagnostic.
generate ✅build ✅lint ✅test ✅

npm install https://pkg.stainless.com/s/grid-typescript/23edde7b90e3eb32de909ed1731f336a2b17da3e/dist.tar.gz
grid-openapi studio · code

Your SDK build had at least one note diagnostic.
generate ✅

grid-ruby studio · code

Your SDK build had at least one note diagnostic.
generate ✅build ✅lint ✅test ✅

⚠️ grid-kotlin studio · code

Your SDK build had a failure in the test CI job, which is a regression from the base state.
generate ✅build ✅lint ✅test ❗

⚠️ grid-python studio · code

Your SDK build had a failure in the lint CI job, which is a regression from the base state.
generate ✅build ✅lint ❗test ❗

pip install https://pkg.stainless.com/s/grid-python/5440131bca25eeda8e8757c99bd7ff90d0bf42df/grid-0.0.1-py3-none-any.whl
⚠️ grid-csharp studio · code

Your SDK build had a failure in the build CI job, which is a regression from the base state.
generate ⚠️build ❗lint ✅test ❗

⚠️ grid-go studio · code

Your SDK build had a failure in the lint CI job, which is a regression from the base state.
generate ✅build ✅lint ❗test ❗

go get github.com/stainless-sdks/grid-go@f4388db834f8c4453e9b5edc7c938dbcccf3c9b1
grid-php studio · code

Your SDK build had at least one note diagnostic.
generate ✅lint ✅test ✅

⚠️ grid-cli studio · code

Your SDK build had a failure in the test CI job, which is a regression from the base state.
generate ⚠️build ⏭️lint ⏭️test ❗


This comment is auto-generated by GitHub Actions and is automatically kept up to date as you push.
If you push custom code to the preview branch, re-run this workflow to update the comment.
Last updated: 2026-05-29 17:05:32 UTC

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 28, 2026

Greptile Summary

This PR strengthens the oneOf-must-have-discriminator Spectral rule to catch violations at all schema depths (not just top-level components.schemas) and promotes it from warn to error. The stateReason property in Card.yaml — the first nested violation uncovered by the broader path — is correctly changed from oneOf to anyOf since it represents a nullable value and needs no tagged-union discriminator.

  • .spectral.yaml: given updated from $.components.schemas[?(@.oneOf)] to $..*[?(@ && @.oneOf)]; severity escalated to error; message updated to guide authors toward anyOf for nullable fields.
  • openapi/components/schemas/cards/Card.yaml (and both compiled openapi.yaml copies): stateReason.oneOfanyOf to fix the newly caught violation.

Confidence Score: 4/5

Safe to merge — the logic changes are correct and the schema fix is consistent across all three copies of the spec.

The rule extension and schema fix work as intended, and a spot-check of the existing oneOf usages in openapi.yaml confirms they all carry proper discriminators. Two non-blocking observations remain: $..* applied to a resolved document may match the same oneOf multiple times if the parent schema is $ref-ed from several places, and the recursive descent adds traversal work compared to the previous top-level-only query. Neither of these affects correctness for the current spec state.

.spectral.yaml — the new JSONPath expression and its interaction with Spectral's default resolved-document mode are worth a quick double-check.

Important Files Changed

Filename Overview
.spectral.yaml Extended oneOf-must-have-discriminator from top-level components only to all schema depths via $..*; escalated severity from warn to error. The logic is correct, but $..* on a resolved document may produce duplicate errors and adds traversal overhead on large specs.
openapi/components/schemas/cards/Card.yaml Corrected stateReason from oneOf to anyOf for a nullable field — the right fix since anyOf is the proper construct for "value or null" when no discriminator is needed.
openapi.yaml Compiled spec updated in sync with the Card.yaml component source; the single oneOfanyOf change for stateReason is correct and consistent.
mintlify/openapi.yaml Mintlify copy of the compiled spec updated identically to openapi.yaml; change is correct and in sync.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Spectral lint run] --> B[Apply oneOf-must-have-discriminator rule]
    B --> C["$..*[?(@ and @.oneOf)]"]
    C -->|Match at any depth| D[Check for discriminator field]
    D --> E{Has discriminator?}
    E -->|Yes| F[Pass]
    E -->|No| G[Error - was warn]
    H[Card.stateReason oneOf nullable] --> I[Changed to anyOf]
    I --> F
Loading

Fix All in Claude Code

Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
.spectral.yaml:71
**`$..*` traversal on the resolved document may produce duplicate violations**

Without `resolved: false`, Spectral applies this rule to the fully inlined document — every `$ref` is replaced with its full schema content before the JSONPath runs. `$..*` then traverses the expanded tree, so a single `oneOf` that is referenced in many places (e.g. `ExternalAccountInfoOneOf` or `CustomerOneOf`) will be matched once per resolved copy. If any such site lacks a discriminator, the same error will fire multiple times for what is logically one definition. Adding `resolved: false` keeps the JSONPath anchored to the literal YAML structure and avoids that duplication.

### Issue 2 of 2
.spectral.yaml:71
**Recursive descent `$..*` can be slow on large resolved specs**

The spec already contains ~92 `oneOf` usages across thousands of nodes. `$..*` visits every node in the (resolved, fully-inlined) document to apply the filter, whereas the previous `$.components.schemas[?(@.oneOf)]` only visited the top-level schema map. For large specs this can add measurable overhead to each Spectral run. Consider a more targeted path such as `$..[oneOf]` or explicit prefixes covering the locations you care about (`$.components.schemas.*`, `$.paths..schema`, etc.) if lint run time becomes an issue.

Reviews (1): Last reviewed commit: "fix(spectral): catch oneOf without discr..." | Re-trigger Greptile

Comment thread .spectral.yaml
Comment thread .spectral.yaml
@pengying pengying force-pushed the 05-27-fix_stainless_rename_simulate.return_simulate.refund_to_avoid_kotlin_keyword branch from ca8e445 to f8ea7ca Compare May 28, 2026 23:09
@pengying pengying force-pushed the 05-27-fix_spectral_catch_oneof_without_discriminator_at_all_schema_depths branch from bd7bf74 to 6d31e9c Compare May 28, 2026 23:09
@pengying pengying force-pushed the 05-27-fix_spectral_catch_oneof_without_discriminator_at_all_schema_depths branch from 6d31e9c to dc08535 Compare May 29, 2026 00:15
@pengying pengying force-pushed the 05-27-fix_spectral_catch_oneof_without_discriminator_at_all_schema_depths branch from dc08535 to 4cc01d1 Compare May 29, 2026 00:19
@pengying pengying changed the base branch from 05-27-fix_stainless_rename_simulate.return_simulate.refund_to_avoid_kotlin_keyword to graphite-base/521 May 29, 2026 00:19
@pengying pengying force-pushed the graphite-base/521 branch from f8ea7ca to ffe06d6 Compare May 29, 2026 00:19
@pengying pengying changed the base branch from graphite-base/521 to main May 29, 2026 00:19
Copy link
Copy Markdown
Contributor Author

pengying commented May 29, 2026

Merge activity

  • May 29, 5:03 PM UTC: A user started a stack merge that includes this pull request via Graphite.
  • May 29, 5:04 PM UTC: @pengying merged this pull request with Graphite.

@pengying pengying merged commit d9e464f into main May 29, 2026
8 checks passed
@pengying pengying deleted the 05-27-fix_spectral_catch_oneof_without_discriminator_at_all_schema_depths branch May 29, 2026 17:04
pengying added a commit that referenced this pull request May 29, 2026
…ation (#525)

## Summary

Bumps the Stainless edition and syncs several resource definitions to match the current API surface and codegen needs.

### Edition
- Bump root `edition: 2025-10-10` → `2026-05-06`.
- Drop the now-redundant pinned `kotlin.2025-10-08` edition; the new root edition covers it.

### Webhook discrimination
- `webhooks.unwrap`: add `discriminator: type` so the unwrap codegen dispatches on the payload's `type` field.
- New `openapi.transforms` entry: strip `type` from `BaseWebhook.properties` *and* `BaseWebhook.required`. Without this, every generated `*WebhookEvent` carries the full `WebhookType` enum on its `type` field via `BaseWebhook`, defeating the discriminator — variants that share the same `data` shape (e.g. multiple Card-related webhooks) become indistinguishable and the deserializer picks the first one declared. Same pattern already applied to customer / account / source / destination / auth base schemas in this file.

### Card refund (Kotlin keyword fix carry-over)
- Keep `refund: post /sandbox/cards/{id}/simulate/return` (the method key was renamed from `return` in #520 to avoid the Kotlin reserved keyword; preserved here through the edition bump).

### Resource cleanup — `customers`
- Drop the `customers.models` block. Customer / kyc / internal-account types are now exposed via `$shared.models` and resource subresources directly.
- Drop the standalone `create_kyc_link` method definition; the API surface is covered by `generate_kyc_link` (renamed earlier in #518).
- Simplify `update_internal_account` to the short form (the `body_param_name` was redundant).

### Resource cleanup — `auth`
- Move `auth_session` model alias from `sessions` → `credentials` (the canonical location for credential-related types).
- Drop the per-variant `*CredentialCreateRequest` / `*CredentialVerifyRequest` model aliases (`email_otp_*`, `oauth_*`, `passkey_*`) for the verify side. Stainless collapses these schemas to their `Fields` siblings after the existing `type`-strip transforms; exposing the wrapper aliases generated broken imports in the TS SDK (`TS2724` / `TS2552`). See #518 for the original diagnosis.
- Drop `auth_session_refresh_request` and the explicit `body_param_name` on `sessions.refresh`; covered by the new edition's defaults.

### New `$shared` models
Surface a few schemas that consumers need to reference directly:
- `individual_customer`, `business_customer`, `beneficial_owner`, `business_info_update`, `swift_beneficiary`

### `agents`
- Add `agent_account_rule` model alias.

## Stack

Stacked on top of #521.
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.

2 participants