A New Enum Value Breaks Clients That Trusted Your List

TL;DR: A new enum value is compatible on the schema and breaking for every client that validates responses against a closed list. Read response fields as text and keep the known list for branching. Send from a closed list. Shipping the tolerant client first helps, but it protects only the clients that upgrade.
On an installed client older than 0.22.0, a plain listing call fails. Nothing in the request is wrong. The server has started naming a fourth value in a field the client reads as one of three, and the client refuses the whole response.
A developer on Hacker News described the same moment from the server side, as the person adding the value: "Maybe some customer wasn't even using that field in the returned API object, but now I've broken their code." (kelnos, Hacker News(opens in new tab))
That is what a new enum value does to a closed client, and most API guidance calls the change compatible.
Every guide says a new enum value is compatible
On the schema, it is. Buf's breaking-change checks treat added enum values as compatible; the page says so while explaining its wire-compatibility rules (Buf docs(opens in new tab)). In a 2017 graphql-js issue, Lee Byron called it "not a schema-breaking change" because "all existing queries continue to behave correctly" (graphql-js #968(opens in new tab)). Protobuf's proto3 went further and made enums open, "specifically because of the unexpected behavior that closed enums cause" (protobuf.dev(opens in new tab)).
The same sources hedge. Byron adds that if clients don't code defensively, expanding the possible values "could cause issues for those clients - it's not an entirely safe change." The API Enhancement Proposals say adding values "has the potential to be disruptive to existing clients" (AEP-126(opens in new tab)).
Three clients that met a value they did not know:
| Client | What it did | Source |
|---|---|---|
Checkout.com .NET SDK | Given an unrecognised value such as UNKNOWN or NotSet for a nullable field, it threw, "turning a missing metadata field into a full payment failure" | PR #556, merged May 2026 |
Fingerprint .NET SDK | When the API added a value to a string enum, the SDK "failed to deserialize the event" | PR #179, merged June 2026 |
Google Ads PHP library | Asking for the name of a newly added value threw "has no name defined for value 41". A protobuf contributor answered that parsing is tolerant, only the name and value lookups throw, and "There is no breaking change" | protobuf #16857 |
In the first two the failure takes down the whole response, not just the field. The third is the disagreement in miniature: the schema says nothing broke, and the code that asked for the name says otherwise.
The consensus is that adding a value isn't breaking, and that the client should cope. We agree with the second half. We reject the first for response fields. If a client validates what it reads, the server just changed what that client accepts.
What happened to our clients
Clipwright(opens in new tab) is our agent-native video API. Its clients read tts_model in a quote, and model in list_voices, as one of three fixed values. When the server named a fourth, those clients failed.
The failure was bigger than the field. For clients older than 0.22.0, list_voices fails whenever the listing holds a voice on the new model, and the unfiltered listing always does. quote fails for those voices. A caller who only wanted to browse lost the call.
This is the shape, as an illustration written for this post. It is not Clipwright's source, and the model names are placeholders.
1import { z } from 'zod'
2
3// Closed: the client promises the server will never grow.
4const VoiceClosed = z.object({
5 id: z.string(),
6 model: z.enum(['alpha', 'beta', 'gamma']),
7})
8
9// One row with model: 'delta' makes the whole listing throw.
10z.array(VoiceClosed).parse(response)
Our fix: from 0.22.0 the clients read those two fields as text.
1// Open on read: any text parses, the known list is only for branching.
2const KNOWN_MODELS = ['alpha', 'beta', 'gamma'] as const
3const VoiceOpen = z.object({ id: z.string(), model: z.string() })
4
5const isKnown = (model: string): model is (typeof KNOWN_MODELS)[number] =>
6 (KNOWN_MODELS as readonly string[]).includes(model)
Ship the tolerant client first, and know what it buys
The order was deliberate. The tolerant client shipped first, in 0.22.0. The server began naming the new value by default in the next release, 0.23.0. Every client older than 0.22.0 still fails on that value, and that is why the 0.23.0 release is marked Breaking.
Release order: the tolerant client first, then the server. An installed client older than 0.22.0 cannot be upgraded from our side.
The order helps one group: clients that upgrade to 0.22.0 before the server names the new value read tts_model and model as text, so a new value in those two fields no longer fails the parse. An installed client older than 0.22.0 is the other group. Nothing on the server can reach into someone's machine and upgrade it, so the order did not save those clients. The Breaking marker is there for them.
That is the rule we kept: adding an enum value is a breaking change for every client that validates responses, even when the tolerant client shipped first.
Read as text, send from a closed list
Tolerance applies to reading only. For sending, a closed list is still right. Clipwright's CLI and MCP server accept a new tts_model value only from the version that knows it. A client that has never heard of a value can read it, but it will not send it. That is the idea in Don't Show an Agent a Field You Will Reject, applied to the way out: what you offer is smaller than what you read.
Others split it the same way, with a narrower rule. Microsoft's evolvable-enums pattern gives reads a placeholder member, unknownFutureValue, and says a request that sends the placeholder in a POST or PUT must be rejected with 400 Bad Request (Microsoft Graph API guidelines(opens in new tab)). That covers the placeholder only. It doesn't say a client must refuse values newer than it knows.
Keboola's MCP server shows the same split on a read path, though not for a new value. A flow stored with a function name that its model didn't list, YEAR against Literal['COUNT', 'DATE'], made a single bad task take down the entire get_flows response. In review, the PR author wrote that the backend accepts only COUNT and DATE, so the value was a malformed config, not something the backend had added. The fix still fits this post: the read path now logs a warning and keeps the rest of the flow returnable, while "write paths ... stay strict so agent-built flows still get loud validation feedback" (keboola/mcp-server #530(opens in new tab), merged May 2026).
A quiet default on the read side is the mirror of the silent substitution we argue against in Never Answer an Agent With a Silent Substitution. If a client can't place a value, it should name it, not swap in another.
One warning from the standards world. The IAB's RFC 9413 looks back at the old advice to be liberal in what you accept, and says that "relying on implementations to consistently handle unexpected input is not a good strategy for extensibility." What it wants instead is a mechanism where "new messages or parameters thereby become entirely expected" (RFC 9413(opens in new tab)). Say in your contract that the set is open. Don't leave clients to find out by crashing.
Check your own client in ten minutes
Search the client for z.enum, Literal[...], strict enum converters and generated types applied to response bodies. Change one response field to a string, keep the known list for branching, and run a test with a made-up fourth value. Leave the fields you send as they are.
FAQ
Is adding an enum value a breaking change?
For the schema, no: existing requests and existing values still work. For a client that validates responses against a closed list, yes, because a value it has never seen makes the parse fail, often for the whole response.
Should API clients use enums for response fields at all?
Use them for fields your client sends, where a closed list protects you. For fields your client only reads, a string plus a known-values check keeps the typing without the crash. AEP-126 says that for enums that change frequently the API "should use a string and document the format."
If the client reads an unknown value, should it send it back?
No. Read it and log it. Sending belongs to a closed list that only the version that knows the value should extend.
Sources
- kelnos on Hacker News(opens in new tab): adding an enum value to a response breaks customers' deserialization
- checkout-sdk-net #556(opens in new tab), dotnet-sdk #179(opens in new tab), protobuf #16857(opens in new tab)
- keboola/mcp-server #530(opens in new tab): a closed
Literalon a read path, tolerant read and strict write - Buf breaking rules(opens in new tab), graphql-js #968(opens in new tab), AEP-126(opens in new tab), protobuf enum guide(opens in new tab)
- Microsoft evolvable enums(opens in new tab), RFC 9413(opens in new tab)
- Agent Native API Without OpenAPI, Warnings Are for Humans, Refusals Are for Agents
A new value in a response can break the client that reads it.
Your agent's client fails on a value nobody warned it about. Clipwright's CLI and MCP server read tts_model and model as text, and still send only values they know.
About the Author
Dimantika
Co-founder of Dimantika. Builds Clipwright and ViralFaceless with coding agents. Previously ran GlockSoft with a partner for about 15 years. Writes about products and finding customers.
View all postsRelated posts
More articles you might like.

Failed Is Not a Terminal Status: A Contract for Agents
A run can fail on our own timeout while the paid work can still finish. Why an agent should not treat failed as a terminal status without a reason it can read.

I Used to Handle Marketing. Now I Manage Coding Agents Too.
Coding agents have changed my working day. I still have to decide who the products are for, and I want more than my own assumptions to work with.

What One Second of AI Video Costs Us, Layer by Layer
One clip passes through five paid layers. We measured one of them with a wallet. The rest are price lists and estimates, and that gap is the honest answer.