# API versioning and deprecation

The epoch that carries the management API's compatibility promise, how a deprecation reaches a caller on the wire, and which rules a gate enforces.

Source: https://tokentriage.com/docs/reference/api-versioning

---
import Callout from '../../../components/Callout.astro';
import NextSteps from '../../../components/NextSteps.astro';
import Details from '../../../components/Details.astro';

This page states what TokenTriage promises about changing its APIs, and separates the rules a
gate enforces from the ones that rest on a commitment. Both belong here. A versioning policy
whose reader cannot tell which is which has told them nothing they can plan against.

Every statement below describes the **self-hosted management API** unless it names another
surface. The per-surface table near the end says what each one carries today.

## What the version numbers mean

Three numbers get confused with each other, and only one of them is the compatibility promise.

| What you see | Example | What it means |
|---|---|---|
| The `/tt/api/v1` path segment | `/tt/api/v1/tokens` | A permanent namespace. It is not the contract version and it does not move when the contract does. |
| `info.version` in the spec | `2.31` at the time of writing | `<epoch>.<revision>`. The revision increments whenever the document is regenerated with a change, so it moves often — ask a deployment for its live value rather than quoting this one. |
| The **epoch** | `2` | The leading number of `info.version`. This is the wire-compatibility promise. |

The epoch is a constant in the server (`managementEpoch`, `internal/api/contract/openapi_gen.go:59`),
twinned with `APIVersion` in `internal/api/contract/types.go:26`. A test fails the build if the two
disagree, so the number in the document is the number the server was compiled with.

<Callout type="key">
Compatibility is promised by the **epoch**, not by the path segment and not by the revision.
A response you parse correctly under epoch 2 keeps parsing correctly for every epoch-2 revision.
</Callout>

## What additive-only means inside an epoch

Within one epoch, changes are additive: new operations and new optional fields may appear;
existing ones do not change shape or disappear.

This one is mechanized. A CI job named `oas-breaking` runs
[oasdiff](https://github.com/oasdiff/oasdiff) over every committed OpenAPI document on each push
and pull request, and fails the build on a breaking finding (the `oas-breaking` job in
`.github/workflows/ci.yml`; the hard failure is its final `exit 1`). The job also runs a
non-vacuity check on itself first — the step *"Gate self-test — oasdiff MUST flag a removed
endpoint"* — which confirms oasdiff still reports a removed endpoint, and errors out calling
itself vacuous if it does not.

Three limits on that gate are worth knowing, because each one is a case where a green build is
not evidence:

| Limit | What happens | Where |
|---|---|---|
| An epoch bump switches it off | If the leading number changed between base and head, findings print as information and nothing fails. Changing epoch is how a breaking change is made legitimately. | the `CONTRACT EPOCH BUMP` branch |
| No baseline means no comparison | With no base revision to diff against, the job exits clean. That is an absence of comparison rather than a pass, and it says so. | the `NO BASE REVISION` branch |
| It covers `docs/api/*.yaml` in this repository only | The glob is non-recursive, so a document filed in a subdirectory ships ungated. The management and governance documents are gated; other surfaces in the table below are not. | `specs=(docs/api/*.yaml)` |

## How a deprecation reaches you

A deprecation is machine-readable on the wire before it is anything else. The server emits it
from one middleware that runs before any handler can write, so the headers ride error responses
as well as successful ones (`internal/api/dashsrv/conventions.go:301`).

| Header | Form | Emitted when |
|---|---|---|
| `Deprecation` | RFC 9745 structured-field date, `@<unix>` | A deprecated operation, query parameter or request header applies to this request. |
| `Sunset` | RFC 8594 HTTP-date | Only once a removal date is set. |
| `Link` | RFC 8288, `rel="deprecation"`, anchored at the deprecated thing | Alongside `Deprecation`, added rather than replaced, so a later pagination link cannot erase it. |

Two behaviours are worth planning around.

**An absent `Sunset` means no removal date is scheduled**, which is a different statement from
a date chosen to fill the field. The emitter sets it only when a record carries one
(`conventions.go:459`).

**Headers fire only when you used the deprecated thing.** A deprecated query parameter announces
itself on requests that sent it, and stays quiet on requests that did not
(`conventions.go:427`). Announcing a deprecation to a caller who is not touching it trains people
to ignore the header.

<Details summary="Which record governs when several apply at once">

`Deprecation` carries the earliest `Since` among the applicable records, and `Sunset` the
earliest `Sunset` among those that have one: the longest-standing notice, and the soonest thing
that stops working. Registration validates the RFC 9745 ordering rule per record, so the pair
cannot contradict itself (`internal/api/contract/openapi_registry.go:841`).

</Details>

## What is deprecated today

One thing, and it is a parameter rather than an operation.

| Field | Value |
|---|---|
| Surface | the `offset` query parameter of `governanceRefusals` |
| Deprecated since | 2026-07-25 |
| Sunset | 2027-02-01 |
| Successor | the cursor form of the same operation |
| Record | `internal/api/contract/ops_governance.go:406` (`refusalOffsetDeprecation`), published in `docs/api/management.yaml` as the `offset` parameter's `deprecated: true` plus `x-deprecated-since` |

No operation carries a deprecation record. The `governance.yaml` document as a whole is retired
in favour of the management document, and carries its own `x-deprecated-since`, `x-sunset` and
`x-superseded-by` at `docs/api/governance.yaml:17`. The file stays in the repository after its
sunset so a stale link finds a document saying where the contract went.

## The six-month runway, and what stands behind it

**The commitment: a deprecation gets at least six months, or one minor release, whichever is
longer.** It is published in the API changelog (`docs/api/CHANGELOG.md:1`) and in the
getting-started page served by the running gateway at `/tt/api/docs/getting-started.html`, whose
bytes are pinned to the shipped bundle by a test (`internal/api/dashsrv/docs_test.go:249`).

What enforces it, stated precisely, because the two halves differ in strength:

| Rule | Standing |
|---|---|
| `Sunset` must be on or after `Deprecation` | Enforced at registration for every record, RFC 9745 §4 (`internal/api/contract/openapi_registry.go:841`) |
| The record in flight has a runway of at least 180 days | Enforced in CI, read from the live registry rather than hard-coded (`internal/api/dashsrv/governance_ap2_test.go:510`) |
| Every future record will have a runway of at least 180 days | A commitment. No gate sweeps the registry for it. |

The deprecation now in flight runs 191 days. A second record added tomorrow with a shorter
runway would pass every gate in the tree, so the third row is the company's word rather than a
mechanism, and it is written here as such.

## Preview operations

An operation may be marked `preview`, which exempts it from the additive promise and says so on
the wire as `x-stability` (`internal/api/contract/openapi_registry.go:101`). A preview operation that
stays in preview for more than two milestones fails a test built to catch it
(`internal/api/contract/preview_stale_test.go:70`), so the marking cannot become permanent by
inattention.

<Callout type="note">
**No operation is in preview today.** The mechanism is built and the staleness test runs, but it
currently walks an empty set. Every operation in the management contract is stable and carries
the full promise.
</Callout>

## What each surface carries

| Surface | Epoch | Published contract | Breaking-change gate |
|---|---|---|---|
| Management API, self-hosted | 2 | `docs/api/management.yaml` | Yes, `oas-breaking` |
| Governance view | 2, retired | `docs/api/governance.yaml`, superseded | Yes, `oas-breaking` |
| Central admin API (enterprise) | 1 | Generated; kept as a test golden (`internal/central/centralapi/testdata/central.yaml`) rather than under `docs/api/` | No |
| Cloud control plane | 1 | Not published as a document. The contract is the in-repo descriptor registry, frozen as a golden | Yes, an in-repo descriptor gate rather than `oas-breaking` |
| Python and TypeScript SDKs | pin epoch 2 | Their own contract version, described on [the SDK contract page](/docs/sdks/contract) | Their own conformance corpus |
| CLI | none | No versioning or deprecation contract exists for the CLI today, and no command or flag carries a deprecation marker | No |

**The cloud control plane carries no deprecation contract.** Its epoch promises additive change
and nothing more: it has no deprecation mechanism and nothing on it is deprecated. The six-month
runway, the `Deprecation` and `Sunset` headers and the preview machinery described above are
management-API statements and do not reach it. The epoch itself is the constant `CloudEpoch` in
the control plane's `control/cloudapi/epoch.go`; `scripts/check-cloud-epoch.mjs` reads it there
and fails this site's build if either row above disagrees — and says in its own output whether it
reached that constant or only the copy pinned in this repository, so a build that could not read
the control plane never reads as one that did.

**The central admin API has no deprecation contract and no breaking-change gate.** Its epoch
promises additive change, and no gate distinguishes an additive change from a breaking one: the
enterprise contract is frozen as a byte golden (`internal/central/centralapi/testdata/central.yaml`) whose test
catches drift from the generator rather than breakage for a caller. A removed operation and an
added optional field fail it
identically, and both are repaired by regenerating. The epoch itself is the constant
`centralEpoch` in this repository's `internal/central/centralapi/openapi.go`;
`scripts/check-central-epoch.mjs` reads it there and fails this site's build if the row above
disagrees — and re-checks, on every run that reads the source, that the golden
has not quietly grown the gate this row says does not exist.

The SDKs carry a **separate** contract version answering a different question, and the rules for
how it evolves are set out on [the SDK contract page](/docs/sdks/contract) rather than repeated
here.

## What is not mechanized

Published so a reader can weigh it, rather than discovered later:

| Rule | Standing today |
|---|---|
| Operation ids are unique | Enforced. Registering a duplicate panics at startup (`internal/api/contract/openapi_registry.go:233`). |
| Operation ids are not reused after retirement | A house convention recorded in the registry comments. No frozen list of retired ids exists, and no gate would catch a re-mint. |
| A retired operation answers `410` naming its successor | **Not implemented.** The `gone` problem type is registered (`internal/api/contract/problem.go:183`) and no handler emits it; no operation has been retired yet. The published description says so itself, in the conditional rather than the indicative, and the repository records the measurement at `internal/api/contract/problem_docs.go:160`. |
| The enterprise and cloud surfaces get the same additive gate | They do not. Neither is covered by `oas-breaking`. The cloud control plane has its own additive gate over its descriptor registry from epoch 1; the enterprise surface has none — its committed contract is a byte golden that catches drift from the generator, not breakage for a caller. |

<Callout type="warning">
**A revision citation written before 2026-09-01 may not identify a document.** Revisions `2.20`
through `2.25` each used to appear twice in the API changelog, from two numbering series a
repository merge concatenated. Those twelve headings now carry a track tag (`2.23-bm` for the
commercialization series, `2.23-jg` for the LLM-as-judge one), so a bare number identifies a
document again from here. Nothing was renumbered: `2.19` and below and `2.26` and above were never
ambiguous and have not moved, and the duplicated numbers themselves are unchanged. A citation into
that band written before the tags landed therefore still needs its date or its track to resolve.
The header of `docs/api/CHANGELOG.md` carries the resolution and the reasoning behind it.
</Callout>

## Reading the contract at runtime

A deployment answers for itself. `GET /tt/api/v1/capabilities` returns the running server's
`epoch` and `version` alongside the URLs of its spec, its docs and its problem registry
(`internal/api/contract/meta.go:97`). Both SDKs read it to explain a version-skew error rather than
guessing at one.

```console
$ curl -s https://your-gateway/tt/api/v1/capabilities | jq '.management_api.epoch'
2
```

The same object carries `version`, `spec_url`, `docs_url`, `problems_url` and
`problem_type_base`, so a client can find the contract from the deployment rather than from a
page like this one.

<NextSteps items={[
  { title: "HTTP headers", href: "/docs/reference/headers", description: "The wire-header contract these deprecation headers sit beside." },
  { title: "The SDK contract", href: "/docs/sdks/contract", description: "The separate contract version the two SDKs implement, and how it evolves." },
  { title: "Changelog", href: "/docs/reference/changelog", description: "What each capability wave added, by theme." }
]} />
