---
key: api.versioning-v1
title: '2026-07-13 — API versioning: the whole `/api` surface is mounted at `/api/v1`, additive-only after freeze (#286)'
status: active
since: '2026-07-13'
supersedes: none
superseded-by: none
rule: The entire `/api` surface is versioned to `/api/v1` uniformly (no unversioned corner); legacy unversioned callers are rewritten in-pipeline (not redirected) with Deprecation/Link/Sunset headers, and post-freeze `/api/v1` is additive-only — a breaking change requires `/api/v2`.
signals: '`ApiVersionRewriteMiddleware`, `ApiRouteVersioningTests`, RFC 8594 Deprecation header · paths: `docs/api-conventions.md` §1/§9 · issues: #286, #197'
mechanics: '`ApiRouteVersioningTests`, `docs/api-conventions.md` §1/§9'
---

The #197 cold review's C1 **BLOCKER**: `/api/*` was entirely unversioned (`info.version` was cosmetic), so the
first breaking change would silently break the SPA and any external/MCP client with no negotiation path. This is
the Phase-2 contract-freeze gate — versioning can't be added compatibly *after* the contract ossifies, so it
lands before freeze.

**What changed.** Every route under `/api` was swept to `/api/v1` — all 251 controller route attributes, the ~24
`Location`-header literals, the scanner callback URL (`CallLibraryScannerHandler.GetBaseUrl`), and the
`Startup` request-log path literal. This is **uniform**: the machine JSON API, the browser-session auth surface
(`/api/v1/auth/*`, still `IgnoreApi`), the internal loopback callbacks (`/api/v1/scan/*`) and the scripted-build
surface (`/api/v1/scripted/*`) are all versioned, so there is no unversioned corner and the compat rewrite needs
no exclusion list. The OpenAPI `v1.json` (160 paths), `endpoint-index.md`, and the SPA (945 request literals +
its test mocks, incl. regex/positional URL parsers) were regenerated/swept in lockstep. **No wire-DTO or
status-code change** — only the path prefix moved.

**Legacy compat = an in-pipeline rewrite, NOT a redirect** (`ApiVersionRewriteMiddleware`, sequenced before
`UseRouting` in the API branch). A legacy caller hitting an unversioned `/api/foo` has its request *path*
rewritten to `/api/v1/foo` and continues in-pipeline — method, body, auth headers and query string all survive,
so curl / the future MCP server / bookmarked URLs keep working with no round-trip (a 307/308 redirect would have
been fragile for non-GET + custom-header clients). Rewritten (legacy) responses carry RFC 8594 `Deprecation: true`
+ `Link: </docs>; rel="deprecation"`, and a `Sunset` header when `Api:LegacyRoutesSunset` is configured. An
already-versioned path (`/api/v1/*`) passes through untouched; a future `/api/v2/*` is **not** forced back to v1
(the middleware only fills in a *missing* version).

**Freeze semantics (owner decisions):** once shipped, `/api/v1` is **additive-only** — new endpoints/optional
fields are fine; renaming/removing/retyping an existing one requires a new `/api/v2`, never an in-place break.
The legacy-rewrite compat shim has a **2-release sunset window** (owner-chosen) before removal; the actual removal
is a tracked Phase-3 follow-up, not this PR. Existing pre-freeze warts (e.g. channel `{id}` vs `{channelNumber}`,
the synthesized negative-id "(none)" group rows) are frozen as-is per their own prior decisions.

**Route-convention standardization (#286, owner-requested).** The leading-slash inconsistency (238 absolute
`"/api/…"` method routes vs 13 relative `"api/…"`) is resolved: the standard is a **leading-slash absolute route
on each method's `[Http*]` attribute, no class-level `[Route]`** — except the two controllers where many actions
share a parametrized prefix (`ScannerController` `{scanId}`, `ScriptedScheduleController` `{buildId}`, ~40
methods), which keep a leading-slash absolute **class** `[Route("/api/v1/…")]` with relative method segments (the
right tool for a shared prefix). Enforced by `ApiRouteVersioningTests`: it reflects over every `[ApiController]`
in `Controllers.Api`, computes each action's *effective* route (ASP.NET's class+method combination rule), and
asserts it matches `^/api/v\d+/` — so a new controller that drifts (relative or unversioned) fails CI, the
"fix-it-while-you're-in-the-file" gate the `dotnet format` rules use. Browser-nav endpoints deliberately outside
`/api` (e.g. `GET /auth/oidc/login`) are out of scope for the test. Docs: `api-conventions.md` §1/§9. Refs #286 #197.
