API versioning & deprecation (TASK-0127)
The public REST API lives under a major-version prefix: /api/v1. This
document is the policy that governs how that surface evolves. The normative
parts are enforced by the CI contract test
(tests/integration/openapi.contract.test.ts) and the deprecation middleware
(apps/api/src/common/deprecation.ts).
1. The rule
/api/v1is stable. Additive changes (new endpoints, new optional fields, new query params, new errordetails[].reasonvalues) ship inside it without a version bump.- Breaking changes — removing/renaming an endpoint, changing a response
shape in a way clients cannot ignore, tightening validation that rejects
previously-valid input, changing a status code or error
code— ship only in a new major (/api/v2), side by side withv1for the duration of the deprecation window. - A client that speaks
v1keeps working until thev1Sunset; nothing inv1ever silently changes semantics.
2. Deprecation lifecycle
A route enters deprecation by being added to the DEPRECATED_ROUTES map in
apps/api/src/common/deprecation.ts:
export const DEPRECATED_ROUTES: Record<string, DeprecatedRoute> = {
"GET /api/v1/legacy-thing/:id": {
sunset: "Tue, 01 Jul 2027 00:00:00 GMT",
reason: "Use GET /api/v2/things/{id}"
};
};
From that point, every response from the route (success or error) carries
two standard headers (RFC 8594 Sunset):
Deprecation: true
Sunset: Tue, 01 Jul 2027 00:00:00 GMT
and the operation is marked deprecated: true in docs/api/openapi.yaml
(kept by hand — the map and the spec are the two halves of the record; the
contract test checks the live behavior, the spec check keeps the docs honest).
Policy for the window:
| Stage | Signal | Client expectation |
|---|---|---|
| In preview | endpoint present, not documented as stable | no guarantees |
| Stable | no headers | normal use |
| Deprecated | Deprecation: true + Sunset |
migrate before Sunset |
| Sunset passed | route removed (new major takes over) | 404 / 410 with a details pointer |
The Sunset date is at least 6 months from the deprecation date (the
published contract's minimum notice). The map is intentionally empty at
Phase-13 ship: the mechanism, the header contract, and a documented example
are what Phase 13 delivers; the first real deprecation happens when v2
lands (Phase 15, production).
3. What "breaking" means in practice (the decision table)
| Change | Breaking? | Handling |
|---|---|---|
| New endpoint | no | ship in v1 |
| New optional request field | no | ship in v1 |
| New field in a response | no | ship in v1 (clients ignore unknown fields) |
| Removing/renaming a response field | yes | new major + deprecation |
| Removing an endpoint | yes | new major + deprecation |
| Tightening validation (rejecting previously-valid input) | yes | new major + deprecation |
Changing a code or status for the same failure |
yes | new major + deprecation |
New error.details[].reason value for an existing failure |
no | ship in v1 |
| Changing rate-limit default numbers | no (operational) | ship in v1; announce in the changelog |
| New error code for a new class of failure | no | ship in v1 |
The rationale: clients that only handle the code they need stay safe
through additive changes; the additive details[].reason values are the
extension point (a client that ignores unknown reasons is still correct).
4. The contract that keeps this honest
- Bidirectional OpenAPI test —
tests/integration/openapi.contract.test.tsassertsspec paths ⊆ live routesandlive public routes ⊆ spec, plus that/api/v1/docsserves the interactive swagger-ui and/api/v1/docs/openapi.yamlserves the exact spec the test compares. A controller change without a spec regeneration fails CI in both directions. - Deprecation header test — a synthetic (non-live) route map is run
through
createDeprecationMiddlewareand theDeprecation+Sunsetheaders are asserted (the map is empty at ship, so the mechanism is proven without deprecating a live route). - Error-envelope walk — the canonical
{error:{code,message,details?, requestId}}shape and thedocs/api/authentication.md§9 code table are exercised by the integration suites (API keys, auth, permissions) that assert real 401/403/404/409/429 envelopes.
Protocol surfaces outside the v1 contract. The SSO (SAML/OIDC,
TASK-0131) and SCIM 2.0 (TASK-0132) protocol routes are served by the
API without the /api/v1 prefix (the IdP / user's browser mid-handshake
talks to them directly) and are excluded from this contract by an
explicit allowlist: scripts/generate-openapi.mjs skips the bases
internal, saml, sso and scim/v2 (NON_V1_BASES), and the contract
test's live-route introspection only walks routes under /api/v1. They
carry their own spec sections: sso.md and
scim.md. The org-admin SSO/SCIM routes
(/api/v1/orgs/:orgId/sso*, /api/v1/orgs/:orgId/scim*) are ordinary v1
routes and ARE in the contract.
5. Regenerating the spec
node scripts/generate-openapi.mjs
The generator scans apps/api/src/modules/** (controller base + method
decorators) and rewrites docs/api/openapi.yaml. It is the single source of
the route inventory — the contract test then verifies it against the booted
router, so a hand-edit to the spec that drifts from the controllers is caught
by the same gate.