Breaking Changes

Catch a change that breaks callers before it ships.

kizuna diff compares two versions of your API and reports what each change costs the people already calling it. It reads the declarations, so route keys, MCP tool names and job keys are all in view, and every Zod schema is compared field by field.

Run it against a ref

kizuna diff --against main
BREAKING POST /users body.organisationId is now required

CHANGED  users.patchUser renamed to users.updateUser
         the generated client method changes name, the HTTP surface does not

CHANGED  GET /users/:id can now answer 410
ADDED    POST /users/:id/archive added

Routes are always compared: the method, the path, every status, and every request and response schema. The rest is opt-in.

kizuna.config.ts
export default defineConfig({
    diff: {
        dottedKeys: true,
        jobs: true,
        tools: true,
    },
});
OptionFails the gate on
dottedKeysa renamed route key, which names the method when you publish the client as an SDK
jobsa job key gone, which POST /jobs/run addresses by name
toolsan MCP tool a model can no longer call

All of it is recorded in .kizuna/snapshot.yaml either way, so switching one on later still compares against what you already committed.

Gate it in CI

fetch-depth: 0 is there because diff reads the base out of git. generate --check runs first, so a snapshot nobody regenerated fails the build instead of answering from a stale file. continue-on-error keeps the job alive past a breaking change, which is the only way the label step runs.

.github/workflows/breaking-changes.yml
name: Breaking Changes

on:
    pull_request:
        branches: [main]

jobs:
    breaking-changes:
        runs-on: ubuntu-latest
        steps:
            - uses: actions/checkout@v6
              with:
                  fetch-depth: 0

            - uses: pnpm/action-setup@v6
            - uses: actions/setup-node@v6
              with:
                  node-version: 24
                  cache: pnpm

            - run: pnpm install --frozen-lockfile

            - name: Check the snapshot is current
              run: pnpm kizuna generate --check

            - name: Compare against main
              id: diff
              continue-on-error: true
              run: pnpm kizuna diff --against ${{ github.event.pull_request.base.sha }}

            - name: Require a label for breaking changes
              if: steps.diff.outcome == 'failure'
              run: |
                  if [[ "${{ contains(github.event.pull_request.labels.*.name, 'breaking changes') }}" == "true" ]]; then
                      echo "Breaking changes acknowledged via label."
                  else
                      echo "::error::Breaking changes detected. Add the 'breaking changes' label to acknowledge."
                      exit 1
                  fi

A breaking change blocks the pull request until someone adds the breaking changes label.

The route surface

ChangeLevel
A route removedbreaking
A route renamedbreaking
A method or path changedbreaking
A declared status no longer answeredbreaking
A job key removedbreaking
An MCP tool removed or renamedbreaking
A new statuschanged
A new deprecation or sunsetchanged
A route addedadded

A rename is spotted by matching a departed key against an arrived one on the same method and path, so it reads as one breaking change rather than a removal and an addition. Renaming users.patchUser to users.updateUser leaves the method, the path and every schema alone, and still changes the client method, the MCP tool name, and the method names in the generated Swift and Kotlin clients.

Schemas, where direction decides everything

A caller sends requests and reads responses, so tightening an input and dropping an output are the two that hurt.

ChangeRequestResponse
A required field addedbreakingchanged
An optional field addedquietchanged
A field removedchangedbreaking
An optional field requiredbreakingchanged
A required field optionalchangedbreaking
A field made nullablechangedbreaking
A nullable field made non-nullbreakingchanged
A type changedbreakingbreaking
An enum value removedbreakingbreaking
An enum value addedchangedbreaking

That last row catches people out. Adding a value to a response enum breaks any client that generated a closed enum for it, which is what unknownEnumCase exists for in the Swift and Kotlin clients.

Bodies, query parameters, headers and every response body are compared, reported by path and followed into arrays:

BREAKING POST /users body.address.postcode is now required
BREAKING GET /users 200.items[].id is number instead of string

What it cannot read is the rule inside a .refine() or .superRefine(), because a predicate is a function rather than a declaration. It does see one arrive or leave, so adding a refinement is reported as tightened validation, but a rewritten predicate that accepts less than it did passes quietly.

Reference