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
Routes are always compared: the method, the path, every status, and every request and response schema. The rest is opt-in.
| Option | Fails the gate on |
|---|---|
dottedKeys | a renamed route key, which names the method when you publish the client as an SDK |
jobs | a job key gone, which POST /jobs/run addresses by name |
tools | an 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.
A breaking change blocks the pull request until someone adds the breaking changes label.
The route surface
| Change | Level |
|---|---|
| A route removed | breaking |
| A route renamed | breaking |
| A method or path changed | breaking |
| A declared status no longer answered | breaking |
| A job key removed | breaking |
| An MCP tool removed or renamed | breaking |
| A new status | changed |
| A new deprecation or sunset | changed |
| A route added | added |
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.
| Change | Request | Response |
|---|---|---|
| A required field added | breaking | changed |
| An optional field added | quiet | changed |
| A field removed | changed | breaking |
| An optional field required | breaking | changed |
| A required field optional | changed | breaking |
| A field made nullable | changed | breaking |
| A nullable field made non-null | breaking | changed |
| A type changed | breaking | breaking |
| An enum value removed | breaking | breaking |
| An enum value added | changed | breaking |
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:
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
- Deprecations, for retiring a route rather than removing it
- Configuration