oxidecomputer / oxidecomputer/omicron
Adding a new value to an existing enum field breaks API version translation
Nobody has claimed this yet.
- Dominant language
- Rust
- Stars
- 572
- Forks
- 97
- Avg merge
- 2d 12h
- Merged PRs (30d)
- 96
Description
Our API versioning setup aims to let older clients Just Work when used to talk to newer versions of the API by translating old request bodies into the newer shape on the way in and back into the older shape on the way out. However, it turns out there is at least one type of API change where this translation cannot work and the API is forced to error out, even for GET requests.
During the release of v20, #10560 came in last minute and I forgot to bump the web console so the API version matched (see https://github.com/oxidecomputer/omicron/pull/10596 for a releng improvement that prevents this). This resulted in the instances page in the console blowing up due to an API error on instance_list. #10560 added a new value to an existing enum that the older client would not know how to handle. The API rightly errors out because there's no value it could translate amd_turin_v2 to that the client would recognize:
Contrast this with an entirely new field like jumbo frames (#10472), which is left optional in the POST body so old clients can leave it off, and in the response the API can also leave it out, so the old client is not surprised.
@ahl had the idea of adding a catchall variant (calling it unknown off the top of my head) to all API enums, which would give the API something to translate new values to for old clients. That would allow these to work, and users would almost never run into them. There are some downsides, and maybe there are alternative solutions. Just something to think about.
🤖 Claude Opus notes on other kinds of API changes that run into this problem
A useful frame first: translation works exactly when there's an information-preserving mapping in the direction you need. It's asymmetric — requests go old→new (up), responses new→old (down) — so breaks fall into two buckets, plus some the body-translation layer can't touch.
Response direction (new→old): server has data the old schema can't hold
All share the enum shape — the new value set is larger than the old closed set, so there's no target value.
- New enum variant (your case) or new polymorphic subtype in a tagged union.
- Widened scalar domain —
u32→u64, a range that grows, a closed status enum gaining values. Worse than enums: often no sensible catchall, so clamping is silently wrong data. - Cardinality change — single object becomes a list; down-translating is lossy when len > 1.
- New structured error codes — same break in the error path, easy to overlook.
Request direction (old→new): old client can't supply what the new server needs
The break is when up-translation must synthesize information it doesn't have.
- New required field with no safe default. Jumbo-frames worked because "off" is fine; a field that genuinely needs a value can't be backported — old clients only get default behavior, which may be wrong.
- Removed/narrowed variant or tightened validation. Old client sends a now-invalid value; if there's no survivor to map to, rejection is correct but still a break.
- Field split — one old field maps to several new ones it underdetermines.
Below the translation layer (can't help at all)
- Removed/renamed endpoints — no type mapping saves you; needs a compat shim, not translation. Auth/header/transport changes likewise out of scope.
Mitigations
- Reserve extension points up front — catchall variants,
additional_propertiesmaps, optional/defaulted fields. - Policy + CI lint: every new request field must have a server-side default for older versions — the request-side analog of the catchall, mechanically checkable from a schema diff.
- Client-robustness contract (the real fix): clients and progenitor codegen treat unknown variants / extra fields as non-fatal and degrade. Catchalls are one mechanism for this "must-ignore" semantics.
- Schema-diff lint in CI flagging every non-translatable change above — added variant without catchall, new required field without default, widened type, removed field/endpoint, cardinality change. RFD 619's "future work" gestures at linting; this is what to lint for.
- Accept-and-gate — for genuinely incompatible changes, refuse just the affected operation with a "please upgrade" prompt (531 already provides the prompt path) rather than breaking broadly.
Three caveats on the catchall
- Forward-looking only. The server can only emit a catchall if that version's blessed schema already has it, so you must add catchalls everywhere now. It prevents the next #10560; it doesn't fix the v20 console retroactively — clients built before the catchall still break.
- Degradation, not function.
cpu_platform: Unknownwon't crash but can't render the real platform. "Doesn't error" is the win. - Request-position catchalls are a hazard, and round-trip fidelity bites. A catchall in a request enum lets clients send garbage the server must reject anyway — you may want split request/response enum types. And for read-modify-write (console GET then PUT), the catchall must preserve the original payload so the server can recover the real variant on the way up; a bare marker breaks editing of any object holding an unknown value. (Moot for server-assigned read-only fields like
cpu_platform— worth confirming which enums are actually editable first.)
Edit: turns out this is discussed in RFD 421 Using OpenAPI as a locus of update compatibility. Maybe we should use the non_exhaustive tag and then have the schema generator do something based on it, rather than explicitly adding an unknown variant in the definition of each enum.
It is common to add a new variant to a sum type (enum). Is this a breaking change? The answer likely varies depending on whether the type is used as an input or output. For an input, an additional variant is similar to adding an optional field or parameter: existing clients with existing calls will continue to function [not breaking]. However! When used as a response (e.g. a new state for some entity), we must treat this as breaking since clients will not be expecting the state.
Rust has a mechanism for expressing the possibility that types might grow new fields (both structs and enums): #[non_exhaustive]. While this is natural in rust, a new enum variant would, effectively, look like an unexpected response. It is feasible to express in OpenAPI / JSON Schema, but it could present challenges in understanding to both humans and code generation tools (i.e. those not written by us).
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with nexus/types/versions/src/instance_cpu_type_turin_v2/instance.rs and compare the enum translation failure from #10560 with the optional-field case in #10472. Read RFD 421, especially its discussion of non_exhaustive enums and response compatibility, then determine which compatibility strategy and schema-generator changes the project should adopt. Done means the approach and affected API-change cases are decided.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- openapi, rust
- Domain
- api, backend-api-design
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Needs clarification
- Newbie friendliness
- 30/100