---
name: api-compatibility-classifier
source: https://app.decimal.ai/s/api-compatibility-classifier@1/SKILL.md
source_sha256: e7debe8dba41
---

# API Compatibility Classifier

Given a diff of a **public surface** — a function/method signature, a REST request or response schema, a GraphQL type, a CLI flag set, or any library API other people call — classify each change and derive the version bump the release needs.

The base model knows semver at a slogan level ("breaking = major") but applies it unevenly. Its two systematic errors:

1. **It uses a flat "adding is safe, removing is breaking" heuristic** and ignores that a change's direction flips depending on whether the surface is an **input** (request params) or an **output** (response fields). Adding a *required request field* is breaking; adding a *response field* is additive. They are not the same "add."
2. **It forgets the pre-1.0 rule** — on a `0.x.y` version it will say "breaking change, so bump to 1.0.0" when the correct bump is the next minor (`0.NEXT.0`).

This skill makes the classification mechanical and the semver bump correct.

## The one principle that fixes most mistakes: inputs and outputs move in opposite directions

A public API is a contract with two sides. A caller **sends inputs** and **consumes outputs**. Compatibility is about not breaking the caller, so the two sides have **mirror-image** rules:

- **Inputs (request params, function arguments, accepted values):** the caller must still be able to send exactly what it sent before. **Widening** what you accept is safe (additive); **narrowing** it, or **requiring more**, is breaking.
- **Outputs (response fields, return values):** the caller must still receive everything it relied on, in the shape it expected. **Removing** or **shrinking** an output is breaking; **adding** a field is additive.

So the same verb classifies differently by side: *adding a required field* to a **request** = breaking, but *adding a field* to a **response** = additive. Ask "is this the input side or the output side?" before deciding.

## Classification rule table

| Change | Side | Class |
|---|---|---|
| Remove or rename a public function, endpoint, field, param, enum member, or error code | either | **breaking** |
| Change a type incompatibly (e.g. `int` → `string`, object → array) | either | **breaking** |
| Add a new **required** request param / argument (no default) | input | **breaking** |
| Make an existing optional request param **required** | input | **breaking** |
| **Tighten** a request type: narrow accepted values, add a stricter validator, `string` → enum, raise a minimum | input | **breaking** |
| Reorder positional parameters | input | **breaking** |
| Remove a response field, or make an always-present response field optional/nullable | output | **breaking** |
| Change the exception/error type thrown for the same condition | output | **breaking** |
| Change a URL path, HTTP method, or default value that alters behavior | either | **breaking** |
| Add a new endpoint, method, or function | — | **additive** |
| Add a new **optional** request param with a default (omitting it preserves old behavior) | input | **additive** |
| **Widen** a request type: accept more values than before | input | **additive** |
| Add a new field to a response object | output | **additive** |
| Add a new enum member the request will now **accept** | input | **additive** |
| Deprecate (mark, keep working) a field or param | — | **additive** |
| Change a private / underscore-prefixed / `@internal` symbol | — | **internal** |
| Refactor internals with identical observable behavior | — | **internal** |
| Docstring, comment, or formatting change | — | **internal** |
| Bug fix that restores documented behavior | — | **internal (patch)** |

### The one that trips people: a new value in an OUTPUT enum

Adding a new member to an enum a **response** can now return is *not* purely additive. Callers that exhaustively `switch`/`match` on the old set will hit an unhandled case. Conventionally it is treated as **additive/minor** (most clients tolerate unknown values), but call it out as a **compatibility risk** — the safe framing is "additive but may break exhaustive consumers." This is the mirror of accepting a new enum value on the *input* side, which is unambiguously additive.

## Deriving the semver bump

The release bump is the **highest-impact class** in the diff: one breaking change makes the whole release breaking, no matter how many additive changes ride along.

| Highest class in diff | Version ≥ 1.0.0 | Pre-1.0 version `0.y.z` |
|---|---|---|
| breaking | **MAJOR** — `(X+1).0.0` | **MINOR** — `0.(y+1).0` |
| additive | **MINOR** — `X.(Y+1).0` | **PATCH** — `0.y.(z+1)` |
| internal / bug fix | **PATCH** — `X.Y.(Z+1)` | **PATCH** — `0.y.(z+1)` |

**The 0.x rule (pre-1.0):** while the major is `0`, semver treats the API as unstable, and the common convention (npm, Cargo) shifts every level down one notch — the **minor** slot plays the role of "major/breaking" and the **patch** slot absorbs features and fixes. So a breaking change on `0.4.2` releases as **`0.5.0`**, not `1.0.0`. Do not promote to `1.0.0` for a breaking change unless the maintainer is explicitly declaring the API stable. (Strict reading: on `0.0.z` every change may break; flag that edge if you see it.)

## Output format

For a given diff, produce:

1. **Per-change classification** — each change as `breaking` / `additive` / `internal`, one line of why, naming the input-vs-output side when it decides the verdict.
2. **Release bump** — the single resulting semver level and the concrete next version (given the current one), citing the highest-impact change.
3. **Caller impact** — for each breaking change, one line on what breaks and the migration.

## Examples

**Diff (library at `2.3.1`):**
```
- def fetch(id):
+ def fetch(id, *, region):    # no default value
```
`fetch` gained a **required** argument on the **input** side → **breaking**. Existing calls `fetch(id)` now raise `TypeError`. Release: `2.3.1` → **`3.0.0`**. Migration: give `region` a default, or update all call sites.

**Diff (REST API at `0.6.4`):**
```
response User:
+   last_login: string   # new field
```
A field added to the **output** → **additive**. Pre-1.0, additive bumps the patch → `0.6.4` → **`0.6.5`**.

## Edge cases
- **Behavioral-only breaks:** a same-signature change (a default flips, an error code changes, pagination size changes) is still **breaking** even though the type signature is untouched — classify by observable contract, not by shape alone.
- **Documented vs. accidental behavior:** a fix that removes behavior users depended on but that was never documented is a judgment call — flag it as "technically a fix, practically breaking for some" rather than silently calling it a patch.
- **Deprecation is additive, removal is breaking:** marking a field deprecated is a minor; the major is owed when you actually delete it.
- **Private surfaces:** if the changed symbol is not reachable by consumers (underscore-prefixed, not exported, `internal` package), it is **internal** regardless of how drastic the change looks.
