Breaking Change Detection

Part of the API Versioning & Deprecation reference. Versioning strategy answers how to ship an incompatible change; detection answers the prior question — whether the change you are about to merge is incompatible at all. This section covers the classification rules, the CI machinery that applies them, and the categories of break no diff tool can see.

Problem framing

Most breaking changes are not decisions. Nobody proposes “let us break the mobile app”; someone renames a field for clarity, marks a parameter required to tighten validation, or removes an enum value that “nothing uses”. Each looks local, and the consumer that breaks is three teams away and finds out in production.

A diff in CI converts that from a judgement call into a mechanical check: every pull request is compared against the specification as released, each change is classified, and a breaking finding fails the build until someone explicitly accepts it. The classification is not arbitrary — it follows from one asymmetry about who sends what, described below. What a diff cannot see is behavioural change, which is why detection also needs contract tests. Together they make the question “is this breaking?” answerable before merge rather than after deploy, and they feed the version derivation used in Client SDK Generation Pipelines.

The asymmetry that decides everything

Clients send requests and receive responses. Therefore relaxing what you accept is safe, and relaxing what you promise is not.

Request and response compatibility directions For requests, accepting more is safe and accepting less is breaking. For responses, promising more is safe and promising less is breaking. Examples are listed under each direction. Requests — what you accept Responses — what you promise SAFE — accept more • make a required field optional • widen a range or a max length • add an accepted enum value BREAKING — accept less • make an optional field required • narrow a range or a max length • remove an accepted enum value SAFE — promise more • add a response field • make an optional field always present • narrow a value range you return BREAKING — promise less • remove or rename a response field • make a guaranteed field optional • add an enum value clients must handle

The last item on the bottom-right is the one that generates the most argument. Adding a response enum value promises less in the sense that clients can no longer rely on the previously exhaustive set — and generated clients frequently model enums as closed, so an unknown value throws during deserialisation. It is not as severe as removing a field, but it is not free either, which is why it deserves its own policy.

Classification reference

Change Requests Responses
Add optional field safe safe (tolerant readers)
Add required field breaking safe
Remove field safe (ignored) breaking
Rename field breaking breaking
Optional → required breaking safe
Required → optional safe breaking
Widen type (integernumber) safe breaking
Narrow type breaking safe
Add enum value safe risky — see policy
Remove enum value breaking safe
Add endpoint safe safe
Remove endpoint breaking breaking
Add 2xx status code safe risky
Remove 2xx status code breaking
Tighten maxLength breaking safe

Spec definition: what CI compares

# .github/workflows/api-compat.yml
name: API compatibility
on: [pull_request]

jobs:
  diff:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }

      - name: Bundle both revisions
        run: |
          npx @redocly/cli bundle openapi.yaml -o /tmp/head.yaml --dereferenced=false
          git show "origin/${{ github.base_ref }}:openapi.yaml" > /tmp/base-src.yaml
          npx @redocly/cli bundle /tmp/base-src.yaml -o /tmp/base.yaml --dereferenced=false

      - name: Classify the diff
        run: |
          docker run --rm -v /tmp:/specs tufin/oasdiff:latest \
            breaking /specs/base.yaml /specs/head.yaml \
            --format githubactions --fail-on ERR

      - name: Summarise all changes for the reviewer
        if: always()
        run: |
          docker run --rm -v /tmp:/specs tufin/oasdiff:latest \
            changelog /specs/base.yaml /specs/head.yaml --format text \
            >> "$GITHUB_STEP_SUMMARY"

Bundling both sides before diffing matters: a change that only moves a schema between files is not a change at all, and an unbundled diff reports it as one, training reviewers to ignore the tool.

Core pattern 1: baseline against what shipped, not the main branch

Diffing against the main branch answers “what changed since the last merge”, which is not the question consumers care about. They hold the released contract, so that is the baseline:

# scripts/baseline.sh — capture the specification as released
set -euo pipefail
TAG="${1:?usage: baseline.sh <release-tag>}"

git show "${TAG}:dist/openapi.yaml" > "baselines/${TAG}.yaml"
ln -sf "${TAG}.yaml" baselines/current.yaml
echo "baseline set to ${TAG}"

With a released baseline, a pull request that reverts an unreleased break correctly reports no breaking change, and a series of individually-safe merges that combine into a break is still caught.

Core pattern 2: an override with a paper trail

Breaking changes must be possible — an API that can never break cannot be improved — but they must leave evidence:

# .oasdiff-ignore.yaml — every entry needs an owner, a reason and an expiry
- id: response-property-removed
  path: /v1/legacy-report
  reason: >
    Removing `estimated_total`, which has returned null for 14 months.
    Confirmed unused by all 6 remaining consumers (see ticket API-2291).
  approved_by: platform-team
  expires: 2026-12-31
# CI step: expired suppressions fail the build
python3 - <<'PY'
import sys, yaml, datetime
today = datetime.date(2026, 7, 31)          # supplied by CI as the run date
entries = yaml.safe_load(open(".oasdiff-ignore.yaml")) or []
stale = [e for e in entries if datetime.date.fromisoformat(str(e["expires"])) < today]
for e in stale:
    print(f"::error::suppression for {e['id']} on {e['path']} expired {e['expires']}")
sys.exit(1 if stale else 0)
PY

Expiry is what stops the ignore file from becoming a graveyard where every rule is permanently disabled.

Core pattern 3: catching what the diff cannot see

A specification describes shape, not behaviour. These changes are invisible to any diff and break consumers just as effectively:

Behavioural breaks a spec diff cannot see Changed default values, changed sort order, tightened rate limits, changed validation strictness and changed error codes are invisible to a diff. Each is caught by a contract test, a replay of recorded traffic or a limit assertion. invisible to a spec diff • default sort order changed • default page size changed • rate limit lowered • new server-side validation rule • error `type` URI reworded • timing/latency contract changed • optional field now always null contract tests assert ordering, defaults, error codes traffic replay compare old and new responses per request limit assertions quota and timeout values checked in CI
// behaviour.contract.test.ts — properties no diff can verify
import { describe, it, expect } from "vitest";

describe("behavioural contract", () => {
  it("returns newest-first by default", async () => {
    const { data } = await get("/invoices");
    const dates = data.map((i: any) => i.created_at);
    expect([...dates].sort().reverse()).toEqual(dates);
  });

  it("keeps the documented default page size", async () => {
    const { data } = await get("/invoices");
    expect(data.length).toBeLessThanOrEqual(50);
  });

  it("keeps error type URIs stable", async () => {
    const res = await post("/invoices", {});
    expect((await res.json()).type).toBe("urn:api:errors:v1:validation-failed");
  });

  it("still accepts a payload that was valid last release", async () => {
    const res = await post("/invoices", require("./fixtures/v1-minimal-order.json"));
    expect(res.status).toBeLessThan(400);
  });
});

The last test is the highest-value one in the file: a corpus of payloads that were valid at each release, replayed forever. Any new validation rule that rejects an old payload fails immediately, with the fixture naming the release it came from.

CI/CD enforcement

The gates form a sequence, and the order matters — a lint failure should not be masked by a diff failure:

Stage Tool Fails when
1 · lint Spectral style or naming rules violated
2 · diff oasdiff breaking a breaking change is unsuppressed
3 · changelog oasdiff changelog never; posts a summary for review
4 · contract tests your test runner behaviour changed
5 · replay recorded traffic any response body differs unexpectedly
6 · classify script derives the version bump for release

SDK and client impact

The classification feeds directly into SDK versioning. A breaking finding means a major bump; anything else is minor or patch. Making that derivation automatic is what stops a breaking change from shipping as a patch release that dependency bots apply unattended:

# derive-bump.sh — the diff decides the version, not the author
if docker run --rm -v "$PWD:/specs" tufin/oasdiff:latest \
     breaking /specs/baselines/current.yaml /specs/dist/openapi.yaml --fail-on ERR >/dev/null 2>&1
then
  echo "minor"      # no breaking findings
else
  echo "major"
fi

Consumers can then rely on semver ranges meaning what they say — the basis of the pinning strategies in SDK Version Pinning.

Edge cases and anti-patterns

Anti-pattern Recommended approach
Diffing against the main branch Diff against the released baseline
Diffing unbundled multi-file specs Bundle both sides first
A permanent suppression list Require an owner, a reason and an expiry per entry
Treating response enum additions as free Publish a tolerant-reading rule and signal the change
Relying on the diff alone Add contract tests and traffic replay
Letting authors choose the version bump Derive it from the diff classification
Breaking silently because “nobody uses it” Prove it from access logs, then announce
Fixing a break by loosening the spec The spec should describe reality, not excuse it

Making the gate credible

A breaking-change gate only works while people believe it. Once it fires on something harmless — a reworded description, a reordered path, a schema moved between files — the next failure is assumed to be noise too, and within a month somebody adds a blanket suppression or removes the job entirely. Credibility is therefore a design requirement, not a nice-to-have.

Three settings protect it. Exclude prose elements from the comparison, so a typo fix in a description never fails a build. Bundle both revisions before diffing, so reorganising files produces no findings at all. And gate on error-level results only, routing warnings into the pull request summary where a human can weigh them — then promote a specific warning to error once experience shows your consumers are genuinely sensitive to it.

The fourth protection is about escapes rather than noise. Breaking changes must be possible, or the API can never be improved, so provide a suppression file — but require every entry to name an owner, a reason and an expiry date, and fail the build when an entry expires. Without expiry the file quietly becomes a list of rules nobody enforces any more, which is the same outcome as deleting the gate, arrived at slowly.

How a breaking-change gate loses and keeps credibility A gate that fires on description edits is distrusted, then suppressed, then removed. A gate that excludes prose, bundles before diffing and expires its suppressions stays trusted and keeps catching real changes. credibility is a design requirement noisy gate fires on a typo fix developers stop reading it blanket suppression one rule disabled then several more gate removed nobody trusts it breaks ship again tuned gate prose excluded, bundled expiring suppressions the last stage is a choice made at setup, not a later rescue

One more property is worth building in from the start: the gate should report what it did allow, not only what it blocked. Posting the full changelog into every pull request — additive changes included — turns the check from a bouncer into a review aid, and reviewers who read it regularly start noticing the changes that are technically additive but strategically significant, such as a new optional field that will obviously become required later. That habit catches a category of problem no classifier can, because the question it answers is not “is this compatible?” but “is this the contract we want to live with?”.

FAQ

Is adding a new field to a response a breaking change?

For most clients, no. HTTP semantics expect tolerant reading, and every mainstream JSON deserialiser ignores unknown members by default, so an added field lands harmlessly. The exceptions are real but narrow: clients validating responses against a strict schema, and statically typed clients deserialising into closed types that reject unknown properties. Document tolerant reading as an expectation of your API so the responsibility is explicit, and if you have a small known consumer set, confirm none of them validates strictly before treating additions as automatically safe.

Why is adding an enum value to a response considered risky?

Because generated clients typically model an enum as a closed set — a TypeScript string-literal union, a Python Enum, a Go typed constant block — and an unrecognised value either throws during deserialisation or silently falls through an exhaustive switch. The field is still present and still a string, so no diff rule about removal or renaming fires, yet a client built last quarter can crash on the new value. Handle it with policy rather than avoidance: publish a rule that clients must tolerate unknown enum members, ship a fallback variant where the language allows it, and classify response enum additions as a signalled change so consumers are told rather than surprised.

Can a spec diff catch every breaking change?

No, and treating it as complete is the main risk of adopting one. A diff sees only what the document expresses. A changed default sort order, a lowered rate limit, a new validation rule enforced in code but not in the schema, a field that becomes permanently null, an error type URI reworded — all break consumers while the diff reports nothing. Pair the diff with a contract-test suite that asserts behaviour, and with a replay of recorded production requests against the new build comparing bodies. The diff gives you cheap, complete coverage of structural change; the tests cover the rest.

Should a breaking change always force a new API version?

Not always, but it should always be deliberate, announced and evidenced. If the affected consumers are known, few and reachable — an internal API with three callers you can enumerate from access logs — a coordinated change with a migration window is cheaper for everyone than a parallel version. If consumers are unknown or numerous, a new version with an overlap period is the only honest option, with Deprecation and Sunset headers on the old one as described in Sunset & Deprecation Headers. What must never happen is the third option: shipping the break quietly and finding out from support tickets.