---
title: Breaking changes
description: How diff classifies every difference between two versions of your canonical schemas — and why anything unprovable is reported as breaking.
url: https://pr-2-390be2854416.thally.app/concepts/breaking-changes
lastVerified: 2026-08-20T00:00:00.000Z
verifiedVersion: 0.1.0
---

# Breaking changes

How diff classifies every difference between two versions of your canonical schemas — and why anything unprovable is reported as breaking.

[`diff`](/commands/diff) compares two sets of canonical tools and classifies
every difference between them. It never calls a provider API and needs no
network access.

The question it answers is narrow and practical:

> Would an argument object that was valid against the old schema still be valid
> against the new one, and would a caller written against the old schema still
> work?

## Three classifications

- **breaking** — existing callers or existing valid arguments will stop working.
- **non-breaking** — the new schema accepts everything the old one did.
- **informational** — documentation or metadata changed; behaviour did not.

`schemaport diff` exits 1 when breaking changes exist, which is what makes it
usable as a release gate. `--fail-on any` fails on any change at all;
`--fail-on never` only reports.

## What it looks like

Version 2 of `refund_order` adds a currency, adds an optional reason, drops one
enum value, and rewords a description:

```bash
schemaport diff v1/ v2/
```

```text
Tool: refund_order

BREAKING
- Required property `currency` was added. Existing callers do not send it.
  Path: inputSchema.properties.currency
- Enum value `"store_credit"` removed.
  Path: inputSchema.properties.refundMethod.enum

NON-BREAKING
- Optional property `reason` was added.
  Path: inputSchema.properties.reason

INFORMATIONAL
- The description changed.
  Path: inputSchema.properties.orderId.description

Result: 2 breaking, 1 non-breaking, 1 informational
```

Four edits in one commit, sorted into three buckets, exit code 1. Two of those
changes would have broken production callers, and neither is visible in a code
review of the JSON diff without someone reasoning it through by hand.

## The philosophy: structural, not clever

`diff` does **not** attempt general JSON Schema subsumption. It compares
structure — types, required lists, enum members, bounds, composition branches —
using rules that are individually explainable.

> **Warning:**
When a change cannot be classified with confidence, it is reported as
**breaking**. A false "safe" is the expensive mistake.

That asymmetry is deliberate, and it is the same instinct as
[safe and lossy compilation](/concepts/safe-and-lossy-compilation). A tool that
occasionally says "breaking" about something harmless costs you a minute of
reading. A tool that says "safe" about something harmful costs you an outage,
and — worse — teaches you to trust it.

You can see the asymmetry in the individual rules:

- `multipleOf` follows the constraint rules, but any change other than removal
  is breaking, because proving that one divisor subsumes another is out of
  scope.
- Composition branches are compared by structural equality, not subsumption.
  Reordering `anyOf` branches is recognised as equivalent, but a branch edited
  in place counts as changed — and therefore breaking.
- `allOf` is a conjunction, so adding a branch narrows what is accepted rather
  than widening it. That inversion is handled explicitly instead of being
  approximated.
- An enum that both gains and loses values produces two changes, so the breaking
  half is never hidden by the safe half.

## What it compares

Every rule carries a stable, machine-readable code such as `required-property-added`
or `constraint-narrowed`; see [`diff`](/commands/diff) for them individually.
The families are:

| Family | Breaking example | Non-breaking example |
|---|---|---|
| Tools | A tool was removed | A tool was added |
| Properties | An optional property became required | A required property became optional |
| Types | The type set narrowed | `string` widened to `["string", "null"]` |
| Values | An enum value was removed | An enum value was added |
| Constraints | A lower bound rose | A bound was dropped |
| Structure | Extra properties stopped being accepted | An `items` schema was dropped |
| Composition | `anyOf` was introduced | The composition constraint was dropped |
| Metadata | — | `description`, `title`, `default`, `examples`, `format` are informational |

Nested objects and array `items` are compared recursively, so a change reports
at full depth — `inputSchema.properties.history.items.properties.note.type`,
not just "something under `history` changed".

### Two rules worth understanding

**Renames are a heuristic, and are still breaking.** When a tool disappears and
another appears with an identical `inputSchema`, `diff` treats it as a rename.
It is reported as breaking — callers use the old name — but calling it a rename
is more useful than reporting an unrelated removal plus an addition. Once a
rename is identified, the two tools are then diffed against each other, so
schema changes made at the same time are still reported.

**`format` changes are informational.** Every target treats `format` as
advisory; no provider guarantees enforcement. Adding `format: "email"` therefore
does not actually narrow what will be accepted, so calling it breaking would be
a false alarm. If you rely on format enforcement, add an explicit `pattern` —
which *is* classified.

## It compares canonical schemas only

`diff` reads your canonical tools, never compiled output. A provider-specific
representation change is not a change to your contract, and should not show up
as one: if OpenAI's strict mode starts requiring a different shape next month,
your compiled files change and your `diff` output does not.

This is also why the two versions you compare must be loadable independently.
Tool names have to be unique across everything a command loads, so keep version
directories as siblings and point `diff` at one each — see
[Canonical schemas](/concepts/canonical-schemas).

## Determinism

Changes are sorted by tool name, then classification, then path, then code. The
same pair of inputs always produces byte-identical output, so `diff` is safe to
snapshot in a test or to post as a pull-request comment.

The diff engine is owned by [`core`](https://github.com/schemaport/core); the
command, its flags and its exit code belong to
[`cli`](https://github.com/schemaport/cli).

## Next

- [`diff`](/commands/diff) — the full rule table, flags and JSON output.
- [Continuous integration](/guides/ci) — using the exit code as a release gate.
- [Exit codes](/reference/exit-codes) — what 1, 2 and 3 mean.