apigenieevolve
evolve
Plans safe API changes: evolve plan splits the breaking changes between two OpenAPI documents
into an expand wave (deprecate, add next to) and a contract wave (remove), writes the wave
documents and the plan, and proves the plan with diff.
(experimental, outside the 1.0 compatibility promise)
evolve planis the first version of the planner: its recipes, the layout ofplan.yamland the options can still change, andevolve statusandevolve nextwill join it. It says so in its--helpand on the first line it prints, on the standard error. See the experimental commands.
Usage
apigenie evolve plan [OPTIONS] OLD_FILE NEW_FILE
Description
diff says that a change breaks consumers. evolve plan says how to ship the same change without
breaking anyone, the way database migrations do it: first expand (add the new shape next to
the old one and deprecate the old one with a sunset date), let consumers move during a window,
then contract (remove what was deprecated). The command takes the document consumers use today
(OLD_FILE) and the document the change arrives at (NEW_FILE, usually the one in the pull
request), and writes three files to the directory of --out (evolve-out/ by default):
| File | What it is | When to ship it |
|---|---|---|
1-expand.yaml |
The target document with every old element kept next to its replacement, deprecated: true and x-sunset: <date>; non-breaking against OLD_FILE |
now |
2-contract.yaml |
The target document | on or after the sunset date |
plan.yaml |
The plan: the waves, the units, the residue, the server notes |
The wave files take the format of NEW_FILE (1-expand.json for a JSON document) and keep its
comments and layout. The sunset date is --today plus --window (90 days by default).
Recipes
Each breaking change of the diff becomes a unit with a recipe:
| Change | Recipe | Expand wave | Contract wave |
|---|---|---|---|
| An operation or a whole path is removed | remove-operation |
the operation stays, deprecated with its sunset date | removed |
| An operation moved to another path (a hint says where, see below) | move-path |
the old operation stays, deprecated | removed |
| A response property is removed | remove-response-property |
the property stays, deprecated (and required when it was) | removed |
| A response property is renamed | rename-response-property |
the old property stays, deprecated, next to the new one | removed |
| A request property is renamed | rename-request-property |
the old property stays, deprecated; neither name is required | the old one removed, the new one required when it was in NEW_FILE |
| A request parameter is renamed | rename-request-parameter |
the old parameter stays, deprecated and optional; the new one is optional | the old one removed, the new one required when it was |
| A request parameter is removed | remove-request-parameter |
the parameter stays, deprecated | removed |
| A request property becomes required (new or existing) | require-request-property |
present but optional | required |
| A request parameter becomes required | require-request-parameter |
optional | required |
| A request enum value is removed | remove-request-enum-value |
the value stays, listed in x-deprecated-enum, with x-sunset on the schema |
removed |
A rename is detected when an object loses one property (or an operation one parameter) and gains
one whose schema is the same, after following $refs and ignoring descriptions, titles, examples
and extensions. Removed and added members that were not paired are listed as rename candidates;
a hint decides them. A component schema is reported where it is used and under components; one
unit covers all of them, and a schema used in requests and responses gets the request recipe, the
stricter one.
Changes the differ rates non-breaking need no recipe: they are in both waves as they are in
NEW_FILE. Breaking changes no recipe makes safe are residue: a changed type or format, a
response property no longer required, a new security requirement, a replaced server, a changed
status code or media type. The plan lists them with a reason, the expand wave keeps the old shape
there, the contract wave applies the change, and the command exits with status 1: these need a
major version or a new resource.
Server notes
A document can say that both fullName and name exist; only the implementation can emit both or
accept both. Each unit that needs it carries a server note (server in plan.yaml, the
“Server checklist” of the report): emit both names until the sunset, accept both, apply a default
when a soon-required property is missing, accept a deprecated enum value until the sunset. A plan
whose server does not follow its notes is a lie the documents can’t catch.
Hints
--hints FILE names what the diff can’t see, as YAML or JSON:
renames:
- from: "#/components/schemas/Customer/properties/fullName"
to: "#/components/schemas/Customer/properties/name"
- from: "GET /v1/parcels/{id}"
to: "GET /v1/shipments/{id}"
window: 90d
reason: Unify naming with the shipments API
renames pairs a removed element with an added one: operations as METHOD /path, properties and
parameters by their location, written the way the overrides.where.location of the
diff configuration is
(/components/schemas/Customer/properties/fullName,
/paths/~1pets/get/parameters/query:page; a leading # is accepted). A hint that names no removed
and added element of the diff is an error. window applies when --window is not given, and
reason is written into every unit.
Verification
Before anything is written, the plan is checked with the differ and the same configuration:
diff OLD_FILE 1-expand.yamlhas no breaking change.diff 1-expand.yaml 2-contract.yaml --today <sunset>has breaking changes only where a unit’s contract step owns them (arequiredlist it changes) or where the residue is; the scheduled removals are non-breaking thanks to thesunset_passedcondition.2-contract.yamlequalsNEW_FILE, ignoringx-sunset,x-deprecated-enumandx-apigenie-*extensions.
When a check fails, the report names the change and the unit, nothing is written, and the command exits with status 2. The same differ suggests the versions of the waves: a minor for the expand wave (a patch when it only adds extensions), a minor for a contract wave that only removes scheduled elements, a major for one that requires something or carries residue.
plan.yaml
apigenie-evolve: 1
from: {path: api.v1.yaml, version: 1.4.0}
to: {path: api.v2.yaml, version: 1.4.0}
today: 2026-10-13
window: 90d
waves:
- id: expand
file: 1-expand.yaml
version: 1.5.0
units: [remove-operation-quoteLegacy, rename-response-property-Parcel-fullName]
- id: contract
file: 2-contract.yaml
version: 1.6.0
notBefore: 2027-01-11
units: [remove-operation-quoteLegacy, rename-response-property-Parcel-fullName]
units:
remove-operation-quoteLegacy:
recipe: remove-operation
from: GET /quotes/legacy
sunset: 2027-01-11
rename-response-property-Parcel-fullName:
recipe: rename-response-property
from: /components/schemas/Parcel/properties/fullName
to: /components/schemas/Parcel/properties/name
detected: true
sunset: 2027-01-11
server: emit both fullName and name until 2027-01-11
residue: []
A unit’s from is METHOD /path for an operation and the element’s location otherwise; to
names the new element of a rename or a move, value the removed enum value, detected whether
the planner found a rename itself (true) or a hint supplied it (false), reason comes from the
hints, sites lists the places a component schema was reported at. Each residue item has its
location, the change as diff describes it, and why.
Options
| Option | Description |
|---|---|
--hints FILE |
The hints file: renames, the window, the reason. |
--window WINDOW |
How long consumers have before the contract wave: days (90d) or weeks (12w). Defaults to the hints file’s window, then to 90d. |
--today DATE |
The date the plan is made at (YYYY-MM-DD); the sunset dates are this plus the window. Defaults to the current date. |
--config FILE, --no-config |
The configuration file whose commands.diff block rates the changes, as for diff. |
--out DIR |
Where the plan and the wave documents go. Defaults to evolve-out. |
--format FORMAT |
The report: text (default) or json. |
JSON output
With --format json the report is an object whose formatVersion member is the version of the
format, changed only when a member is removed or changes meaning (see
Compatibility); its JSON Schema is
apigenie-evolve-report.json.
{
"formatVersion": 1,
"result": "planned", // or "residue", or "failed"
"plan": { "apigenie-evolve": 1, "...": "the same object as plan.yaml" },
"checks": [
{ "id": "expand-non-breaking", "status": "ok" },
{ "id": "contract-scheduled-only", "status": "ok" },
{ "id": "contract-equals-target", "status": "ok" }
],
"serverNotes": [{ "unit": "rename-response-property-Parcel-fullName", "note": "emit both fullName and name until 2027-01-11" }],
"files": ["evolve-out/plan.yaml", "evolve-out/1-expand.yaml", "evolve-out/2-contract.yaml"]
}
Exit status
| Status | Meaning |
|---|---|
| 0 | Every breaking change was placed in a wave; the files were written |
| 1 | The files were written, but some changes are residue (listed in the report) |
| 2 | Invalid options, a file that can’t be read or isn’t an OpenAPI 3.x document, a hint that names nothing, an invalid configuration, or a failed check (nothing written) |
Examples
Plan the change of a pull request, then let diff confirm the waves:
apigenie evolve plan api.main.yaml api.pr.yaml --out evolve-out
apigenie diff api.main.yaml evolve-out/1-expand.yaml
apigenie diff evolve-out/1-expand.yaml evolve-out/2-contract.yaml --today 2027-01-11
With hints for a moved endpoint, a two-month window and a JSON report for a pipeline:
apigenie evolve plan api.main.yaml api.pr.yaml --hints evolve.yaml --window 8w --format json
Serve the expand wave to old consumers before shipping it:
apigenie-server evolve-out/1-expand.yaml