Contract evolution: breaking changes, spec diffs, versioning and deprecation
A request may accept more; a response may never send what an old client cannot read.
Predict first
Before you read: predict
ExamHall's proctor console is a desktop app whose builds lag the web by weeks. It reads GET /attempts through a zod schema written for today's contract, and its flag button sends { "kind": "gaze", "note": … } to POST /attempts/{id}/flags. The API team plans four changes for one release: (1) the flag request's kind also accepts "phone"; (2) an attempt's seat becomes optional in the response; (3) an attempt's status may also be "paused"; (4) the flag request requires a new severity field (1 to 3).
Which of them break a console build from six weeks ago?
The 30-second version
Clients you cannot redeploy keep calling your old contract. A request change is safe if the new server still accepts what old clients send; a response change is safe if old clients still accept what it sends. Diff the OpenAPI document in CI, base first. Rename by expanding; announce the deprecation with Deprecation, and the expected removal with Sunset.
A request may accept more; a response may never send what an old client cannot read.
How it works
Step 1 of 12
In the kitchen
- operators' programs, updated whenever their owners get round to it → clients you cannot redeploy (step 1)
Analogy: A radio contest's log format (amateur radio)
A radio contest organiser takes logs from hundreds of operators, sent by logging programs each operator updates when they like, and publishes a results file that clubs' scoring spreadsheets read.
| At the contest | In the API |
|---|---|
| operators' programs, updated whenever their owners get round to it | clients you cannot redeploy (step 1) |
| logs coming in, results going out: two different checks | requests and responses judged in opposite directions (step 2) |
| the log checker starts accepting a new band: no old log is bounced | a request accepting more (step 3) |
| a new mandatory box on the log: every old program's log bounces | a newly required request field (step 3) |
| a new entry category in the results file: a spreadsheet with a fixed list of categories stops | a value the old reader lacks (step 4) |
| an extra column in the results file that spreadsheets skip | a new response field and a tolerant reader (step 5) |
| "format 2 is superseded from 1 November" | Deprecation (step 11) |
| "format 2 logs are refused after 31 March" | Sunset (step 12) |
In real terms: before shipping, ask who reads what this change produces, and whether their old code still accepts it.
Breaks down when: a bounced operator gets an email and resends; an old client fails in a user's hands, often with no one to tell.
Contrasting case: a frontend and its API deployed together from one repository, with no other caller: every reader is redeployed with the change.
Examples, run for real
4.1 ExamHall EXH-3650: the one-step rename
The proctor API renamed flagged to suspicionLevel in one release. Desktop consoles six weeks old failed on every GET /attempts (EXH-3650). Run on the spec after the fact, the diff gate MR !3950 later added would have failed it:
EXH-3650 one-step rename · old console (6 weeks old) throws at 0.flagged · openapi-diff main -> MR: 1 breaking [response.body.scope.add]
MR !3950 adds that gate to CI, base branch first, and replaces the rename with two releases, the second shipped only after the old field's Sunset date:
MR !3950 release 1, both fields sent · old console reads it · new console reads it
MR !3950 release 2, after the Sunset date, flagged removed · old console throws at 0.flagged · new console reads it
Release 2 still breaks any console left on the old build: the Sunset date tells those owners when (step 12), and the gate's failure is expected and signed off for that one release (steps 7, 9).
4.2 Ticketing: four edits, two directions
A box office's app from last month sends { "eventId": "ev-77", "seatType": "wheelchair" } and reads events through a zod schema. Each edit runs against it:
request: `seatType` drops "wheelchair" · remove-enum-value · last month's app: its request gets 422
request: optional `promoCode` added · add-optional-field · last month's app: its request gets 201
response: `venue` removed · remove-field · last month's app: its reader throws
response: optional `accessibleEntrance` added · add-optional-field · last month's app: reads the response
The two additions are safe: the server still accepts the old request (step 3), and the old reader skips the unknown field (step 5). The removals break in both directions: the server refuses a value it once accepted, and the reader misses a field it requires (steps 3–4).
Picture it

Try it
Answer first, then check. Nothing is saved.
Question 1 of 7
A payments ledger's partners call POST /transfers and GET /transfers/{id}. One Transfer schema is both the body of POST /transfers and the response of GET /transfers/{id}, and it requires amountCents, currency and reference. A release makes reference optional in Transfer, because the ledger's own fee transfers have none. A partner's month-old integration always sends a reference and reads transfers through a zod schema written before the release.
Where does the release break that integration?
Question 2 of 7
A video platform's CI job compares the OpenAPI document on main with the pull request's and fails the build on any breaking difference. Its step is npx openapi-diff pr/openapi.json main/openapi.json (openapi-diff 0.24.1). A pull request makes a new profileId field required in the body of POST /playback-sessions, and the job passes. The TV apps shipped last month send no profileId.
Why did the job pass?
Question 3 of 7
From 1 November 2026, every response of ExamHall's v1 GET /attempts carries Deprecation: @1793491200 and Sunset: Wed, 31 Mar 2027 23:59:59 GMT. On 1 December 2026 a developer on the desktop console team reads them and plans the team's next releases.
Which plan fits what the two headers say?
Question 4 of 7
A box office's partner API ships v2 of /holds on 1 November 2026, and from that day v1 is no longer the recommended version. No date has been set for switching v1 off.
Which header should v1 responses start carrying that day?
Question 5 of 7
The interviewer says: "ExamHall's mobile proctor app cannot ship an update for three months, but we need to rename flagged to suspicionLevel in the attempts API. Walk me through what you would do".
Write 5–8 sentences, release by release.
Question 6 of 7
A chat API's team is retiring v1 of GET /rooms. Every v1 response now carries Deprecation: @1806537599 (31 March 2027) and Sunset: Sun, 01 Nov 2026 00:00:00 GMT. A partner developer reads both on 1 October 2026 and has to plan her release.
What should she do?
Question 7 of 7
MR !3952 lets submittedAt be null (nullable: true) in ExamHall's Attempt response, for attempts still in progress, where it used to send an empty string. The CI step is npx openapi-diff main/openapi.json mr/openapi.json (openapi-diff 0.24.1), and the job fails the build on any breaking difference. A proctor console build from six weeks ago reads attempts through a zod schema whose submittedAt is z.string().
What does the job do, and what does that console do with an in-progress attempt once the MR ships?
Go deeper
Pro tips
- Plant known breaks to learn what your diff gate misses. When: adding a spec-diff job to CI (steps 7–8). Copy the spec, make one breaking edit at a time, and run the job on each. Verify (RAN, openapi-diff 0.24.1,
tips.mjs):
Cover what it missed with another rule, or with the pair test oftip 1 openapi-diff 0.24.1, 4 planted breaks · caught 2: response field removed (`flagged`): 1 breaking, 0 other; response field retyped (`flagged`: boolean to string): 1 breaking, 1 other tip 1 missed 2: new response enum value (`status: paused`): 0 breaking, 0 other; request enum value removed (`kind: audio`): 0 breaking, 0 otherscale-version-skew-n-minus-onestep 11. - Point deprecated responses at the migration guide. When: first sending
Deprecation(step 11). AddLink: <https://…/migrate-v2>; rel="deprecation"; type="text/html". Source (cited, RFC 9745 §3): "The resource provider can provide a link to the resource's documentation using a Link header field with the relation type deprecation".
Source
Valid as of 2026-10-12.
- IETF, RFC 9745: The Deprecation HTTP Response Header Field (March 2025; the row's second source), https://www.rfc-editor.org/rfc/rfc9745.txt (
rfc9745-deprecation.txt). "The act of deprecation does not change any behavior of the resource." (step 11); "The timestamp given in the Sunset HTTP header field MUST NOT be earlier than the one given in the Deprecation header field." (step 12); the exampleDeprecation: @1688169599(step 11). - IETF, RFC 8594: The Sunset HTTP Header Field (May 2019; the row's third source), https://www.rfc-editor.org/rfc/rfc8594.txt (
rfc8594-sunset.txt). "The Sunset value is an HTTP-date timestamp" (step 12). - OpenAPI Initiative, OpenAPI Specification v3.2.0 (September 2025; the row's first source), https://spec.openapis.org/oas/v3.2.0.html (
oas-3.2.0.txt). Supports step 9 ("Declares this operation to be deprecated. Consumers SHOULD refrain from usage of the declared operation. Default value is false."). The scripts' documents are OpenAPI 3.0.3, which openapi-diff 0.24.1 reads; no item depends on a 3.2.0 feature. - Semantic Versioning 2.0.0, https://semver.org/ (
semver-2.0.0.txt; the row's last source). Supports step 10: "MAJOR version when you make incompatible API changes". Keys no item:fsd-design-systemsstep 11 already teaches the minor/major split for a component library. - Tool, not a source: openapi-diff 0.24.1 (npm, Atlassian), whose output the scripts grade as executed fact about that version.
Related lessons
- Read first: Status codes and the error contract (RFC 9457 Problem Details)
- Read first: HTTP method semantics, safe, idempotent, PUT vs PATCH vs POST
- Related: Version skew: every release works with N−1
- Related: Zero-downtime schema migrations: expand, migrate, contract
- Related: Contract thinking from the caller's side
- Related: Types vanish at runtime, validate at the boundary
Checked against the tools and docs as of 2026-10-12.