An API change can be valid and still break its consumers
Reason about request and response compatibility separately, test actual consumer behavior, and choose a migration order that preserves the contract.
The decision
A job API adds paused to a response enum that previously contained queued and done. The updated description is well formed. The server returns a payload allowed by that description. An older worker receives it and throws: its dispatch function has two branches and refuses to guess what a third state means.
No field disappeared. No type became obviously wrong. Both versions can describe legitimate APIs. The release still needs a migration because validity answers a different question from compatibility.
There are three distinct checks here. Parsing establishes that a document is syntactically readable. Standards validation establishes that the description and its schemas obey the chosen dialect, and that particular payloads satisfy those schemas. Compatibility asks whether supported participants can still fulfill their obligations together. Even passing the first two checks does not establish the third. A change can also preserve structural compatibility while becoming less useful, for example by never returning a completion state that a polling client needs.
The engineering decision is specific: can the new server replace the old one while supported clients keep working? To answer it, identify which side produces each message, which side consumes it, and what the consumer must do after decoding it. A schema diff is useful evidence, but it cannot supply those obligations on its own.
This article uses a small, vendor-neutral job fixture. It makes the direction of compatibility explicit and demonstrates failures without pretending to implement an OpenAPI validator.
Name the direction before judging the change
For a request, the client produces data and the server consumes it. For a response, the server produces data and the client consumes it. During a server upgrade, the client may remain unchanged on both sides.
Let R_old and R_new be the request payload sets accepted by the old and new server contracts. Let S_old and S_new be the response payload sets allowed by those contracts. A conservative structural rule for replacing the server is:
requests: R_old ⊆ R_new
responses: S_new ⊆ S_oldThe new server must accept what existing clients could send. Its responses must stay within what those clients were promised. Requests may widen; responses may narrow. These are directional conditions, not a universal definition of compatibility imposed by OpenAPI.
The word “allowed” matters. An implementation may emit only a small part of its documented response space. A schema change that admits a new value might not break anyone today, but it permits a later implementation change that does. Decide whether your gate protects documented promises or only observed traffic. Policy choice: this article treats the documented space as the structural release boundary, then asks for consumer behavior evidence as a separate condition.
That boundary is conservative. An older client might handle more than its schema promises. Conversely, a client might depend on properties that its schema leaves optional. Such cases require explicit consumer evidence rather than silently redefining either contract.
“Required” has several locations and two directions
OpenAPI 3.1.1, §4.8.24, defines Schema Objects using JSON Schema Draft 2020-12 semantics unless otherwise stated.[1] Within an object schema, required is an array of property names. Draft 2020-12 Validation §6.5.3 says an object is valid when every listed name is present; omitting the keyword behaves like an empty array.[2]
Defining a property does not require its presence. Core §10.3.2.1 applies properties schemas to names appearing in both the instance and the schema.[3] Thus this object allows omission of priority, while requiring a string if it appears:
{
"type": "object",
"properties": {
"id": {"type": "string"},
"state": {"type": "string", "enum": ["queued", "done"]},
"priority": {"type": "string"}
},
"required": ["id", "state"]
}Changing required to ["id", "state", "priority"] shrinks the accepted object set. Applied to requests, that rejects an old legal payload such as {"id":"j1","state":"queued"}. Applied to responses, it strengthens a guarantee: every new response includes the already-declared property. The structural direction is safe for an old schema, assuming no other constraint changes.
This distinction concerns an existing property. Adding a new property and requiring it changes both presence and shape. An old closed object schema or strict decoder may reject that addition.
| Isolated change | Request consumed by new server | Response consumed by old client |
|---|---|---|
| Existing optional property becomes required | Narrows acceptance; old omission breaks | Narrows outputs; strengthens presence guarantee |
| Existing required property becomes optional | Widens acceptance | Widens outputs; old client may need the missing value |
| Enum gains a value | Widens acceptance | Widens outputs; old exhaustive client can fail |
| Enum loses a value | Narrows acceptance | Narrows outputs; check remaining behavior obligations |
These classifications assume unchanged types, value constraints, and object openness. They establish structural direction. Removing a response enum value can still impair usefulness if clients require that state to complete a workflow.
OpenAPI also has boolean required fields at different levels. In §4.8.13 it determines whether the request body itself is required and defaults to false. In §4.8.12 it controls an operation parameter; path parameters must be required.[4] Neither is the object-property array above. A review finding should name the exact location, such as “request JSON body, property priority,” rather than merely reporting “required changed.”
Optional also does not mean nullable. An absent state and "state": null are different instances. A string-only property rejects null when present. Allowing null expands its possible values, which can break a response consumer that expects a string. Likewise, an annotation describing a default is not evidence that the deployed server inserts it; defaulting behavior needs its own implementation check.[5]
An enum addition is an executable counterexample
Draft 2020-12 Validation §6.1.2 says an instance validates against enum when its value equals one of the array elements.[6] Adding paused therefore admits another value. It does not teach an older client how to act on that value.
The fixture's original consumer is deliberately exhaustive:
def old_worker(job):
if job['state'] == 'queued':
return 'schedule'
if job['state'] == 'done':
return 'archive'
raise ValueError('unsupported state: ' + repr(job['state']))For the updated response {"id":"j1","state":"paused"}, the new producer rule accepts the payload. The old rule rejects it, and the actual old consumer raises ValueError. An updated worker returns hold.
Refusing unknown values is a consumer policy, not a specification requirement. Here it prevents accidentally scheduling paused work. A fallback that maps every unknown state to queued could avoid the exception while producing the wrong business action. “The client did not crash” is therefore an insufficient acceptance criterion.
A tolerant client can make a future enum addition easier: preserve the raw value, represent an unknown state explicitly, and choose an approved safe action. For this worker, holding unknown jobs might be appropriate. For a dashboard, displaying an unrecognized state might suffice. For settlement logic, guessing may be unacceptable. Those decisions belong to the workflow owner and must be tested before relying on tolerance during rollout.
Even an optional response addition needs consumer evidence
Our illustrated schema omits additionalProperties. Core §10.3.2.3 says omission has the same assertion behavior as an empty schema; with these flat schemas, unknown properties are allowed.[7] Consequently, a response containing an optional priority string can satisfy the old open object schema.
The fixture nevertheless includes a strict decoder that accepts only id and state. It rejects that response. This is a narrower implementation contract than the schema describes. The example does not establish that optional additions generally break clients. It establishes that schema acceptance and decoder acceptance can disagree.
That disagreement deserves an owner. Either the server promises a closed shape and documents it, or the client must tolerate permitted additions, or the release must preserve that client's narrower interface for a declared support period. A hidden strict decoder cannot be resolved by calling an additive change “obviously safe.”
The reverse direction matters too. Closing a previously open request object with additionalProperties: false rejects old requests carrying extra keys. Whether those keys were ignored or used elsewhere, they were structurally allowed. Inspecting only required fields misses this restriction.
Run the bounded experiment
The accompanying fixture.py needs Python 3 and only the standard library. From its directory, run:
python3 fixture.pyIt uses handwritten rules for these flat job objects and actual consumer functions. It takes no OpenAPI document as input, does not validate schema syntax, and does not resolve references or implement arbitrary JSON Schema keywords. The printed labels describe the illustrative rules, not certification by a standards validator.
The recorded normal and optimized (python3 -O fixture.py) runs both pass ten checks. Explicit runtime comparisons remain active under optimization; printed actions and acceptance values come from evaluated results. They include the request omission rejected after making priority required, the inverse response omission, enum widening, an optional response addition rejected by a strict decoder, missing versus null, and closing an open request object. Expected consumer failures count as successful counterexample checks.
One check enumerates 90 objects over a declared finite alphabet. It confirms that requiring priority narrows acceptance and widening state expands it within that alphabet. This is bounded evidence, not a proof over every JSON instance. The inclusion reasoning above comes from the isolated rules; the enumeration makes representative witnesses inspectable.
Retain the script hash and execution receipt when reviewing the manuscript. Then replace the illustrative consumers with the oldest supported SDK or application versions. Exercise decoding and business actions together. A new-schema payload accepted by the producer and rejected by an old consumer is a concrete release finding; a clean synthetic run cannot establish compatibility for untested clients.
Choose a migration that matches deployment reality
For a required request property, deploy acceptance first. Continue accepting omission and implement a documented default or legacy path. Upgrade clients to send the property, observe usage, and enforce the requirement only at a declared boundary. This costs temporary server complexity. It also avoids requiring every client to upgrade simultaneously. Do not silently default when omission changes the meaning of the operation.
For a new response enum value, upgrade consumers before enabling production of that value. A feature flag can separate deployment from emission, but its disabled path must actually preserve the old response space. Test rollback too: an old server can reject new request values, while an old client can reject new response values already stored in durable records.
Versioning offers a clearer separation when the semantics cannot coexist. It costs parallel implementations, documentation, and a retirement process. An adapter can reduce that cost, provided it has a meaningful mapping. Mapping paused to queued would destroy the distinction this worker needs; returning a compatible label is not enough.
A coordinated cutover may be reasonable for a small, controlled fleet. Mobile clients, external integrations, cached SDKs, and dormant batch jobs complicate that assumption. Traffic observations can show which versions are active; they cannot prove an offline client has disappeared. Policy choice: define a support window and require explicit approval for ending it, rather than treating a quiet dashboard as permission to break old callers.
Compatibility work also has a cost in the other direction. Indefinitely accepting legacy requests can preserve ambiguity and delay validation improvements. Indefinitely suppressing new response states can prevent useful product behavior. A good migration states when the stronger contract becomes available, who must move, and what evidence permits retirement.
A checklist for the release review
Before approving an API change, record:
- Replacement direction. Which server and client versions must coexist? Is rollback included?
- Message location. Request or response; body, parameter, header, status, or media type; exact schema dialect and property path.
- Changed set. What formerly allowed request is now rejected, or what newly allowed response exceeds the old promise? Include a witness.
- Consumer obligation. Decode successfully and produce which action? Check exhaustive branches, null handling, missing values, and unknown-property policies.
- Evidence boundary. Which real client versions ran? Which references, formats, compositions, or workflows remain unchecked?
- Migration boundary. Acceptance order, emission control, owner, support deadline, rollback behavior, and retirement criterion.
If the only evidence is that the new description is valid, the compatibility question remains open. Start with one supported consumer and one payload admitted by the change. Observe whether that consumer completes the intended task. That small experiment turns “additive” or “valid” from reassuring labels into a release decision with an inspectable basis.
Primary references
[1] OpenAPI 3.1.1, §4.8.24: Schema Object.
[2] JSON Schema Draft 2020-12 Validation, §6.5.3: required.
[3] JSON Schema Draft 2020-12 Core, §10.3.2.1: properties.
[4] OpenAPI 3.1.1, §4.8.13: Request Body Object and §4.8.12: Parameter Object.
[5] JSON Schema Draft 2020-12 Validation, §9.2: default, under §9, schema annotations. Default insertion requires application behavior beyond the keyword's specified metadata.
[6] JSON Schema Draft 2020-12 Validation, §6.1.2: enum.
[7] JSON Schema Draft 2020-12 Core, §10.3.2.3: additionalProperties.
Run the example
Download the complete Python example. Extract the archive and follow its README. The examples are synthetic demonstrations, not production benchmarks.
مواصلة القراءة
Design a verification API that cannot confuse incomplete with passed
Separate execution status, check outcomes, and missing evidence so downstream systems cannot mistake an incomplete run for permission to proceed.
When a dependency graph is incomplete, widen the test plan
Use explicit uncertainty boundaries to decide which tests an incomplete dependency map can safely exclude, with an executable fallback example.
Bind an approval to the action that will actually run
Define an exact action identity, distinguish integrity from authority, and reject changes between review and execution.
