`.meta()` clones the schema in Zod 4 and registers the clone, so a bare
`schema.meta({ id, description })` statement registers nothing at all.
Forty-one of them sat across nine route modules. The ids they named —
`MeasurementType`, `MeasurementSource`, `LoginPasswordRequest`,
`CreateMeasurementRequest`, `CreateBatchWorkoutRequest` and thirty more —
reached `components.schemas` zero times, the descriptions written for them
were published nowhere, and every use site inlined an anonymous copy. A client
generated from the contract got duplicated nameless types where one named
model belongs, which is the part that matters, because the iOS client is why
the contract is published.
Forty of them now bind their clone, and every use site points at it. Where the
schema comes from `src/lib/validations/*` the import is aliased and the clone
takes the name the use sites already spell, so nothing mutates the runtime
validation schema. The seven that cross module boundaries are exported from
`./shared` and the eight consumers import them from there; importing the raw
schema from the validation module instead is exactly what inlined them.
Thirty-four named components appear where inline copies stood. Fully
dereferenced, the document differs by eighty-six added descriptions and
nothing else: no operation's request or response shape moves. `CoachPrefs`
was already force-registered in the index and finally carries its description.
Five ids legitimately stay out of `components.schemas` —
`ListMeasurementsQuery`, `ListAllergiesQuery`, `ListEncountersQuery`,
`SuggestEncounterQuery` and `ListPractitionersQuery`. The emitter expands a
`requestParams.query` object into inline `parameters`, so a query id has
nowhere to land; they stay in the assigning form for consistency.
Three findings the wiring turned up:
`CreateShareLinkRequest` and `HealthRecordExportRequest` were annotated in the
Coach module, which imported the two schemas for no other reason and never
referenced them. Both operations that send these bodies live in the
health-record module, so the annotations move there. Both descriptions
described a `sections` field: the export body has no such field and rejects it
with a 422, and the share-link body freezes scope through `selection` plus
`documentIds` / `documentOnly`. Publishing either sentence verbatim would have
published a lie, so both are corrected to the shape the routes actually take.
`CoachPlansListQuery` — the forty-first — named a component that nothing
referenced and that a query object could never reach. The operation publishes
its two parameters by hand, because the schema carries a cross-field
refinement the expansion cannot express, and every clause of the annotation is
already published on the operation and on both parameters. It is deleted
rather than left registering nothing.
`pnpm openapi:check` compares the registry against the committed YAML and
never the routes against the registry, so a route that was simply never
registered stayed invisible to the gate. Sixteen of them are called by the
native client and describe nothing: sign-out, the three per-user preference
scalars, the two pre-session discovery reads, passkey login options, the
passkey rename and delete, registration status, the consent latest-receipt
reader and its revoke, account deletion, the instance service switches, the
API-token list and revoke, the threshold overrides, the version read, and the
assistant feature matrix.
Each contract is derived from the handler rather than from its docblock, so
the published statuses are the ones the code can actually produce — the 413 on
a 1 KB body cap, the 400 the consent family answers where the rest of the API
answers 422, the 403 an instance-wide API switch produces on a token READ, the
last-admin and last-Guardian refusals on account deletion, and the fresh-factor
arm that a Bearer transport cannot clear at any scope.
Four request bodies moved to the validation modules the handlers now parse
with, so the published wire cannot drift from the runtime one: the three
`/api/auth/me` preference scalars into a new `user-prefs` module, and the
passkey rename beside the login schemas. Behaviour is unchanged.
The generated YAML is left out; it is regenerated once the remaining groups
land.
POST /api/consent/ai/web now accepts an optional { intent } body. Without
one (the AI-settings mount heal) a standing revocation still blocks the
mint; intent "affirmative" is the user's own consent act and may
supersede an earlier revocation, exactly as a first grant would. The
consent card offers the explicit grant control whenever consent does not
stand, and the audit row records which intent minted the receipt.
The document auto-read opt-in already declared "affirmative" on enable;
the settings-mount heal posts no body. Both stay as they are.
The server-side gate requires an active ConsentReceipt before any health
snapshot egresses via the operator's server-managed key, but the web
client never minted one — so web users on a shared-key deployment hit the
fail-closed no-key fallback on every AI surface.
Add ensureWebAiConsentReceipt() and POST /api/consent/ai/web: an
idempotent ai_full grant mirroring the iOS master grant (a user with an
active receipt is a no-op). The AI-settings card calls it on mount — the
web equivalent of the iOS shell-mount heal — so existing accounts gain a
receipt without a re-consent step.
Surface an honest signal: resolveReadOnlyStatusMiss now returns a
consent-missing outcome when a provider is configured but the gate would
block egress, distinct from no-provider, and skips the wasted enqueue.
Document the receipt precondition + the consent surface in the OpenAPI
contract (it shipped without a bump): the /api/consent/ai family, a
Consent tag, and a 403 consent.ai.required response on the AI-egress
route.