`/api/v1` ADVERTISES its contract version on every response — an `x-motir-api-version` header, so a client's skew gate costs nothing
Nothing on a /api/v1 response says which contract version served it. Verified by grep on origin/main @ 6d472611 during 11.5.1:
withV1Route(lib/api/v1/route.ts) stamps exactlyx-request-idandx-ratelimit-{limit,remaining,reset}on every exit path. No version header.GET /api/v1/mereturnsmeSchema, which is.strict()over{ user: { id, name, email }, workspaceId, scopes }(lib/api/v1/identity/schema.ts). No version field, and.strict()means one cannot appear by accident.V1_CONTRACT_VERSIONis referenced byemit.ts,lib/apiDocs/reference.tsand two tests — never by a response path.
So the only surface carrying the number is the spec document at /api/openapi/v1.json, and a client that wants to know what it is talking to must download a specification to read one string.
Why this is a follow-up and not part of 11.5
11.5's boundary forbids server changes, and the story that would have owned this — 11.7 — shipped before the gap was written up. docs/decisions/cli-v1-client.md Q3 therefore pins a lazy probe: the CLI fetches the spec only after a boundary parse failure or an unrouted 404, once per process, and only then compares majors/minors. That design is correct on its own and this card is not a prerequisite for it.
This card makes the probe free rather than possible. When the header lands, the client reads it off a response it already made and skips the spec fetch entirely; the probe stays as the fallback for a server that does not send it (every server older than this card).
What to build
withV1Routestampsx-motir-api-version: <V1_CONTRACT_VERSION>intoresponseHeadersalongside the request id — before the try block, so it survives a 401, a 403, a 429, a mapped domain error and a 500 alike, exactly asx-request-iddoes. A client that gets a 404 for an absent route still learns the version from the 404 it got for a route that exists.- Declare it in the emitted document — add the header to
V1_SHARED_RESPONSE_HEADERS(lib/api/v1/openapi/headers.ts) with a description saying it is the CONTRACT'sMAJOR.MINOR.PATCH(ADR Amendment 4 Q6), not the deployment's release number, so a reader cannot mistake it for one. - Do NOT add it to
meSchema. That schema is.strict()and its three fields are deliberate; a version belongs on the transport, not in one endpoint's body, and putting it there would make it unavailable on every other response.
Acceptance criteria
- Every
/api/v1response carriesx-motir-api-versionequal toV1_CONTRACT_VERSION— asserted on a 200, a 401, a 403, a 429, a mapped domain error and a 500, driving the real wrapper, not a fixture. - The header is declared in
V1_SHARED_RESPONSE_HEADERSand therefore appears on every operation in the emitted OpenAPI document — asserted against the emitted document, not against the constant. meSchemais unchanged, and a test asserts it still has exactly its three keys.- The header value is read from
V1_CONTRACT_VERSION, never restated — asserted by importing the constant in the test rather than hard-coding1.0.0. - Adding the header is documented as an ADR §8 additive change in
public-api-conventions.md, withV1_CONTRACT_VERSIONbumped to the next MINOR in the same PR (a new response header is exactly §8's additive case, and a contract version that does not move when the contract grows is the one thing this header must never be). - The per-file coverage floor (≥90%) holds.
Context refs
lib/api/v1/route.ts—withV1Route,responseHeaders, and thestamphelper every exit path runs through.lib/api/v1/openapi/headers.ts—V1_SHARED_RESPONSE_HEADERS, wherex-request-idand the rate-limit trio are declared.lib/api/v1/openapi/emit.ts—V1_CONTRACT_VERSION, and Amendment 4 Q6's meaning for it.lib/api/v1/identity/schema.ts—meSchema, the.strict()shape this card must NOT widen.docs/decisions/cli-v1-client.mdQ3 — the consumer, the grep this card records, and why the probe stands without it.docs/decisions/public-api-conventions.md§8 — the additive-change rule this lands under.