--- key: security.contract-freeze-honesty title: 2026-07-12 (#197 Bundle C — contract-freeze honesty) status: active since: '2026-07-12' supersedes: none superseded-by: none rule: The OpenAPI doc's declared security/401 scheme is generated from the same `ApiKeyAuthorizationFilter.EndpointRequiresKey` predicate the runtime enforces (so declared auth can't drift from enforced auth), every `/api/*` action returns a ResponseModel (no raw Application VMs), and Channel REST resources are keyed by immutable `Id`, never mutable `Number`. signals: 'OpenAPI contract honesty, ResponseModel wrapping, Id vs Number key · paths: `OpenApiContractHonestyTests`, `ErsatzTV.Core/Api` · issues: #197, #287, #288' mechanics: '`OpenApiContractHonestyTests`; `docs/api-conventions.md`' --- **#287 — OpenAPI contract honesty by construction.** The "v1" document now emits the `ApiKey` security scheme plus per-operation `security`/`401` derived from the *same* `ApiKeyAuthorizationFilter.EndpointRequiresKey` predicate the runtime filter enforces, so declared auth can never drift from enforced auth. Every operation also gets a synthesized stable `operationId` (the framework only assigned one when `Name=` was set — ~90 were missing), and body/param-binding operations get the documented `400 ValidationProblemDetails` they actually return. `DayOfWeek` is now a string enum in the schema (added to `Startup.UseStringEnumSchemas`), removing the SPA's `WithDayNames` wart. Pinned by in-process document generation in tests (`OpenApiContractHonestyTests`) rather than the committed `v1.json`. **#288 — Wrap the last raw ViewModels; reverse the §7a "intentional `version` leak."** Minted `MediaCollectionResponseModel`, `ProgramScheduleResponseModel`, and `ChannelDetailResponseModel` (all `#nullable enable`) and routed `CollectionController` / `ScheduleController` / `SmartCollectionController` / `ResolutionController.GetResolutionByName` / the channel detail GET+writes through ResponseModels, so no `/api/*` action returns an Application VM. This reverses the earlier §7a judgment that a ResponseModel "purely to hide one field was disproportionate": `Version` is now header-only (ETag) on every aggregate body — confirmed safe by grepping `web/src` (the SPA reads `version` from the ETag header, never the response body). `ChannelDetailResponseModel` is the *full editable* field set the channel editor needs (distinct from the lean list `ChannelResponseModel`; drops only the derived `webEncodedName`). Also flipped `#nullable enable` onto the remaining 24 lagging `ErsatzTV.Core/Api/` files for schema honesty, and added `pageNum` paging to `GET /api/search`. **Channel REST resources are keyed by database `Id`, never by `Number`.** `Channel.Number` is user-mutable (editable on update, bulk-renumbered via `/api/channels/bulk/renumber`, transiently invalid mid-renumber), so the immutable int PK is the canonical key for all `/api/channels/*` single-item routes, sub-resources (including `playout/reset`, re-keyed from `{channelNumber}` to `{id:int}` in Bundle C), and `Location` headers. `Number` remains the identity on broadcast surfaces only (IPTV/M3U/XMLTV), a separate contract. A number-based lookup endpoint may be added additively later; `UniqueId` (Guid) stays out of the REST contract absent a federation requirement.