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 plan is the first version of the planner: its recipes, the layout of plan.yaml and the options can still change, and evolve status and evolve next will join it. It says so in its --help and 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:

  1. diff OLD_FILE 1-expand.yaml has no breaking change.
  2. diff 1-expand.yaml 2-contract.yaml --today <sunset> has breaking changes only where a unit’s contract step owns them (a required list it changes) or where the residue is; the scheduled removals are non-breaking thanks to the sunset_passed condition.
  3. 2-contract.yaml equals NEW_FILE, ignoring x-sunset, x-deprecated-enum and x-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