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