Documentation for v2.11.0. Read the latest, v2.12.0.
Developer
API lifecycle
The unit of evolution is the version line: the
ordered MAJOR.MINOR.PATCH versions one
Producer has published for one API type. This
chapter is what happens on that line, with the exact messages a refusal carries.
What changed in 2.6.0
-
Every accepted provide, every refusal and every newly deprecated element can be delivered as a
webhook (
version.published,provide.rejectedwith the rule that fired,version.deprecated). Receivers are in Webhooks. -
A GA provide with a previous GA on the line reports the classification against it in the
version.publishedevent:compatibility.impactispatch,minorormajor. The diff is computed only when a hook will receive the event.
In short#
- Iterate on snapshots: overwritable by their provider, never compatibility-checked, expire when unused.
- Release the same number as GA: it is promoted in place and becomes immutable.
-
Bump when a GA provide is refused with
409: the body carries the proposed version; set it in the spec and republish. - Deprecate an element in the spec before removing it; a removal of a deprecated element is not breaking.
- Keep old GA versions: they serve pinned Consumers until those move their pin. The graph shows who still depends on what.
The rules of a version line#
409 names the number to publish as instead.
1. Iterating on snapshots#
While an API is in flux, provide it as a snapshot, which is the default: every build publishes a snapshot unless the ga switch is set (how stability is decided).
-
Overwritable by its provider: re-providing the same snapshot number replaces it
wholesale when the Provide comes from the user who last provided it; every overwrite is audited.
Different content from anyone else is refused 409
snapshot_owned, naming the provider and proposing the next free minor, so two developers who both picked 1.3.0 cannot break each other's Consumer builds. Identical bytes from anyone (CI rebuilding the commit its developer already provided) stay a no-op. An administrator deletes the snapshot to free the number. - Never compatibility-checked: break whatever you like between overwrites.
- Consumers may opt in: a Consumer that pins your snapshot builds against work in progress; the graph flags it as snapshot-pinned.
-
Expiry: a snapshot neither provided nor required for
snapshot_max_age_days(default 30) is cleaned up. Use keeps it alive. -
Origin: a provide may say why.
build_url,change_url(the pull or merge request),tickets(ids) andnote(one line) are stored with the version as its origin; the latest provide that carries any of them wins, and a no-op re-provide of identical bytes only fills an empty one, so CI rebuilding what a laptop provided adds the references without erasing them. Shown on the version row, in the diff and the blame, answered by the MCP and carried by theversion.publishedwebhook.
2. Going GA#
When the version is ready, provide the same number with stability: ga:
-
Promotion in place: the snapshot entry becomes GA, the snapshot content is
gone, and the response says
"promoted": true. A byte-identical promotion keeps the snapshot's provider on the released version. - Immutable forever: re-providing identical content is a no-op; different content is refused with a proposed next version.
-
The number is claimed: no snapshot may ever exist for a GA number again. Lower
numbers that never went GA stay legal, so preparing a hotfix
1.2.1while1.3.0is GA works. - Never age-culled: GA versions survive until deliberately deleted.
- A role: GA needs the releaser role, also for a promotion and even for the idempotent re-provide. Without it:
HTTP/1.1 403 Forbidden
{ "error": "publishing GA for 'orders' requires the 'releaser' role — publish as a snapshot, or ask an administrator to grant the role" }
3. Choosing the next version: the server proposes#
You never guess the bump. A provide is refused with 409 Conflict and a
proposed_version when:
- You forgot to bump: the number is GA with different content. The proposal is the next free number, bumped by what actually changed, breaking: major, additive: minor, shape-identical: patch, skipping numbers the line already uses.
- The semver is a lie: the changes relative to the highest GA below the new number are breaking without a major bump. The proposal is the next major.
- You reused a released number as a snapshot: pick the proposed next number.
The three messages, verbatim:
{ "error": "version 1.1.0 of openapi 'orders' is GA and immutable, but the submitted content differs — did you forget to increment info's version? Publish as 1.2.1 instead",
"proposed_version": "1.2.1" }
{ "error": "version 1.1.0 of openapi 'orders' is GA — a released number can never carry a snapshot again; bump your version to 1.2.1",
"proposed_version": "1.2.1" }
{ "error": "version 1.3.0 of openapi 'orders' is breaking relative to GA 1.1.0 but does not bump the major — publish as 2.0.0 instead: <what broke>",
"proposed_version": "2.0.0" }
When the bytes differ but no endpoint the splitter sees changed, the first two messages add (no endpoint content changed — the difference may be whitespace or comments; documents are compared byte-for-byte after line endings and a leading BOM are normalized). Line endings and a BOM are not content; whitespace, indentation and comments are.
The breaking-change classifier covers all three API types:
-
OpenAPI: removed paths, methods or response codes, removed parameters or schema
properties, parameter and property type changes, new required request fields, new required
parameters or optional ones made required, request enums that lose a value or become an enum.
Schemas are compared at every nesting depth, in components and in inline request and response
bodies. A new enum value in a response is not breaking;
allOf/oneOf/anyOfcompositions are not analysed. -
AsyncAPI: removed messages, removed payload properties, payload property type
(or
$ref) changes. Enum andrequiredchanges are not analysed. -
gRPC/proto: removed rpcs or messages, removed fields, field number, type or
repeatedlabel changes, rpc signature changes. Enum changes are not analysed.
Elements marked deprecated in the previous GA are exempt: removing them is not breaking. Pinned
Consumers are never broken by a new version; the checks keep the numbers honest so that a Consumer
reading 2.4.0 → 2.5.0 can trust the upgrade.
4. Deprecation and migration#
Once a new major is GA, move Consumers off the old elements:
-
Annotate in the spec, per element. OpenAPI:
deprecated: trueon the operation, a parameter or a schema property. AsyncAPI:deprecated: true(orx-deprecated: true) on the operation, message or payload property. Proto:option deprecated = true;in the rpc or message body, or[deprecated = true]on a field. -
At the level of the thing you remove. There is no whole-spec switch: a
root-level
deprecated: trueor a file-leveloption deprecatedis ignored. Deleting an operation or rpc needs the marker on it; removing a message, property or field needs the marker on that message, property or field. Deprecating an operation does not exempt removing fields from its payload; deprecating protoOuterdoes not coverOuter.Inner. - Monitor the graph's Outdated highlight and the Producer page's consumer counts to see who still pins the old versions.
-
Consumers notice: a require that is answered from a deprecated endpoint carries
the header
X-Sanshain-Deprecated: true.
5. Removal and retirement#
Removing an element is always two GA releases within the line:
- Publish a version with the deprecation marker: an ordinary compatible update, a minor bump.
- Publish a later version without the element. Because it was deprecated in the previous GA, the removal is not breaking, so a minor bump suffices.
Old GA versions stay available by default; a pin never rots out from under a Consumer. The escape
hatch is the audited delete-version action on the Producer page (admins and the Producer's
maintainers): it frees the number, Consumers still
pinned to it fail 404 on their next require, and a sanshain-branch that references it
shows a dangling edge until the number is re-provided. The confirmation lists both before asking.
Dropping a whole protocol family is a different act,
retiring.
6. Multi-producer topics: AsyncAPI message contracts#
REST paths belong to one service; Kafka topic names live in a global namespace, and an audit log
or a dead-letter queue legitimately has many producers. Sanshain therefore tracks AsyncAPI
compatibility per message, not per channel. On a GA provide, every named
publish message registers a
message contract keyed by channel and
message name:
-
Identity is the message
name, falling back totitle. A message with neither has no cross-service identity: it is skipped and is unsuitable for a shared topic. Name your messages. - Ownership: the first Producer to publish a channel and message name owns it.
-
The owner may widen its message (add properties, add messages); a breaking
payload change, a removed non-deprecated property or a type change, is refused with
409. -
A different Producer publishing the same channel and message name is accepted
only with a semantically identical payload schema; otherwise
409naming the owner. Align the schema or rename your message: one owner per message name.
Contracts are enforced on GA provides only; snapshots are never contract-checked. Requiring the
owner's snippet and $ref-ing it from your own spec keeps a consumer's copy aligned.
Sanshain reads 2.x publish/subscribe from the application's perspective
(the convention); only
publish/send registers a contract.
Subscribe operations become consumer edges#
The mirror of publishing is subscribing. When a provided AsyncAPI document has
subscribe (2.x) or receive (3.x) operations, Sanshain harvests them on
every provide as version-less consumer edges, so a channel your own spec says you read needs no
hand-written require. Each
harvested subscription:
-
resolves its provider through the channel's contract. Without a GA provider it
is recorded as unfulfilled and drawn as an edge to a dashed node
<channel> ?. - inherits the provide's stream: a trunk provide's subscriptions show in the main view, a plain snapshot's only in the dev view.
-
is validated as an expectation: reading fewer fields than the contract
guarantees is fine; expecting a property or a type the contract does not guarantee is drift. On
a GA provide drift is refused with
409naming channel, message and property; on a snapshot it is reported in the provide response'sharvested_subscriptionsas adriftnote.
Dropping a subscribe from a later provide retracts its edge. A subscription is never
a pin and never registers a contract.
Summary#
| Situation | Stability | Result | Action |
|---|---|---|---|
| Any change, new or same snapshot number | snapshot | accepted |
iterate; only the provider overwrites, audited; others get 409
snapshot_owned
|
| Same GA number, identical content | GA | accepted, no-op | none |
| Same GA number, different content | GA | 409 immutable_ga |
republish as proposed_version |
| Breaking change without a major bump | GA | 409 breaking_without_major |
republish as the proposed major |
| Snapshot for a GA number | snapshot | 409 snapshot_on_ga |
use the proposed next number |
| GA without the releaser role | GA | 403 ga_requires_releaser |
publish a snapshot, or get the role |
| Removing a deprecated element | GA | accepted, minor | deprecate first, remove one GA later |
| Owned message breaks (AsyncAPI) | GA | 409 message_contract_conflict |
widen your own message only |
| Co-publish with a different schema (AsyncAPI) | GA | 409 message_contract_conflict |
match the owner's schema or rename |
The code in the third column is the rule of the provide.rejected
webhook event.
Checked against v2.6.0.