--- 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: ; 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.