diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 6cc0471ec..2ffb877dd 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -90,8 +90,10 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "403": &a2 description: >- - Cycle tracking is not enabled for this account (errorCode `cycle.disabled`). Resolved against the RECORD - being read, not the caller: a delegate inside somebody else's record sees the owner's setting. + `meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: Cycle tracking is not enabled for + this account (errorCode `cycle.disabled`). Resolved against the RECORD being read, not the caller: a + delegate inside somebody else's record sees the owner's setting. Refused: this request named a record the caller may not act on (`meta.errorCode` = `sharing.access.denied`). @@ -259,8 +261,10 @@ paths: $ref: "#/components/schemas/CycleCustomSymptomUpdatedEnvelope" "401": *a1 "403": &a3 - description: "Cycle tracking is not enabled for this account (errorCode `cycle.disabled`). Resolved against the RECORD - being read, not the caller: a delegate inside somebody else's record sees the owner's setting." + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: Cycle tracking is not enabled for + this account (errorCode `cycle.disabled`). Resolved against the RECORD being read, not the caller: a + delegate inside somebody else's record sees the owner's setting." content: application/json: schema: @@ -395,7 +399,7 @@ paths: "403": *a2 "409": *a7 "422": *a4 - "429": &a12 + "429": &a13 description: "Rate limit exceeded. This route shares one per-account bucket with every other authenticated write — `record-write:`, 300 requests per 60 seconds — keyed on the ACTING account, so a delegate burns their own allowance rather than the record owner's. Only the routes that meter themselves with a dedicated @@ -736,6 +740,15 @@ paths: schema: $ref: "#/components/schemas/CyclePrefsGetEnvelope" "401": *a1 + "403": &a9 + description: "The credential is valid but its scope does not reach this operation. `meta.errorCode` = + `auth.scope.insufficient`: a narrow Bearer token on a route that names no scope it holds, or a + connector-bound token on a write. Retrying with the same token will not help; use a session, or a token + minted with the scope the route names." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 patch: @@ -757,7 +770,7 @@ paths: application/json: schema: $ref: "#/components/schemas/CyclePrefsPatchEnvelope" - "400": &a14 + "400": &a15 description: The request body is not parseable JSON. Nothing was read and nothing was written. `meta.errorCode` names the surface that refused it (`.body.invalid_json`). This is a client-side serialisation fault, not a validation failure — a body that parses but fails the schema is the 422 beside this. @@ -766,6 +779,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/measurement-categories: @@ -785,6 +799,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementCategoriesEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/meta/capabilities: @@ -805,6 +820,7 @@ paths: schema: $ref: "#/components/schemas/CapabilitiesEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/version: @@ -877,7 +893,9 @@ paths: $ref: "#/components/schemas/AssistantFlagsEnvelope" "401": *a1 "403": - description: A selector header was attached to an actor surface (`meta.errorCode` = `sharing.not_permitted`). + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: A selector header was attached to + an actor surface (`meta.errorCode` = `sharing.not_permitted`)." content: application/json: schema: @@ -916,6 +934,7 @@ paths: schema: $ref: "#/components/schemas/UpdateCheckEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 "502": @@ -964,16 +983,17 @@ paths: type: string format: binary "401": *a1 - "403": &a11 - description: "Refused: this request named a record the caller may not act on (`meta.errorCode` = - `sharing.access.denied`). Byte-identical for an account that does not exist, an account that granted - nothing, a grant whose sections do not reach this surface, and a read grant on a request that writes — the - response carries no reason, so it is not an account-enumeration oracle. The reason reaches the record - owner's activity feed and the operator's audit trail instead. On the cookie transport the same code answers - one further case: a session that has entered a shared record and sends a request without its record-context - assertion, which is a client older than the fence — leave the record and reload. A request that names no - record but the caller's own never reaches this response, so a client that never switches and never sends the - per-request account selector will not see it." + "403": &a12 + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: Refused: this request named a + record the caller may not act on (`meta.errorCode` = `sharing.access.denied`). Byte-identical for an account + that does not exist, an account that granted nothing, a grant whose sections do not reach this surface, and + a read grant on a request that writes — the response carries no reason, so it is not an account-enumeration + oracle. The reason reaches the record owner's activity feed and the operator's audit trail instead. On the + cookie transport the same code answers one further case: a session that has entered a shared record and + sends a request without its record-context assertion, which is a client older than the fence — leave the + record and reload. A request that names no record but the caller's own never reaches this response, so a + client that never switches and never sends the per-request account selector will not see it." content: application/json: schema: @@ -1011,6 +1031,7 @@ paths: schema: $ref: "#/components/schemas/ShareLinkCreated" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 get: @@ -1027,6 +1048,7 @@ paths: schema: $ref: "#/components/schemas/ShareLinkList" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/share-links/{id}: @@ -1050,6 +1072,7 @@ paths: schema: $ref: "#/components/schemas/ShareLinkRevoked" "401": *a1 + "403": *a9 "404": description: Link not found (or owned by another user). content: @@ -1257,6 +1280,7 @@ paths: type: string format: binary "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/fhir/Patient: @@ -1286,6 +1310,7 @@ paths: type: string format: binary "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/fhir/Observation: @@ -1332,6 +1357,7 @@ paths: type: string format: binary "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/fhir/MedicationStatement: @@ -1361,6 +1387,7 @@ paths: type: string format: binary "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/fhir/MedicationAdministration: @@ -1390,6 +1417,7 @@ paths: type: string format: binary "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/fhir/Coverage: @@ -1420,6 +1448,7 @@ paths: type: string format: binary "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/fhir/Patient/{id}: @@ -1445,6 +1474,7 @@ paths: type: string format: binary "401": *a1 + "403": *a9 "404": description: OperationOutcome (application/fhir+json). content: @@ -1477,6 +1507,7 @@ paths: type: string format: binary "401": *a1 + "403": *a9 "404": description: OperationOutcome (application/fhir+json). content: @@ -1510,6 +1541,7 @@ paths: type: string format: binary "401": *a1 + "403": *a9 "404": description: OperationOutcome (application/fhir+json). content: @@ -1542,6 +1574,7 @@ paths: type: string format: binary "401": *a1 + "403": *a9 "404": description: OperationOutcome (application/fhir+json). content: @@ -1574,6 +1607,7 @@ paths: type: string format: binary "401": *a1 + "403": *a9 "404": description: OperationOutcome (application/fhir+json). content: @@ -1617,6 +1651,7 @@ paths: type: string format: binary "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/export: @@ -1676,6 +1711,7 @@ paths: schema: type: string "401": *a1 + "403": *a9 "422": description: "`format` or `type` carried a value outside its enum. The message names the accepted set; nothing is exported. Note that this route validates by hand rather than through Zod, so the response is the @@ -1719,6 +1755,7 @@ paths: schema: $ref: "#/components/schemas/FullBackupPayload" "401": *a1 + "403": *a9 "429": description: More than 10 exports in the trailing hour for this account (`export:`, shared across the export family). @@ -1779,6 +1816,7 @@ paths: schema: type: string "401": *a1 + "403": *a9 "429": description: More than 10 exports in the trailing hour for this account. content: @@ -1845,6 +1883,7 @@ paths: schema: type: string "401": *a1 + "403": *a9 "404": description: "`medicationId` named a medication this account does not hold. An unknown id and another account's id are indistinguishable." @@ -1900,6 +1939,7 @@ paths: schema: type: string "401": *a1 + "403": *a9 "429": description: More than 10 exports in the trailing hour for this account. content: @@ -1938,8 +1978,10 @@ paths: "401": *a1 "403": description: >- - The Bearer token carries a narrow scope. The encrypted archive needs a cookie session or a full-access - token; the refusal happens before any second-factor proof is looked at. + `meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: The Bearer token carries a narrow + scope. The encrypted archive needs a cookie session or a full-access token; the refusal happens before any + second-factor proof is looked at. Refused: the request was made while the session is acting on another account (`meta.errorCode` = @@ -2135,7 +2177,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "409": &a9 + "409": &a10 description: 'Refused before anything was checked or spent: the request stated `clientTransport.protocol: "http"` on a non-loopback host while this server issues `Secure` session cookies, so the browser would drop the session. `meta.errorCode` = `auth.session.insecure_transport`, `meta.sessionCookieSecure` = `true`. No password was @@ -2170,7 +2212,8 @@ paths: schema: $ref: "#/components/schemas/MfaVerifyResponse" "401": *a1 - "409": *a9 + "403": *a9 + "409": *a10 "422": *a4 "429": *a5 /api/auth/oidc/native/token: @@ -2203,6 +2246,7 @@ paths: schema: $ref: "#/components/schemas/OidcNativeTokenResponse" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/auth/native/login: @@ -2276,6 +2320,7 @@ paths: schema: $ref: "#/components/schemas/NativeHandoffTokenResponse" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/auth/step-up/options: @@ -2302,6 +2347,7 @@ paths: schema: $ref: "#/components/schemas/StepUpOptionsEnvelope" "401": *a1 + "403": *a9 "409": description: No passkey registered on this account. content: @@ -2370,6 +2416,7 @@ paths: schema: $ref: "#/components/schemas/StepUpMintEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/auth/reproof: @@ -2414,6 +2461,7 @@ paths: schema: $ref: "#/components/schemas/ReproofEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 get: @@ -2432,6 +2480,7 @@ paths: schema: $ref: "#/components/schemas/ReproofStatusEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/auth/me/mfa/totp/setup: @@ -2471,13 +2520,14 @@ paths: application/json: schema: $ref: "#/components/schemas/TotpSetupEnvelope" - "401": &a10 + "401": &a11 description: No session or elevation; or, on the cookie path, `auth.reproof.required` (no recent sign-in and no proof in the body) or `auth.reproof.failed` (the proof did not verify). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" + "403": *a9 "409": description: A second factor is already active on this account. content: @@ -2514,6 +2564,7 @@ paths: schema: $ref: "#/components/schemas/TotpConfirmEnvelope" "401": *a1 + "403": *a9 "409": description: A second factor is already active, or enrollment was never started (no pending secret to confirm). content: @@ -2544,6 +2595,7 @@ paths: schema: $ref: "#/components/schemas/MfaDisableEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/auth/me/mfa/recovery-codes/regenerate: @@ -2562,6 +2614,7 @@ paths: schema: $ref: "#/components/schemas/MfaRecoveryRegenEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/auth/me/mfa: @@ -2581,6 +2634,7 @@ paths: schema: $ref: "#/components/schemas/MfaStatusEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/auth/me/mfa/webauthn/register/options: @@ -2619,7 +2673,8 @@ paths: application/json: schema: $ref: "#/components/schemas/MfaWebauthnRegisterOptionsEnvelope" - "401": *a10 + "401": *a11 + "403": *a9 "422": *a4 "429": *a5 /api/auth/me/mfa/webauthn/register/verify: @@ -2650,6 +2705,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/auth/me/mfa/webauthn/{id}: @@ -2680,6 +2736,7 @@ paths: schema: $ref: "#/components/schemas/MfaWebauthnRenameEnvelope" "401": *a1 + "403": *a9 "404": description: No such security key for this account. content: @@ -2708,6 +2765,7 @@ paths: schema: $ref: "#/components/schemas/MfaWebauthnRemoveEnvelope" "401": *a1 + "403": *a9 "404": description: No such security key for this account. content: @@ -2737,6 +2795,7 @@ paths: schema: $ref: "#/components/schemas/MfaWebauthnLoginOptionsEnvelope" "401": *a1 + "403": *a9 "409": description: No security key is registered on the account — fall back to the TOTP / recovery-code challenge. content: @@ -2767,7 +2826,8 @@ paths: schema: $ref: "#/components/schemas/MfaWebauthnVerifyResponse" "401": *a1 - "409": *a9 + "403": *a9 + "409": *a10 "422": *a4 "429": *a5 /api/auth/passkey/login-verify: @@ -2852,6 +2912,7 @@ paths: - $ref: "#/components/schemas/RefreshResponse" - $ref: "#/components/schemas/RefreshRevokeResponse" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/auth/passkeys: @@ -2870,6 +2931,7 @@ paths: schema: $ref: "#/components/schemas/PasskeyListEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/auth/me/sessions: @@ -2888,6 +2950,7 @@ paths: schema: $ref: "#/components/schemas/SessionListEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 delete: @@ -2920,6 +2983,7 @@ paths: schema: $ref: "#/components/schemas/SignOutEverywhereEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/auth/me/sessions/{id}: @@ -2943,6 +3007,7 @@ paths: schema: $ref: "#/components/schemas/SessionRevokeEnvelope" "401": *a1 + "403": *a9 "404": description: Session not found or not owned by the caller. content: @@ -2966,6 +3031,7 @@ paths: schema: $ref: "#/components/schemas/TrustedDeviceListEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 delete: @@ -2981,6 +3047,7 @@ paths: schema: $ref: "#/components/schemas/TrustedDeviceRevokeAllEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/auth/me/trusted-devices/{id}: @@ -3003,6 +3070,7 @@ paths: schema: $ref: "#/components/schemas/TrustedDeviceRevokeEnvelope" "401": *a1 + "403": *a9 "404": description: Trusted device not found or not owned by the caller. content: @@ -3036,6 +3104,7 @@ paths: schema: $ref: "#/components/schemas/SecurityActivityEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/auth/logout: @@ -3124,7 +3193,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "409": *a9 + "409": *a10 "429": *a5 /api/auth/passkeys/{id}: patch: @@ -3166,6 +3235,7 @@ paths: schema: $ref: "#/components/schemas/PasskeyRenameEnvelope" "401": *a1 + "403": *a9 "404": description: No such passkey for this account. content: @@ -3228,6 +3298,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" + "403": *a9 "404": description: No such passkey for this account. content: @@ -3264,6 +3335,7 @@ paths: schema: $ref: "#/components/schemas/ApiTokenListEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/tokens/measurements: @@ -3322,8 +3394,10 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 "403": - description: The operator has switched the API off instance-wide (`AppSettings.apiGlobal`). Nothing was minted. A Bearer - caller does not reach this arm — it is refused 401 before the switch is read. + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: The operator has switched the API + off instance-wide (`AppSettings.apiGlobal`). Nothing was minted. A Bearer caller does not reach this arm — + it is refused 401 before the switch is read." content: application/json: schema: @@ -3412,8 +3486,9 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 "403": - description: The operator has switched the API off instance-wide. Nothing was minted. A Bearer caller is refused 401 - before the switch is read. + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: The operator has switched the API + off instance-wide. Nothing was minted. A Bearer caller is refused 401 before the switch is read." content: application/json: schema: @@ -3499,8 +3574,9 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 "403": - description: The operator has switched the API off instance-wide. Nothing was minted. A Bearer caller is refused 401 - before the switch is read. + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: The operator has switched the API + off instance-wide. Nothing was minted. A Bearer caller is refused 401 before the switch is read." content: application/json: schema: @@ -3564,10 +3640,11 @@ paths: $ref: "#/components/schemas/ApiTokenRevokeEnvelope" "401": *a1 "403": - description: The operator has switched the API off instance-wide (`AppSettings.apiGlobal`). `meta.errorCode` is the - status default `http.forbidden`; nothing was revoked. The sibling list is deliberately not gated this way — - a token stays live while the switch is off, so its owner can still see it here even though this call will - not kill it. + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: The operator has switched the API + off instance-wide (`AppSettings.apiGlobal`). `meta.errorCode` is the status default `http.forbidden`; + nothing was revoked. The sibling list is deliberately not gated this way — a token stays live while the + switch is off, so its owner can still see it here even though this call will not kill it." content: application/json: schema: @@ -3685,6 +3762,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" + "403": *a9 "422": *a4 "429": *a5 /api/auth/profile: @@ -3721,6 +3799,7 @@ paths: schema: $ref: "#/components/schemas/AuthProfileUpdateEnvelope" "401": *a1 + "403": *a9 "409": description: The requested email belongs to another account (`meta.errorCode` = `profile.update.emailInUse`). Nothing was written. @@ -3736,7 +3815,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": &a16 + "429": &a17 description: "Too many email-address changes, and the request asked for nothing else. The address change shares one per-account bucket across both profile routes — `profile-email:`, 10 requests per 3600 seconds — keyed on the ACTING account, so an account cannot collect a fresh allowance by probing a different address. @@ -3933,6 +4012,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" + "403": *a9 "409": description: The re-proof arm asked for a method the account has no credential for — fall back to another factor. content: @@ -3993,6 +4073,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" + "403": *a9 "422": *a4 "429": *a5 /api/auth/codex/device-start: @@ -4020,6 +4101,7 @@ paths: schema: $ref: "#/components/schemas/CodexDeviceStartEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/auth/codex/device-poll: @@ -4050,6 +4132,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 "502": @@ -4080,6 +4163,7 @@ paths: schema: $ref: "#/components/schemas/CodexDisconnectEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/sync/state: @@ -4098,6 +4182,7 @@ paths: schema: $ref: "#/components/schemas/SyncStateEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/sync/changes: @@ -4137,6 +4222,7 @@ paths: schema: $ref: "#/components/schemas/SyncChangesEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/measurements/series-batch: @@ -4186,7 +4272,7 @@ paths: schema: $ref: "#/components/schemas/GetMeasurementsSeriesBatchResponse" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/measurements/series: @@ -4241,7 +4327,7 @@ paths: schema: $ref: "#/components/schemas/GetMeasurementsSeriesResponse" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/measurements: @@ -4462,7 +4548,7 @@ paths: schema: $ref: "#/components/schemas/ListMeasurementsResponse" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 post: @@ -4518,7 +4604,7 @@ paths: schema: $ref: "#/components/schemas/CreateMeasurementResponse" "401": *a1 - "403": *a11 + "403": *a12 "409": description: A measurement with this data already exists — the `(userId, type, source, externalId)` dedup index rejected the write. Never for a named sleep-stage body, which answers a re-post with 200 instead. @@ -4534,7 +4620,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": *a12 + "429": *a13 /api/measurements/{id}: get: tags: @@ -4555,7 +4641,7 @@ paths: schema: $ref: "#/components/schemas/GetMeasurementResponse" "401": *a1 - "403": *a11 + "403": *a12 "404": description: Measurement not found (or owned by another user). content: @@ -4603,7 +4689,7 @@ paths: schema: $ref: "#/components/schemas/UpdateMeasurementResponse" "401": *a1 - "403": *a11 + "403": *a12 "404": description: Measurement not found (or owned by another user). content: @@ -4639,7 +4725,7 @@ paths: schema: $ref: "#/components/schemas/DeleteMeasurementResponse" "401": *a1 - "403": *a11 + "403": *a12 "404": description: Measurement not found (or owned by another user). content: @@ -4683,6 +4769,7 @@ paths: schema: $ref: "#/components/schemas/BatchMeasurementsResponse" "401": *a1 + "403": *a9 "409": *a7 "422": description: "The batch exceeded the 500-entry limit (`measurement.batch.too_large`), failed validation @@ -4723,6 +4810,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementsDeleteByExternalIdsResponse" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/measurements/bulk-delete: @@ -4750,7 +4838,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementsBulkDeleteResponse" "401": *a1 - "403": *a11 + "403": *a12 "409": *a7 "422": *a4 "429": *a5 @@ -4779,7 +4867,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementsRestoreResponse" "401": *a1 - "403": *a11 + "403": *a12 "409": *a7 "422": *a4 "429": *a5 @@ -4812,11 +4900,13 @@ paths: schema: $ref: "#/components/schemas/SleepNightResponse" "401": *a1 - "403": &a13 + "403": &a14 description: >- - Module disabled for this account: the user (or operator) has the module turned off. `meta.errorCode` = - `module.disabled` and `meta.module` carries the disabled module key (e.g. "sleep"). Returned even for a - valid Bearer token. Clients hide the whole module surface end-to-end rather than retry. + `meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: Module disabled for this account: + the user (or operator) has the module turned off. `meta.errorCode` = `module.disabled` and `meta.module` + carries the disabled module key (e.g. "sleep"). Returned even for a valid Bearer token. Clients hide the + whole module surface end-to-end rather than retry. Refused: this request named a record the caller may not act on (`meta.errorCode` = `sharing.access.denied`). @@ -4853,7 +4943,7 @@ paths: schema: $ref: "#/components/schemas/SleepRhythmResponse" "401": *a1 - "403": *a13 + "403": *a14 "422": *a4 "429": *a5 /api/workouts: @@ -4902,7 +4992,7 @@ paths: schema: $ref: "#/components/schemas/ListWorkoutsResponse" "401": *a1 - "403": *a13 + "403": *a14 "422": *a4 "429": *a5 /api/workouts/{id}: @@ -4938,10 +5028,12 @@ paths: schema: $ref: "#/components/schemas/GetWorkoutResponse" "401": *a1 - "403": &a19 - description: 'Module disabled for this account: the user (or operator) has the module turned off. `meta.errorCode` = - `module.disabled` and `meta.module` carries the disabled module key (e.g. "sleep"). Returned even for a - valid Bearer token. Clients hide the whole module surface end-to-end rather than retry.' + "403": &a21 + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: Module disabled for this account: + the user (or operator) has the module turned off. `meta.errorCode` = `module.disabled` and `meta.module` + carries the disabled module key (e.g. \"sleep\"). Returned even for a valid Bearer token. Clients hide the + whole module surface end-to-end rather than retry." content: application/json: schema: @@ -4976,6 +5068,7 @@ paths: schema: $ref: "#/components/schemas/DeleteWorkoutResponse" "401": *a1 + "403": *a9 "404": description: Workout not found (or owned by another user). content: @@ -5038,6 +5131,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 + "403": *a9 "409": *a7 "413": description: Request body exceeds the 5 MB ceiling. @@ -5095,7 +5189,7 @@ paths: schema: $ref: "#/components/schemas/ActivityCalendarEnvelope" "401": *a1 - "403": *a13 + "403": *a14 "422": *a4 "429": *a5 /api/activity/goals: @@ -5113,6 +5207,7 @@ paths: schema: $ref: "#/components/schemas/ActivityGoalsEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 patch: @@ -5135,8 +5230,9 @@ paths: schema: $ref: "#/components/schemas/ActivityGoalsPatchEnvelope" "401": *a1 + "403": *a9 "422": *a4 - "429": *a12 + "429": *a13 /api/measurements/provenance: get: tags: @@ -5261,7 +5357,7 @@ paths: schema: $ref: "#/components/schemas/DayProvenanceEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/measurements/provenance/compare: @@ -5439,7 +5535,7 @@ paths: schema: $ref: "#/components/schemas/SourceComparisonEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/devices: @@ -5463,6 +5559,7 @@ paths: schema: $ref: "#/components/schemas/DeviceRegisterResponse" "401": *a1 + "403": *a9 "409": description: Device or APNs token already registered to another user. content: @@ -5497,6 +5594,7 @@ paths: schema: $ref: "#/components/schemas/DeviceRevokeEnvelope" "401": *a1 + "403": *a9 "404": description: No such device for this caller — also the answer for one already revoked, so a retry after a successful revoke is a 404 rather than an idempotent 200. @@ -5549,7 +5647,7 @@ paths: schema: $ref: "#/components/schemas/MedicationIntakeAggregateEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": description: The query failed validation — a missing or unknown `scope`, or a `days` outside 1..365. Multi-issue envelope; an audit breadcrumb is filed under the RECORD as `medications.intake.list.validation-failed` with @@ -5639,9 +5737,11 @@ paths: "401": *a1 "403": description: >- - Refused: a delegate tried to snooze, or to change a dose the owner had already recorded as taken or skipped - (`meta.errorCode` = `sharing.not_permitted`). Both stay open to the owner, and the attempt is filed on the - owner's activity trail. + `meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: Refused: a delegate tried to + snooze, or to change a dose the owner had already recorded as taken or skipped (`meta.errorCode` = + `sharing.not_permitted`). Both stay open to the owner, and the attempt is filed on the owner's activity + trail. Refused: this request named a record the caller may not act on (`meta.errorCode` = `sharing.access.denied`). @@ -5677,7 +5777,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": *a12 + "429": *a13 /api/medications/{id}/glp1: get: tags: @@ -5702,7 +5802,7 @@ paths: schema: $ref: "#/components/schemas/Glp1DetailResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "404": description: Medication not found (or owned by another account). content: @@ -5786,7 +5886,7 @@ paths: schema: $ref: "#/components/schemas/Glp1PostResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "404": description: Medication not found (or owned by another account). content: @@ -5821,7 +5921,9 @@ paths: $ref: "#/components/schemas/MedicationApiEndpointStateEnvelope" "401": *a1 "403": - description: The operator has disabled the API globally. + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: The operator has disabled the API + globally." content: application/json: schema: @@ -5875,7 +5977,9 @@ paths: $ref: "#/components/schemas/MedicationApiEndpointCreatedEnvelope" "401": *a1 "403": - description: The operator has disabled the API globally. + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: The operator has disabled the API + globally." content: application/json: schema: @@ -5921,7 +6025,7 @@ paths: schema: $ref: "#/components/schemas/ReminderPhaseConfigEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "404": description: Medication not found (or owned by another account). content: @@ -5959,6 +6063,7 @@ paths: schema: $ref: "#/components/schemas/ReminderPhaseConfigSavedEnvelope" "401": *a1 + "403": *a9 "404": description: Medication not found (or owned by another account). content: @@ -5987,6 +6092,7 @@ paths: schema: $ref: "#/components/schemas/ReminderPhaseConfigResetEnvelope" "401": *a1 + "403": *a9 "404": description: Medication not found (or owned by another account). content: @@ -6037,7 +6143,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationSideEffectsEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "404": description: Medication not found (or owned by another account). content: @@ -6076,7 +6182,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationSideEffectEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "404": description: Medication not found (or owned by another account). content: @@ -6121,7 +6227,7 @@ paths: schema: $ref: "#/components/schemas/DeleteMedicationSideEffectEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "404": description: No such entry for this medication and this record. An unknown id, another medication's entry and another record's entry are indistinguishable. @@ -6169,7 +6275,7 @@ paths: schema: $ref: "#/components/schemas/UpdateMedicationIntakeEventEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "404": description: No live event with that id under this medication and record. An unknown id and an already-tombstoned one are indistinguishable. @@ -6217,7 +6323,7 @@ paths: schema: $ref: "#/components/schemas/DeleteMedicationIntakeEventEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "404": description: No event with that id under this medication and record. content: @@ -6260,7 +6366,7 @@ paths: schema: $ref: "#/components/schemas/BulkDeleteMedicationIntakeEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "404": description: Medication not found (or owned by another account). content: @@ -6302,7 +6408,7 @@ paths: schema: $ref: "#/components/schemas/PurgeMedicationIntakeEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "404": description: Medication not found (or owned by another account). content: @@ -6347,7 +6453,7 @@ paths: schema: $ref: "#/components/schemas/MedicationIntakeImportAcceptedEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "404": description: Medication not found (or owned by another account). content: @@ -6407,6 +6513,7 @@ paths: schema: $ref: "#/components/schemas/BulkMedicationIntakeResponseEnvelope" "401": *a1 + "403": *a9 "409": *a7 "422": *a4 "429": *a5 @@ -6427,7 +6534,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationsResponse" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 post: @@ -6459,7 +6566,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationResponse" "401": *a1 - "403": *a11 + "403": *a12 "409": *a7 "422": *a4 "429": *a5 @@ -6479,7 +6586,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationCategoriesResponse" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 post: @@ -6506,7 +6613,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationCategoryResponse" "401": *a1 - "403": *a11 + "403": *a12 "409": *a7 "422": *a4 "429": *a5 @@ -6539,6 +6646,7 @@ paths: schema: $ref: "#/components/schemas/UpdateMedicationCategoryResponse" "401": *a1 + "403": *a9 "404": description: Not one of the record's own categories. content: @@ -6570,6 +6678,7 @@ paths: schema: $ref: "#/components/schemas/DeleteMedicationCategoryResponse" "401": *a1 + "403": *a9 "404": description: Not one of the record's own categories. content: @@ -6598,6 +6707,7 @@ paths: schema: $ref: "#/components/schemas/MedicationCoursesResponse" "401": *a1 + "403": *a9 "404": description: Medication not found (or another user's). content: @@ -6635,6 +6745,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationCourseResponse" "401": *a1 + "403": *a9 "404": description: Medication not found (or another user's). content: @@ -6676,6 +6787,7 @@ paths: schema: $ref: "#/components/schemas/UpdateMedicationCourseResponse" "401": *a1 + "403": *a9 "404": description: Course not found (`medications.course.notFound`), or another user's. content: @@ -6709,6 +6821,7 @@ paths: schema: $ref: "#/components/schemas/DeleteMedicationCourseResponse" "401": *a1 + "403": *a9 "404": description: Course not found (`medications.course.notFound`), or another user's. content: @@ -6733,7 +6846,7 @@ paths: schema: $ref: "#/components/schemas/MedicationListLayoutResponse" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 put: @@ -6759,6 +6872,7 @@ paths: schema: $ref: "#/components/schemas/MedicationListLayoutSaved" "401": *a1 + "403": *a9 "409": description: Medications layout changed since it was loaded (optimistic-concurrency conflict). No write happened. Re-read the resource, re-apply the user's change against the fresh state, and resend with the new token. @@ -6767,7 +6881,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": &a15 + "422": &a16 description: Request validation failed. When the body carried a `baseUpdatedAt` that could not be parsed as an ISO-8601 timestamp — including an explicit `null` — `meta.errorCode` = `invalid_base_updated_at` and nothing was written. Omit the key entirely for the unconditional write; do not send `null` for it. @@ -6789,6 +6903,7 @@ paths: schema: $ref: "#/components/schemas/MedicationListLayoutReset" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/medications/{id}: @@ -6812,7 +6927,7 @@ paths: schema: $ref: "#/components/schemas/GetMedicationResponse" "401": *a1 - "403": *a11 + "403": *a12 "404": description: Medication not found (or owned by another user). content: @@ -6853,7 +6968,7 @@ paths: schema: $ref: "#/components/schemas/UpdateMedicationResponse" "401": *a1 - "403": *a11 + "403": *a12 "404": description: Medication not found (or owned by another user). content: @@ -6882,6 +6997,7 @@ paths: schema: $ref: "#/components/schemas/DeleteMedicationResponse" "401": *a1 + "403": *a9 "404": description: Medication not found (or owned by another user). content: @@ -6926,7 +7042,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationIntakeResponse" "401": *a1 - "403": *a11 + "403": *a12 "404": description: Medication not found (or owned by another user). content: @@ -6944,7 +7060,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": *a12 + "429": *a13 get: tags: - Medications @@ -7009,7 +7125,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationIntakeEventsResponse" "401": *a1 - "403": *a11 + "403": *a12 "404": description: Medication not found (or owned by another user). content: @@ -7042,7 +7158,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationInventoryResponse" "401": *a1 - "403": *a11 + "403": *a12 "404": description: Medication not found (or owned by another user). content: @@ -7078,7 +7194,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationInventoryItemResponse" "401": *a1 - "403": *a11 + "403": *a12 "404": description: Medication not found (or owned by another user). content: @@ -7122,7 +7238,7 @@ paths: schema: $ref: "#/components/schemas/UpdateMedicationInventoryItemResponse" "401": *a1 - "403": *a11 + "403": *a12 "404": description: Inventory item not found (or owned by another user / medication). content: @@ -7157,7 +7273,7 @@ paths: schema: $ref: "#/components/schemas/DeleteMedicationInventoryItemResponse" "401": *a1 - "403": *a11 + "403": *a12 "404": description: Inventory item not found (or owned by another user / medication). content: @@ -7193,14 +7309,16 @@ paths: $ref: "#/components/schemas/MedicationExtractResponse" "401": *a1 "403": - description: "AI refusal: the `medicationExtract` capability is unavailable for this record. The envelope carries - `meta.errorCode`, `meta.capability` and `meta.reason` (one of `AiUnavailableReason`). - `assistant.disabled.documentAi` — the operator turned off reading documents, or every AI feature (the master - switch). `ai.record.notPermitted` — AI work is not admitted inside this record (a delegate acting in - somebody else's record). `consent.ai.required` — the provider this request would reach leaves the machine - and the record holds no active `ai_extraction` or `ai_full` receipt; only a self-hosted model is exempt. The - question is answered for the provider actually picked, so it can differ from the published capability when a - text-mode read takes a different provider than a vision read would." + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: AI refusal: the + `medicationExtract` capability is unavailable for this record. The envelope carries `meta.errorCode`, + `meta.capability` and `meta.reason` (one of `AiUnavailableReason`). `assistant.disabled.documentAi` — the + operator turned off reading documents, or every AI feature (the master switch). `ai.record.notPermitted` — + AI work is not admitted inside this record (a delegate acting in somebody else's record). + `consent.ai.required` — the provider this request would reach leaves the machine and the record holds no + active `ai_extraction` or `ai_full` receipt; only a self-hosted model is exempt. The question is answered + for the provider actually picked, so it can differ from the published capability when a text-mode read takes + a different provider than a vision read would." content: application/json: schema: @@ -7253,7 +7371,7 @@ paths: schema: $ref: "#/components/schemas/GetMedicationCadenceResponse" "401": *a1 - "403": *a11 + "403": *a12 "404": description: Medication not found (or owned by another user). content: @@ -7302,7 +7420,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationComplianceResponse" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/medications/{id}/compliance: @@ -7329,6 +7447,7 @@ paths: schema: $ref: "#/components/schemas/GetMedicationComplianceResponse" "401": *a1 + "403": *a9 "404": description: Medication not found (or owned by another user). content: @@ -7361,6 +7480,7 @@ paths: schema: $ref: "#/components/schemas/GetMedicationEfficacyResponse" "401": *a1 + "403": *a9 "404": description: Medication not found (or owned by another user). content: @@ -7397,6 +7517,7 @@ paths: schema: $ref: "#/components/schemas/SetMedicationEfficacyTargetResponse" "401": *a1 + "403": *a9 "404": description: Medication or biomarker not found. content: @@ -7453,6 +7574,7 @@ paths: schema: $ref: "#/components/schemas/GetDoseHistoryResponse" "401": *a1 + "403": *a9 "404": description: Medication not found (or owned by another user). content: @@ -7483,7 +7605,7 @@ paths: schema: $ref: "#/components/schemas/ListScheduleRevisionsResponse" "401": *a1 - "403": *a11 + "403": *a12 "404": description: Medication not found (or owned by another user). content: @@ -7521,7 +7643,7 @@ paths: schema: $ref: "#/components/schemas/CreateScheduleRevisionResponse" "401": *a1 - "403": *a11 + "403": *a12 "404": description: Medication not found (or owned by another user). content: @@ -7565,7 +7687,7 @@ paths: schema: $ref: "#/components/schemas/UpdateScheduleRevisionResponse" "401": *a1 - "403": *a11 + "403": *a12 "404": description: Medication or revision not found (or owned by another user). content: @@ -7606,7 +7728,7 @@ paths: schema: $ref: "#/components/schemas/DeleteScheduleRevisionResponse" "401": *a1 - "403": *a11 + "403": *a12 "404": description: Medication or revision not found (or owned by another user). content: @@ -7669,7 +7791,7 @@ paths: schema: $ref: "#/components/schemas/MedicationDoseHistoryImportQueuedEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "409": description: A dose-history import is already in progress. content: @@ -7731,7 +7853,7 @@ paths: schema: $ref: "#/components/schemas/MedicationIntakeImportJobStatusEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "404": description: No such job for this medication (or owned by another user). content: @@ -7765,7 +7887,7 @@ paths: schema: $ref: "#/components/schemas/MedicationIntakeImportJobStatusEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "404": description: No such job for this account (or owned by another user). content: @@ -7795,7 +7917,7 @@ paths: schema: $ref: "#/components/schemas/ProfileSummaryEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/ai/test: @@ -7844,8 +7966,10 @@ paths: $ref: "#/components/schemas/AiTestEnvelope" "401": *a1 "403": - description: The operator's master AI switch is off (`meta.errorCode = "assistant.disabled.enabled"`), or the switches - could not be read (`ai.unavailable`). Nothing was sent. + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: The operator's master AI switch + is off (`meta.errorCode = \"assistant.disabled.enabled\"`), or the switches could not be read + (`ai.unavailable`). Nothing was sent." content: application/json: schema: @@ -7896,6 +8020,7 @@ paths: schema: $ref: "#/components/schemas/GetAiProviderResponse" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 patch: @@ -7939,6 +8064,7 @@ paths: schema: $ref: "#/components/schemas/PatchAiProviderResponse" "401": *a1 + "403": *a9 "413": description: Body exceeds 64 KiB. content: @@ -7978,6 +8104,7 @@ paths: schema: $ref: "#/components/schemas/GetCoachPrefsResponse" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 put: @@ -8000,8 +8127,9 @@ paths: application/json: schema: $ref: "#/components/schemas/PutCoachPrefsResponse" - "400": *a14 + "400": *a15 "401": *a1 + "403": *a9 "409": description: Coach preferences changed since it was loaded (optimistic-concurrency conflict). No write happened. Re-read the resource, re-apply the user's change against the fresh state, and resend with the new token. @@ -8010,7 +8138,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a15 + "422": *a16 "429": *a5 /api/auth/me/disable-coach: get: @@ -8027,6 +8155,7 @@ paths: schema: $ref: "#/components/schemas/GetDisableCoachResponse" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 patch: @@ -8048,8 +8177,9 @@ paths: application/json: schema: $ref: "#/components/schemas/PatchDisableCoachResponse" - "400": *a14 + "400": *a15 "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/auth/me/diabetes: @@ -8069,6 +8199,7 @@ paths: schema: $ref: "#/components/schemas/GetDiabetesResponse" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 patch: @@ -8092,6 +8223,7 @@ paths: schema: $ref: "#/components/schemas/PatchDiabetesResponse" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/auth/me/health-score-config: @@ -8111,6 +8243,7 @@ paths: schema: $ref: "#/components/schemas/HealthScoreConfigEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 patch: @@ -8139,6 +8272,7 @@ paths: schema: $ref: "#/components/schemas/PatchHealthScoreConfigEnvelope" "401": *a1 + "403": *a9 "409": description: Score selection changed since it was loaded (optimistic-concurrency conflict). No write happened. Re-read the resource, re-apply the user's change against the fresh state, and resend with the new token. @@ -8179,6 +8313,7 @@ paths: schema: $ref: "#/components/schemas/GetModulesResponse" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 patch: @@ -8203,8 +8338,9 @@ paths: application/json: schema: $ref: "#/components/schemas/PatchModulesResponse" - "400": *a14 + "400": *a15 "401": *a1 + "403": *a9 "409": description: Module preferences changed since it was loaded (optimistic-concurrency conflict). No write happened. Re-read the resource, re-apply the user's change against the fresh state, and resend with the new token. @@ -8213,7 +8349,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a15 + "422": *a16 "429": *a5 /api/auth/me/source-priority: get: @@ -8230,6 +8366,7 @@ paths: schema: $ref: "#/components/schemas/GetSourcePriorityResponse" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 put: @@ -9164,8 +9301,9 @@ paths: application/json: schema: $ref: "#/components/schemas/PutSourcePriorityResponse" - "400": *a14 + "400": *a15 "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/user/avatar: @@ -9198,6 +9336,7 @@ paths: schema: $ref: "#/components/schemas/UploadAvatarResponse" "401": *a1 + "403": *a9 "413": description: Upload exceeds the 2 MiB byte limit or the 2048×2048 dimension limit. content: @@ -9222,6 +9361,7 @@ paths: "204": description: Avatar cleared (or already empty). "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/user/avatar/{id}: @@ -9258,7 +9398,9 @@ paths: description: Raw WebP bytes. "401": *a1 "403": - description: Caller is not the avatar's owner. + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: Caller is not the avatar's + owner." content: application/json: schema: @@ -9286,6 +9428,7 @@ paths: schema: $ref: "#/components/schemas/GetProfileResponse" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 patch: @@ -9309,6 +9452,7 @@ paths: schema: $ref: "#/components/schemas/PatchProfileResponse" "401": *a1 + "403": *a9 "409": description: The requested email belongs to another account (`meta.errorCode` = `profile.update.emailInUse`). Nothing was written. Shared with PUT /api/auth/profile, which runs the same handler. @@ -9317,7 +9461,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 - "429": *a16 + "429": *a17 /api/auth/me/notification-prefs: get: tags: @@ -9334,6 +9478,7 @@ paths: schema: $ref: "#/components/schemas/GetNotificationPrefsResponse" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 patch: @@ -9359,8 +9504,9 @@ paths: application/json: schema: $ref: "#/components/schemas/PatchNotificationPrefsResponse" - "400": *a14 + "400": *a15 "401": *a1 + "403": *a9 "409": description: Notification preferences changed since it was loaded (optimistic-concurrency conflict). No write happened. Re-read the resource, re-apply the user's change against the fresh state, and resend with the new token. @@ -9369,7 +9515,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a15 + "422": *a16 "429": *a5 /api/auth/me/report-selection: get: @@ -9387,6 +9533,7 @@ paths: schema: $ref: "#/components/schemas/GetReportSelectionResponseEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 put: @@ -9411,8 +9558,9 @@ paths: application/json: schema: $ref: "#/components/schemas/PutReportSelectionResponseEnvelope" - "400": *a14 + "400": *a15 "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/auth/me/unit-preference: @@ -9432,6 +9580,7 @@ paths: schema: $ref: "#/components/schemas/GetUnitPreferenceEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 patch: @@ -9459,6 +9608,7 @@ paths: schema: $ref: "#/components/schemas/PatchUnitPreferenceEnvelope" "401": *a1 + "403": *a9 "413": description: Request body exceeds 1024 bytes. Nothing was written. content: @@ -9484,6 +9634,7 @@ paths: schema: $ref: "#/components/schemas/GetGlucoseUnitEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 patch: @@ -9515,6 +9666,7 @@ paths: schema: $ref: "#/components/schemas/PatchGlucoseUnitEnvelope" "401": *a1 + "403": *a9 "413": description: Request body exceeds 1024 bytes. Nothing was written. content: @@ -9539,6 +9691,7 @@ paths: schema: $ref: "#/components/schemas/GetDocumentsAutoAiReadEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 patch: @@ -9576,6 +9729,7 @@ paths: schema: $ref: "#/components/schemas/PatchDocumentsAutoAiReadEnvelope" "401": *a1 + "403": *a9 "413": description: Request body exceeds 1024 bytes. Nothing was written. content: @@ -9600,6 +9754,7 @@ paths: schema: $ref: "#/components/schemas/GetInjectionSitePrefsEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 patch: @@ -9628,6 +9783,7 @@ paths: schema: $ref: "#/components/schemas/PatchInjectionSitePrefsEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/user/thresholds: @@ -9652,6 +9808,7 @@ paths: schema: $ref: "#/components/schemas/GetThresholdsEnvelope" "401": *a1 + "403": *a9 "404": description: The authenticated user row could not be loaded. In practice unreachable — the session that resolved the caller was validated against that row. @@ -9689,6 +9846,7 @@ paths: schema: $ref: "#/components/schemas/PutThresholdsEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 delete: @@ -9732,6 +9890,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 + "403": *a9 "422": description: '`metric` was omitted and the body did not carry `{ "confirm": "RESET_THRESHOLDS" }`. Nothing was reset.' content: @@ -9760,6 +9919,7 @@ paths: schema: $ref: "#/components/schemas/DeviceListEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/auth/me/devices/{id}: @@ -9790,6 +9950,7 @@ paths: schema: $ref: "#/components/schemas/DevicePatchEnvelope" "401": *a1 + "403": *a9 "404": description: No such device for this account. content: @@ -9828,6 +9989,7 @@ paths: schema: $ref: "#/components/schemas/AccountDeviceRevokeEnvelope" "401": *a1 + "403": *a9 "404": description: No such device for this account. content: @@ -9854,6 +10016,7 @@ paths: schema: $ref: "#/components/schemas/GetLabsLocalOcrEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 patch: @@ -9875,6 +10038,7 @@ paths: schema: $ref: "#/components/schemas/PatchLabsLocalOcrEnvelope" "401": *a1 + "403": *a9 "413": description: Request body exceeds 1024 bytes. Nothing was written. content: @@ -9899,6 +10063,7 @@ paths: schema: $ref: "#/components/schemas/GetWorkoutRoutesEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 patch: @@ -9921,6 +10086,7 @@ paths: schema: $ref: "#/components/schemas/PatchWorkoutRoutesEnvelope" "401": *a1 + "403": *a9 "413": description: Request body exceeds 1024 bytes. Nothing was written. content: @@ -9944,6 +10110,7 @@ paths: schema: $ref: "#/components/schemas/GetUseCentralCodexEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 patch: @@ -9972,6 +10139,7 @@ paths: schema: $ref: "#/components/schemas/PatchUseCentralCodexEnvelope" "401": *a1 + "403": *a9 "413": description: Request body exceeds 1024 bytes. Nothing was written. content: @@ -10017,7 +10185,9 @@ paths: $ref: "#/components/schemas/LocalePutEnvelope" "401": *a1 "403": - description: A selector header was attached to an actor surface (`meta.errorCode` = `sharing.not_permitted`). + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: A selector header was attached to + an actor surface (`meta.errorCode` = `sharing.not_permitted`)." content: application/json: schema: @@ -10053,6 +10223,7 @@ paths: schema: $ref: "#/components/schemas/TimezonePutEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/auth/me/passkey-nudge/dismiss: @@ -10070,6 +10241,7 @@ paths: schema: $ref: "#/components/schemas/PasskeyNudgeDismissEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/insights/chat: @@ -10116,6 +10288,7 @@ paths: schema: $ref: "#/components/schemas/CoachConversationsPageResponse" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 post: @@ -10160,11 +10333,13 @@ paths: $ref: "#/components/schemas/CoachStreamEvent" "401": *a1 "403": - description: "The `coach` AI capability is unavailable for this record. `meta.capability` = `coach` and `meta.reason` - says which layer refused: the operator's switch (`assistant.disabled.coach`, or `assistant.disabled.enabled` - for the master switch), an owning module or the person's own AI opt-out (`module.disabled` with - `meta.module`), a delegate inside somebody else's record (`ai.record.notPermitted`), or a missing consent - receipt (`consent.ai.required`). Nothing was generated, read from a stored model answer, or enqueued." + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: The `coach` AI capability is + unavailable for this record. `meta.capability` = `coach` and `meta.reason` says which layer refused: the + operator's switch (`assistant.disabled.coach`, or `assistant.disabled.enabled` for the master switch), an + owning module or the person's own AI opt-out (`module.disabled` with `meta.module`), a delegate inside + somebody else's record (`ai.record.notPermitted`), or a missing consent receipt (`consent.ai.required`). + Nothing was generated, read from a stored model answer, or enqueued." content: application/json: schema: @@ -10177,7 +10352,7 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "503": &a18 + "503": &a20 description: "The AI capability could not be resolved (its inputs could not be read) and the answer failed closed: `meta.errorCode` = `ai.unavailable`, `meta.reason` = `check_failed`. Retry later; no data depends on it." content: @@ -10217,6 +10392,7 @@ paths: schema: $ref: "#/components/schemas/CoachMessageResultsResponse" "401": *a1 + "403": *a9 "404": description: Conversation or message not found, or not owned by the caller. content: @@ -10260,6 +10436,7 @@ paths: schema: $ref: "#/components/schemas/CoachMessageTrailResponse" "401": *a1 + "403": *a9 "404": description: Conversation or message not found, or not owned by the caller. content: @@ -10292,6 +10469,7 @@ paths: schema: $ref: "#/components/schemas/CoachConversationDetailResponse" "401": *a1 + "403": *a9 "404": description: Conversation not found or not owned by the caller. content: @@ -10331,6 +10509,7 @@ paths: schema: $ref: "#/components/schemas/CoachConversationRenamed" "401": *a1 + "403": *a9 "404": description: Conversation not found or not owned by the caller. content: @@ -10366,6 +10545,7 @@ paths: schema: $ref: "#/components/schemas/CoachConversationDeleted" "401": *a1 + "403": *a9 "404": description: Conversation not found or not owned by the caller. content: @@ -10408,20 +10588,21 @@ paths: description: "SSE frames: `data: \\n\\n`. See the operation description for the per-`type` frame shapes." "401": *a1 "403": - description: "AI refusal: the `documentAi` capability is unavailable for this record. The envelope carries - `meta.errorCode`, `meta.capability` and `meta.reason` (one of `AiUnavailableReason`). - `assistant.disabled.documentAi` — the operator turned off reading documents, or every AI feature (the master - switch). `module.disabled` with `meta.module = \"inboundDocuments\"` — the module is off for this record. - `ai.record.notPermitted` — AI work is not admitted inside this record (a delegate acting in somebody else's - record). `consent.ai.required` — the provider this request would reach leaves the machine and the record - holds no active `ai_extraction` or `ai_full` receipt; only a self-hosted model is exempt. The question is - answered for the provider actually picked, so it can differ from the published capability when a text-mode - read takes a different provider than a vision read would. The route also answers under `coach`, with the - same envelope and that capability's own codes (`assistant.disabled.coach`, `module.disabled` with - `meta.module = \"coach\"` when the Coach is off for the record). A 422 (`stdResponses`) covers an un-indexed - / unavailable attachment (`coach.fenced.attachmentUnavailable`), the attachment cap - (`coach.fenced.attachmentLimit`), or `attachmentIds` sent with a `conversationId` - (`coach.fenced.attachmentConflict`)." + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: AI refusal: the `documentAi` + capability is unavailable for this record. The envelope carries `meta.errorCode`, `meta.capability` and + `meta.reason` (one of `AiUnavailableReason`). `assistant.disabled.documentAi` — the operator turned off + reading documents, or every AI feature (the master switch). `module.disabled` with `meta.module = + \"inboundDocuments\"` — the module is off for this record. `ai.record.notPermitted` — AI work is not + admitted inside this record (a delegate acting in somebody else's record). `consent.ai.required` — the + provider this request would reach leaves the machine and the record holds no active `ai_extraction` or + `ai_full` receipt; only a self-hosted model is exempt. The question is answered for the provider actually + picked, so it can differ from the published capability when a text-mode read takes a different provider than + a vision read would. The route also answers under `coach`, with the same envelope and that capability's own + codes (`assistant.disabled.coach`, `module.disabled` with `meta.module = \"coach\"` when the Coach is off + for the record). A 422 (`stdResponses`) covers an un-indexed / unavailable attachment + (`coach.fenced.attachmentUnavailable`), the attachment cap (`coach.fenced.attachmentLimit`), or + `attachmentIds` sent with a `conversationId` (`coach.fenced.attachmentConflict`)." content: application/json: schema: @@ -10469,17 +10650,19 @@ paths: $ref: "#/components/schemas/CoachAttachmentsResponse" "401": *a1 "403": - description: "AI refusal: the `documentAi` capability is unavailable for this record. The envelope carries - `meta.errorCode`, `meta.capability` and `meta.reason` (one of `AiUnavailableReason`). - `assistant.disabled.documentAi` — the operator turned off reading documents, or every AI feature (the master - switch). `module.disabled` with `meta.module = \"inboundDocuments\"` — the module is off for this record. - `ai.record.notPermitted` — AI work is not admitted inside this record (a delegate acting in somebody else's - record). `consent.ai.required` — the provider this request would reach leaves the machine and the record - holds no active `ai_extraction` or `ai_full` receipt; only a self-hosted model is exempt. The question is - answered for the provider actually picked, so it can differ from the published capability when a text-mode - read takes a different provider than a vision read would. The route also answers under `coach`, with the - same envelope and that capability's own codes (`assistant.disabled.coach`, `module.disabled` with - `meta.module = \"coach\"` when the Coach is off for the record)." + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: AI refusal: the `documentAi` + capability is unavailable for this record. The envelope carries `meta.errorCode`, `meta.capability` and + `meta.reason` (one of `AiUnavailableReason`). `assistant.disabled.documentAi` — the operator turned off + reading documents, or every AI feature (the master switch). `module.disabled` with `meta.module = + \"inboundDocuments\"` — the module is off for this record. `ai.record.notPermitted` — AI work is not + admitted inside this record (a delegate acting in somebody else's record). `consent.ai.required` — the + provider this request would reach leaves the machine and the record holds no active `ai_extraction` or + `ai_full` receipt; only a self-hosted model is exempt. The question is answered for the provider actually + picked, so it can differ from the published capability when a text-mode read takes a different provider than + a vision read would. The route also answers under `coach`, with the same envelope and that capability's own + codes (`assistant.disabled.coach`, `module.disabled` with `meta.module = \"coach\"` when the Coach is off + for the record)." content: application/json: schema: @@ -10529,6 +10712,7 @@ paths: schema: $ref: "#/components/schemas/CoachAttachmentsResponseDetach" "401": *a1 + "403": *a9 "404": description: Attachment or conversation not found / not owned. content: @@ -10553,6 +10737,7 @@ paths: schema: $ref: "#/components/schemas/InsightsLayout" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 put: @@ -10577,6 +10762,7 @@ paths: schema: $ref: "#/components/schemas/InsightsLayoutSaved" "401": *a1 + "403": *a9 "409": description: Insights layout changed since it was loaded (optimistic-concurrency conflict). No write happened. Re-read the resource, re-apply the user's change against the fresh state, and resend with the new token. @@ -10585,7 +10771,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a15 + "422": *a16 "429": *a5 delete: tags: @@ -10600,6 +10786,7 @@ paths: schema: $ref: "#/components/schemas/InsightsLayoutReset" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/insights/coach/facts: @@ -10621,6 +10808,7 @@ paths: schema: $ref: "#/components/schemas/CoachFactsList" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 post: @@ -10653,9 +10841,11 @@ paths: $ref: "#/components/schemas/CoachFactCreated" "401": *a1 "403": - description: 'Module disabled for this account: the user (or operator) has the module turned off. `meta.errorCode` = - `module.disabled` and `meta.module` carries the disabled module key (e.g. "sleep"). Returned even for a - valid Bearer token. Clients hide the whole module surface end-to-end rather than retry.' + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: Module disabled for this account: + the user (or operator) has the module turned off. `meta.errorCode` = `module.disabled` and `meta.module` + carries the disabled module key (e.g. \"sleep\"). Returned even for a valid Bearer token. Clients hide the + whole module surface end-to-end rather than retry." content: application/json: schema: @@ -10682,6 +10872,7 @@ paths: schema: $ref: "#/components/schemas/CoachFactsCleared" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/insights/coach/facts/{id}: @@ -10715,6 +10906,7 @@ paths: schema: $ref: "#/components/schemas/CoachFactUpdated" "401": *a1 + "403": *a9 "404": description: Fact not found or not owned by the caller. content: @@ -10738,6 +10930,7 @@ paths: schema: $ref: "#/components/schemas/CoachFactDeleted" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/insights/coach/nudge-status: @@ -10760,7 +10953,7 @@ paths: schema: $ref: "#/components/schemas/CoachNudgeStatus" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/insights/coach/seen: @@ -10781,6 +10974,7 @@ paths: schema: $ref: "#/components/schemas/CoachSeenResponse" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/coach/about-me: @@ -10803,6 +10997,7 @@ paths: schema: $ref: "#/components/schemas/GetCoachAboutMeResponse" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 put: @@ -10870,6 +11065,7 @@ paths: schema: $ref: "#/components/schemas/PutCoachAboutMeResponse" "401": *a1 + "403": *a9 "409": description: Self-context changed since it was loaded (optimistic-concurrency conflict). No write happened. Re-read the resource, re-apply the user's change against the fresh state, and resend with the new token. @@ -10878,7 +11074,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a15 + "422": *a16 "429": *a5 /api/anamnesis/facts: get: @@ -10897,7 +11093,7 @@ paths: schema: $ref: "#/components/schemas/GetAnamnesisFactsResponse" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 post: @@ -11315,6 +11511,7 @@ paths: schema: $ref: "#/components/schemas/CreateAnamnesisFactResponse" "401": *a1 + "403": *a9 "409": description: A current revision already exists for this fact kind. `meta.errorCode` = `anamnesis.fact.currentExists`. content: @@ -11370,7 +11567,8 @@ paths: schema: $ref: "#/components/schemas/CorrectAnamnesisFactResponse" "401": *a1 - "404": &a17 + "403": *a9 + "404": &a18 description: No current revision with this id exists for the authenticated account. content: application/json: @@ -11406,7 +11604,8 @@ paths: schema: $ref: "#/components/schemas/RemoveAnamnesisFactResponse" "401": *a1 - "404": *a17 + "403": *a9 + "404": *a18 "409": description: Anamnesis fact changed since it was loaded (optimistic-concurrency conflict). No write happened. Re-read the resource, re-apply the user's change against the fresh state, and resend with the new token. @@ -11433,6 +11632,7 @@ paths: schema: $ref: "#/components/schemas/GetEmergencyProfileResponse" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 patch: @@ -11455,6 +11655,7 @@ paths: schema: $ref: "#/components/schemas/UpdateEmergencyProfileResponse" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/coach/about-me/questions: @@ -11473,6 +11674,7 @@ paths: schema: $ref: "#/components/schemas/GetCoachAboutMeQuestionsResponse" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 delete: @@ -11499,6 +11701,7 @@ paths: schema: $ref: "#/components/schemas/DeleteCoachAboutMeQuestionsResponse" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/coach/about-me/adopt: @@ -11548,9 +11751,11 @@ paths: $ref: "#/components/schemas/CoachAboutMeAdoptResponse" "401": *a1 "403": - description: 'Module disabled for this account: the user (or operator) has the module turned off. `meta.errorCode` = - `module.disabled` and `meta.module` carries the disabled module key (e.g. "sleep"). Returned even for a - valid Bearer token. Clients hide the whole module surface end-to-end rather than retry.' + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: Module disabled for this account: + the user (or operator) has the module turned off. `meta.errorCode` = `module.disabled` and `meta.module` + carries the disabled module key (e.g. \"sleep\"). Returned even for a valid Bearer token. Clients hide the + whole module surface end-to-end rather than retry." content: application/json: schema: @@ -11623,6 +11828,7 @@ paths: schema: $ref: "#/components/schemas/CoachPlansList" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/coach/plans/{id}: @@ -11657,7 +11863,8 @@ paths: $ref: "#/components/schemas/CoachPlanUpdated" "401": *a1 "403": - description: Coach surface disabled. + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: Coach surface disabled." content: application/json: schema: @@ -11694,7 +11901,8 @@ paths: $ref: "#/components/schemas/CoachPlanDeleted" "401": *a1 "403": - description: Coach surface disabled. + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: Coach surface disabled." content: application/json: schema: @@ -11733,7 +11941,9 @@ paths: $ref: "#/components/schemas/CoachReminderSuggestionResultCreated" "401": *a1 "403": - description: Coach surface (or the cadence's module) disabled. + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: Coach surface (or the cadence's + module) disabled." content: application/json: schema: @@ -11766,7 +11976,7 @@ paths: schema: $ref: "#/components/schemas/CoachRemindersList" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 post: @@ -11792,7 +12002,8 @@ paths: $ref: "#/components/schemas/CoachReminderCreated" "401": *a1 "403": - description: Coach surface disabled. + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: Coach surface disabled." content: application/json: schema: @@ -11836,7 +12047,8 @@ paths: $ref: "#/components/schemas/CoachReminderUpdated" "401": *a1 "403": - description: Coach surface disabled. + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: Coach surface disabled." content: application/json: schema: @@ -11872,6 +12084,7 @@ paths: schema: $ref: "#/components/schemas/CoachReminderDeleted" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/coach/suggested-actions: @@ -11900,7 +12113,8 @@ paths: $ref: "#/components/schemas/CoachSuggestedActionResultCreated" "401": *a1 "403": - description: Coach surface disabled. + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: Coach surface disabled." content: application/json: schema: @@ -11930,6 +12144,12 @@ paths: schema: $ref: "#/components/schemas/AdminInviteList" "401": *a1 + "403": &a19 + description: The session is valid but its account is not an administrator. `meta.errorCode` = `auth.admin.required`. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 post: @@ -11965,6 +12185,7 @@ paths: schema: $ref: "#/components/schemas/AdminInviteCreated" "401": *a1 + "403": *a19 "422": *a4 "429": *a5 /api/admin/invites/{id}: @@ -11989,6 +12210,7 @@ paths: schema: $ref: "#/components/schemas/AdminInviteRevoked" "401": *a1 + "403": *a19 "422": *a4 "429": *a5 /api/insights/chat/messages/{id}/feedback: @@ -12013,6 +12235,7 @@ paths: schema: $ref: "#/components/schemas/CoachMessageFeedbackResponse" "401": *a1 + "403": *a9 "404": description: Message not found or not owned by the caller. content: @@ -12043,6 +12266,7 @@ paths: schema: $ref: "#/components/schemas/AdminMeasurementMaintenanceStatusResponse" "401": *a1 + "403": *a19 "422": *a4 "429": *a5 post: @@ -12068,7 +12292,8 @@ paths: $ref: "#/components/schemas/AdminMeasurementMaintenanceResponse" "401": *a1 "403": - description: Caller is not an admin. + description: "`meta.errorCode` = `auth.admin.required` when the session's account is not an administrator; the admin + gate answers it before the route's own checks run. Otherwise: Caller is not an admin." content: application/json: schema: @@ -12118,7 +12343,8 @@ paths: $ref: "#/components/schemas/AdminReminderCheckResponse" "401": *a1 "403": - description: Caller is not an admin. + description: "`meta.errorCode` = `auth.admin.required` when the session's account is not an administrator; the admin + gate answers it before the route's own checks run. Otherwise: Caller is not an admin." content: application/json: schema: @@ -12142,7 +12368,8 @@ paths: $ref: "#/components/schemas/AdminDiagnosticResponse" "401": *a1 "403": - description: Caller is not an admin. + description: "`meta.errorCode` = `auth.admin.required` when the session's account is not an administrator; the admin + gate answers it before the route's own checks run. Otherwise: Caller is not an admin." content: application/json: schema: @@ -12169,7 +12396,7 @@ paths: schema: $ref: "#/components/schemas/DashboardSnapshotResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/insights/comprehensive: @@ -12191,7 +12418,7 @@ paths: schema: $ref: "#/components/schemas/InsightsComprehensiveResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/insights/cards: @@ -12212,7 +12439,7 @@ paths: schema: $ref: "#/components/schemas/InsightsCardsEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/insights/pregenerate: @@ -12244,6 +12471,7 @@ paths: schema: $ref: "#/components/schemas/InsightsPregenerateResponseEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/analytics: @@ -12294,6 +12522,7 @@ paths: schema: $ref: "#/components/schemas/AnalyticsEnvelope" "401": *a1 + "403": *a9 "422": description: "`slice` carried a value outside the closed set. `meta.errorCode` = `analytics.invalid_query`, with the offending path under `details.issues`." @@ -12329,12 +12558,13 @@ paths: $ref: "#/components/schemas/InsightsGenerateReadEnvelope" "401": *a1 "403": - description: "The `briefing` AI capability is unavailable for this record. `meta.capability` = `briefing` and - `meta.reason` says which layer refused: the operator's switch (`assistant.disabled.briefing`, or - `assistant.disabled.enabled` for the master switch), an owning module or the person's own AI opt-out - (`module.disabled` with `meta.module`), a delegate inside somebody else's record (`ai.record.notPermitted`), - or a missing consent receipt (`consent.ai.required`). Nothing was generated, read from a stored model - answer, or enqueued." + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: The `briefing` AI capability is + unavailable for this record. `meta.capability` = `briefing` and `meta.reason` says which layer refused: the + operator's switch (`assistant.disabled.briefing`, or `assistant.disabled.enabled` for the master switch), an + owning module or the person's own AI opt-out (`module.disabled` with `meta.module`), a delegate inside + somebody else's record (`ai.record.notPermitted`), or a missing consent receipt (`consent.ai.required`). + Nothing was generated, read from a stored model answer, or enqueued." content: application/json: schema: @@ -12347,7 +12577,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 - "503": *a18 + "503": *a20 post: tags: - Insights @@ -12382,12 +12612,13 @@ paths: $ref: "#/components/schemas/InsightsGeneratePostEnvelope" "401": *a1 "403": - description: "The `briefing` AI capability is unavailable for this record. `meta.capability` = `briefing` and - `meta.reason` says which layer refused: the operator's switch (`assistant.disabled.briefing`, or - `assistant.disabled.enabled` for the master switch), an owning module or the person's own AI opt-out - (`module.disabled` with `meta.module`), a delegate inside somebody else's record (`ai.record.notPermitted`), - or a missing consent receipt (`consent.ai.required`). Nothing was generated, read from a stored model - answer, or enqueued." + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: The `briefing` AI capability is + unavailable for this record. `meta.capability` = `briefing` and `meta.reason` says which layer refused: the + operator's switch (`assistant.disabled.briefing`, or `assistant.disabled.enabled` for the master switch), an + owning module or the person's own AI opt-out (`module.disabled` with `meta.module`), a delegate inside + somebody else's record (`ai.record.notPermitted`), or a missing consent receipt (`consent.ai.required`). + Nothing was generated, read from a stored model answer, or enqueued." content: application/json: schema: @@ -12445,6 +12676,7 @@ paths: schema: $ref: "#/components/schemas/ProviderChainEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 put: @@ -12471,6 +12703,7 @@ paths: schema: $ref: "#/components/schemas/ProviderChainSavedEnvelope" "401": *a1 + "403": *a9 "422": description: The body was not a valid chain — an empty array, more entries than there are provider types, an unknown provider type, or the same type twice. A single-message envelope with no issue list. @@ -12506,6 +12739,7 @@ paths: schema: $ref: "#/components/schemas/InsightsFeedbackCreatedEnvelope" "401": *a1 + "403": *a9 "409": *a7 "422": description: The body failed validation. A single-message envelope (`Invalid feedback payload`) with no issue list — @@ -12549,7 +12783,7 @@ paths: schema: $ref: "#/components/schemas/CoachReadStripEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/insights/narrative: @@ -12598,7 +12832,7 @@ paths: schema: $ref: "#/components/schemas/NarrativeEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/insights/glp1-timeline: @@ -12635,7 +12869,7 @@ paths: schema: $ref: "#/components/schemas/Glp1TimelineEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/insights/pulse/intraday: @@ -12671,7 +12905,7 @@ paths: schema: $ref: "#/components/schemas/IntradayPulseEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/insights/settings: @@ -12690,6 +12924,7 @@ paths: schema: $ref: "#/components/schemas/InsightsSettingsEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 put: @@ -12715,6 +12950,7 @@ paths: schema: $ref: "#/components/schemas/InsightsSettingsUpdatedEnvelope" "401": *a1 + "403": *a9 "422": description: "`Invalid privacy mode` when `privacyMode` was a string outside the two accepted values, or `No changes` when the body carried nothing this endpoint writes. Single-message envelope with no issue list — this route @@ -12745,7 +12981,7 @@ paths: schema: $ref: "#/components/schemas/TargetsEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/analytics/range: @@ -12787,6 +13023,7 @@ paths: schema: $ref: "#/components/schemas/AnalyticsRangeResponseEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/insights/blood-pressure-status: @@ -12818,7 +13055,7 @@ paths: schema: $ref: "#/components/schemas/BloodPressureStatusResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/insights/pulse-status: @@ -12850,7 +13087,7 @@ paths: schema: $ref: "#/components/schemas/PulseStatusResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/insights/weight-status: @@ -12882,7 +13119,7 @@ paths: schema: $ref: "#/components/schemas/WeightStatusResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/insights/bmi-status: @@ -12914,7 +13151,7 @@ paths: schema: $ref: "#/components/schemas/BmiStatusResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/insights/mood-status: @@ -12946,7 +13183,7 @@ paths: schema: $ref: "#/components/schemas/MoodStatusResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/insights/medication-compliance-status: @@ -12979,7 +13216,7 @@ paths: schema: $ref: "#/components/schemas/MedicationComplianceStatusResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/insights/metric-status: @@ -13074,7 +13311,7 @@ paths: schema: $ref: "#/components/schemas/MetricStatusResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/insights/biomarker-assessment: @@ -13119,7 +13356,7 @@ paths: schema: $ref: "#/components/schemas/BiomarkerAssessmentResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/insights/derived: @@ -13231,7 +13468,7 @@ paths: schema: $ref: "#/components/schemas/DerivedMetricResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/insights/derived/batch: @@ -13271,7 +13508,7 @@ paths: schema: $ref: "#/components/schemas/DerivedBatchResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/insights/score-history: @@ -13321,7 +13558,7 @@ paths: schema: $ref: "#/components/schemas/ScoreHistoryResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/insights/glp1-plateau: @@ -13341,7 +13578,7 @@ paths: schema: $ref: "#/components/schemas/InsightsGlp1PlateauResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/insights/ecg: @@ -13363,7 +13600,7 @@ paths: schema: $ref: "#/components/schemas/EcgListResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 post: @@ -13401,6 +13638,7 @@ paths: schema: $ref: "#/components/schemas/EcgIngestCreatedEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/insights/ecg/{id}: @@ -13440,7 +13678,7 @@ paths: schema: $ref: "#/components/schemas/EcgDetailResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "404": description: No ECG recording with that id for the authenticated user (existence sealed — a foreign id is indistinguishable from a missing one). @@ -13472,7 +13710,7 @@ paths: schema: $ref: "#/components/schemas/RhythmEventsResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/insights/correlations: @@ -13494,7 +13732,7 @@ paths: schema: $ref: "#/components/schemas/CorrelationDiscoveryResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/insights/patterns: @@ -13512,7 +13750,7 @@ paths: schema: $ref: "#/components/schemas/CorrelationPatternListResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/insights/patterns/{id}: @@ -13542,7 +13780,7 @@ paths: schema: $ref: "#/components/schemas/UpdateCorrelationPatternResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "404": description: No current pattern with that id for this account. content: @@ -13567,7 +13805,7 @@ paths: schema: $ref: "#/components/schemas/InsightsHealthStatusEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/insights/breathing-screening: @@ -13586,7 +13824,7 @@ paths: schema: $ref: "#/components/schemas/InsightsBreathingScreeningEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/insights/labs-changes: @@ -13605,7 +13843,7 @@ paths: schema: $ref: "#/components/schemas/InsightsLabsChangesEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/mood/analytics: @@ -13628,7 +13866,7 @@ paths: schema: $ref: "#/components/schemas/MoodDailySeriesEnvelope" "401": *a1 - "403": *a19 + "403": *a21 "422": *a4 "429": *a5 /api/mood/insights: @@ -13663,7 +13901,7 @@ paths: schema: $ref: "#/components/schemas/MoodAggregatesEnvelope" "401": *a1 - "403": *a19 + "403": *a21 "422": *a4 "429": *a5 /api/mood/prognosis: @@ -13686,7 +13924,7 @@ paths: schema: $ref: "#/components/schemas/MoodPrognosisEnvelope" "401": *a1 - "403": *a13 + "403": *a14 "422": *a4 "429": *a5 /api/mood-entries/bulk: @@ -13715,6 +13953,7 @@ paths: schema: $ref: "#/components/schemas/BulkMoodResponseEnvelope" "401": *a1 + "403": *a9 "409": *a7 "422": *a4 "429": *a5 @@ -13743,6 +13982,7 @@ paths: schema: $ref: "#/components/schemas/MoodTagTreeEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/mood/tags/custom: @@ -13767,6 +14007,7 @@ paths: schema: $ref: "#/components/schemas/MoodCustomTagCreatedEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/mood/tags/custom/{key}: @@ -13796,7 +14037,8 @@ paths: schema: $ref: "#/components/schemas/MoodCustomTagPatchEnvelope" "401": *a1 - "404": &a20 + "403": *a9 + "404": &a22 description: Not found / not owned by the caller. content: application/json: @@ -13831,7 +14073,8 @@ paths: schema: $ref: "#/components/schemas/MoodCustomTagDeleteEnvelope" "401": *a1 - "404": *a20 + "403": *a9 + "404": *a22 "422": *a4 "429": *a5 /api/mood/tags/{key}/hidden: @@ -13867,7 +14110,8 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "404": *a20 + "403": *a9 + "404": *a22 "422": *a4 "429": *a5 /api/mood/tags/groups: @@ -13891,6 +14135,7 @@ paths: schema: $ref: "#/components/schemas/MoodTagGroupCreatedEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/mood/tags/groups/{key}: @@ -13920,7 +14165,8 @@ paths: schema: $ref: "#/components/schemas/MoodTagGroupPatchEnvelope" "401": *a1 - "404": *a20 + "403": *a9 + "404": *a22 "422": *a4 "429": *a5 delete: @@ -13951,7 +14197,8 @@ paths: schema: $ref: "#/components/schemas/MoodTagGroupDeleteEnvelope" "401": *a1 - "404": *a20 + "403": *a9 + "404": *a22 "422": *a4 "429": *a5 /api/mood/tags/layout: @@ -13969,6 +14216,7 @@ paths: schema: $ref: "#/components/schemas/MoodTagLayoutGetEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 put: @@ -13991,6 +14239,7 @@ paths: schema: $ref: "#/components/schemas/MoodTagLayoutPutEnvelope" "401": *a1 + "403": *a9 "409": description: Mood-tag layout changed since it was loaded (optimistic-concurrency conflict). No write happened. Re-read the resource, re-apply the user's change against the fresh state, and resend with the new token. @@ -13999,7 +14248,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a15 + "422": *a16 "429": *a5 /api/mood-entries: get: @@ -14083,7 +14332,7 @@ paths: schema: $ref: "#/components/schemas/MoodEntryListEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 post: @@ -14116,8 +14365,8 @@ paths: schema: $ref: "#/components/schemas/MoodEntryEnvelope" "401": *a1 - "403": *a11 - "409": &a21 + "403": *a12 + "409": &a23 description: A mood entry with the same `(date, moodLoggedAt)` instant already exists. `meta.errorCode` = `mood.duplicate_timestamp`. Only a request without `externalId` can answer this — with one, the write upserts in place. @@ -14126,7 +14375,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 - "429": *a12 + "429": *a13 /api/mood-entries/{id}: get: tags: @@ -14148,8 +14397,8 @@ paths: schema: $ref: "#/components/schemas/MoodEntryEnvelope" "401": *a1 - "403": *a11 - "404": *a20 + "403": *a12 + "404": *a22 "422": *a4 "429": *a5 put: @@ -14183,9 +14432,9 @@ paths: schema: $ref: "#/components/schemas/MoodEntryEnvelope" "401": *a1 - "403": *a11 - "404": *a20 - "409": *a21 + "403": *a12 + "404": *a22 + "409": *a23 "422": *a4 "429": *a5 delete: @@ -14210,8 +14459,8 @@ paths: schema: $ref: "#/components/schemas/MoodEntryDeleteEnvelope" "401": *a1 - "403": *a11 - "404": *a20 + "403": *a12 + "404": *a22 "422": *a4 "429": *a5 /api/mood-entries/restore: @@ -14240,7 +14489,7 @@ paths: schema: $ref: "#/components/schemas/MoodEntriesRestoreEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "409": *a7 "422": *a4 "429": *a5 @@ -14269,7 +14518,7 @@ paths: schema: $ref: "#/components/schemas/MoodEntriesBulkDeleteEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "409": *a7 "422": *a4 "429": *a5 @@ -14313,7 +14562,7 @@ paths: schema: $ref: "#/components/schemas/MoodLinkedContextEnvelope" "401": *a1 - "403": *a13 + "403": *a14 "422": *a4 "429": *a5 /api/settings/ntfy: @@ -14332,6 +14581,7 @@ paths: schema: $ref: "#/components/schemas/GetNtfySettings" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 put: @@ -14361,7 +14611,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PutNtfySettingsResponse" - "400": &a22 + "400": &a24 description: Body is not parseable JSON. content: application/json: @@ -14369,18 +14619,20 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 "403": - description: The operator switched ntfy off for this instance and the body asked to enable it. Nothing was stored. + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: The operator switched ntfy off + for this instance and the body asked to enable it. Nothing was stored." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "413": &a23 + "413": &a25 description: Body exceeds 64 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "415": &a24 + "415": &a26 description: Content-Type is not `application/json`. content: application/json: @@ -14411,6 +14663,7 @@ paths: schema: $ref: "#/components/schemas/GetTelegramSettings" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 put: @@ -14440,11 +14693,12 @@ paths: application/json: schema: $ref: "#/components/schemas/PutTelegramSettingsResponse" - "400": *a22 + "400": *a24 "401": *a1 "403": - description: The operator switched Telegram off for this instance and the body asked to enable it. No webhook was - registered and nothing was stored. + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: The operator switched Telegram + off for this instance and the body asked to enable it. No webhook was registered and nothing was stored." content: application/json: schema: @@ -14455,8 +14709,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "413": *a23 - "415": *a24 + "413": *a25 + "415": *a26 "422": description: Body matched neither accepted shape; or `enabled` is true without both a bot token and a chat id; or the deployment's `APP_URL` is not a public HTTPS host on port 80/88/443/8443; or Telegram refused the webhook @@ -14489,6 +14743,7 @@ paths: schema: $ref: "#/components/schemas/GetWebhookSettings" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 put: @@ -14510,10 +14765,11 @@ paths: application/json: schema: $ref: "#/components/schemas/PutWebhookSettingsResponse" - "400": *a22 + "400": *a24 "401": *a1 - "413": *a23 - "415": *a24 + "403": *a9 + "413": *a25 + "415": *a26 "422": description: Body matched neither accepted shape, or `enabled` is true while no webhook URL is stored; or the URL is on a private network the operator has not listed in `NOTIFICATION_PRIVATE_ORIGINS` (`meta.errorCode` = @@ -14540,7 +14796,7 @@ paths: schema: $ref: "#/components/schemas/GetReminderThresholdsResponse" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/settings/global-services: @@ -14564,6 +14820,7 @@ paths: schema: $ref: "#/components/schemas/GlobalServiceAvailabilityEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/settings/account: @@ -14628,8 +14885,10 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "403": description: >- - The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal - happens before any second-factor proof is looked at. + `meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: The Bearer token carries a narrow + scope. Erasure needs a cookie session or a full-access token; the refusal happens before any second-factor + proof is looked at. Refused: the request was made while the session is acting on another account (`meta.errorCode` = @@ -14677,6 +14936,7 @@ paths: schema: $ref: "#/components/schemas/GetEmailChannelEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 put: @@ -14711,6 +14971,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/settings/email/test: @@ -14734,6 +14995,7 @@ paths: schema: $ref: "#/components/schemas/EmailTestEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 "500": @@ -14771,6 +15033,7 @@ paths: schema: $ref: "#/components/schemas/NtfyTestEnvelope" "401": *a1 + "403": *a9 "422": description: The saved server is on a private network the operator has not approved (`meta.errorCode` = `private_origin_not_approved`), or is a link-local, metadata or unspecified address that no grant can open @@ -14820,6 +15083,7 @@ paths: schema: $ref: "#/components/schemas/TelegramTestEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/settings/webhook/test: @@ -14842,6 +15106,7 @@ paths: schema: $ref: "#/components/schemas/WebhookTestEnvelope" "401": *a1 + "403": *a9 "422": description: The saved URL is on a private network the operator has not approved (`meta.errorCode` = `private_origin_not_approved`), or is a link-local, metadata or unspecified address that no grant can open @@ -14933,8 +15198,10 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "403": description: >- - The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal - happens before any second-factor proof is looked at. + `meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: The Bearer token carries a narrow + scope. Erasure needs a cookie session or a full-access token; the refusal happens before any second-factor + proof is looked at. Refused: the request was made while the session is acting on another account (`meta.errorCode` = @@ -14976,6 +15243,7 @@ paths: schema: $ref: "#/components/schemas/ModuleListLayoutEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 put: @@ -14998,6 +15266,7 @@ paths: schema: $ref: "#/components/schemas/ModuleListLayoutSavedEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/settings/preview-features: @@ -15014,6 +15283,7 @@ paths: schema: $ref: "#/components/schemas/PreviewFeaturesEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 patch: @@ -15035,6 +15305,7 @@ paths: schema: $ref: "#/components/schemas/PreviewFeaturesSavedEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/settings/privacy-summary: @@ -15053,6 +15324,7 @@ paths: schema: $ref: "#/components/schemas/PrivacySummaryEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/consent/ai: @@ -15091,6 +15363,7 @@ paths: schema: $ref: "#/components/schemas/ConsentValidationErrorEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/consent/ai/web: @@ -15123,6 +15396,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/consent/ai/latest: @@ -15160,7 +15434,7 @@ paths: anyOf: - $ref: "#/components/schemas/ConsentLatestForKindEnvelope" - $ref: "#/components/schemas/ConsentLatestByKindEnvelope" - "400": &a25 + "400": &a27 description: "`kind` was present but is not one of `ai_full`, `ai_insights_only`, `ai_coach`, `ai_extraction`. Multi-issue envelope; nothing was read or revoked. The consent family answers 400 here where the rest of the API answers 422." @@ -15169,6 +15443,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 delete: @@ -15220,8 +15495,9 @@ paths: anyOf: - $ref: "#/components/schemas/ConsentRevokeForKindEnvelope" - $ref: "#/components/schemas/ConsentRevokeAllEnvelope" - "400": *a25 + "400": *a27 "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/import/health-connect-export: @@ -15260,6 +15536,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 + "403": *a9 "409": description: An import for this account (Health Connect or Apple Health) is already queued or running. The upload was discarded. `errorCode` is `import.apple_health.busy` for either kind, `jobId` names the running job. @@ -15302,6 +15579,7 @@ paths: schema: $ref: "#/components/schemas/HealthConnectImportStatusEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/import/csv: @@ -15354,6 +15632,7 @@ paths: schema: $ref: "#/components/schemas/CsvImportResponseEnvelope" "401": *a1 + "403": *a9 "413": description: CSV exceeds the 16 MB limit. content: @@ -15549,7 +15828,9 @@ paths: $ref: "#/components/schemas/LabsCsvImportEnvelope" "401": *a1 "403": - description: "`module.disabled`: the module the import writes to is switched off." + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: `module.disabled`: the module the + import writes to is switched off." content: application/json: schema: @@ -15739,7 +16020,9 @@ paths: $ref: "#/components/schemas/MedicationsCsvImportEnvelope" "401": *a1 "403": - description: "`module.disabled`: the module the import writes to is switched off." + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: `module.disabled`: the module the + import writes to is switched off." content: application/json: schema: @@ -15787,6 +16070,7 @@ paths: schema: $ref: "#/components/schemas/JsonImportStatsEnvelope" "401": *a1 + "403": *a9 "413": description: Body exceeds the 16 MB limit. content: @@ -15856,6 +16140,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 + "403": *a9 "409": description: An import for this account is already queued or running, and these bytes are not the ones it carries (the same bytes resolve to that job as a 202). `meta.errorCode` is `import.apple_health.busy` and `meta.jobId` @@ -15924,6 +16209,7 @@ paths: schema: $ref: "#/components/schemas/AppleHealthImportStatusEnvelope" "401": *a1 + "403": *a9 "404": description: No such job, or one the caller may not see. The two are indistinguishable on purpose. content: @@ -15952,7 +16238,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderListEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 post: @@ -15977,7 +16263,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderCreatedEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "409": *a7 "422": *a4 "429": *a5 @@ -16001,8 +16287,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderEnvelope" "401": *a1 - "403": *a11 - "404": &a26 + "403": *a12 + "404": &a28 description: Measurement reminder not found / not owned. content: application/json: @@ -16039,8 +16325,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderPatchEnvelope" "401": *a1 - "403": *a11 - "404": *a26 + "403": *a12 + "404": *a28 "422": *a4 "429": *a5 delete: @@ -16062,8 +16348,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderDeleteEnvelope" "401": *a1 - "403": *a11 - "404": *a26 + "403": *a12 + "404": *a28 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/satisfy: @@ -16087,8 +16373,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderSatisfyEnvelope" "401": *a1 - "403": *a11 - "404": *a26 + "403": *a12 + "404": *a28 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/complete: @@ -16115,8 +16401,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderCompleteEnvelope" "401": *a1 - "403": *a11 - "404": *a26 + "403": *a12 + "404": *a28 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/skip: @@ -16145,8 +16431,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderSkipEnvelope" "401": *a1 - "403": *a11 - "404": *a26 + "403": *a12 + "404": *a28 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/snooze: @@ -16179,8 +16465,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderSnoozeEnvelope" "401": *a1 - "403": *a11 - "404": *a26 + "403": *a12 + "404": *a28 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/history: @@ -16220,8 +16506,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderHistoryEnvelope" "401": *a1 - "403": *a11 - "404": *a26 + "403": *a12 + "404": *a28 "422": *a4 "429": *a5 /api/labs: @@ -16299,7 +16585,7 @@ paths: schema: $ref: "#/components/schemas/ListLabResultsEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 post: @@ -16326,7 +16612,7 @@ paths: schema: $ref: "#/components/schemas/CreateLabResultResponse" "401": *a1 - "403": *a11 + "403": *a12 "409": *a7 "422": description: "Nothing was written. `meta.errorCode` = `labs.create.invalid` when the body failed validation (every issue @@ -16341,7 +16627,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": *a12 + "429": *a13 /api/labs/overview: get: tags: @@ -16359,7 +16645,7 @@ paths: schema: $ref: "#/components/schemas/LabOverviewEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/labs/{id}: @@ -16382,8 +16668,8 @@ paths: schema: $ref: "#/components/schemas/GetLabResultResponse" "401": *a1 - "403": *a11 - "404": &a27 + "403": *a12 + "404": &a29 description: Lab result not found (or owned by another user). content: application/json: @@ -16417,8 +16703,8 @@ paths: schema: $ref: "#/components/schemas/UpdateLabResultResponse" "401": *a1 - "403": *a11 - "404": *a27 + "403": *a12 + "404": *a29 "422": *a4 "429": *a5 delete: @@ -16440,8 +16726,8 @@ paths: schema: $ref: "#/components/schemas/DeleteLabResultResponse" "401": *a1 - "403": *a11 - "404": *a27 + "403": *a12 + "404": *a29 "422": *a4 "429": *a5 /api/labs/restore: @@ -16467,7 +16753,7 @@ paths: schema: $ref: "#/components/schemas/RestoreLabResultsResponse" "401": *a1 - "403": *a11 + "403": *a12 "409": *a7 "422": *a4 "429": *a5 @@ -16486,6 +16772,7 @@ paths: schema: $ref: "#/components/schemas/OcrCapabilityEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/labs/ocr/extract: @@ -16528,7 +16815,7 @@ paths: schema: $ref: "#/components/schemas/OcrTextExtractRequest" responses: - "202": &a37 + "202": &a39 description: Queued for the background worker. Every refusal the route can answer quickly (capability, provider, rate limit, budget, validation) has already been answered; anything that goes wrong from here is reported on the run. @@ -16552,15 +16839,16 @@ paths: $ref: "#/components/schemas/AiRunAcceptedEnvelope" "401": *a1 "403": - description: "AI refusal: the `labsOcr` capability is unavailable for this record. The envelope carries - `meta.errorCode`, `meta.capability` and `meta.reason` (one of `AiUnavailableReason`). - `assistant.disabled.documentAi` — the operator turned off reading documents, or every AI feature (the master - switch). `module.disabled` with `meta.module = \"labs\"` — the module is off for this record. - `ai.record.notPermitted` — AI work is not admitted inside this record (a delegate acting in somebody else's - record). `consent.ai.required` — the provider this request would reach leaves the machine and the record - holds no active `ai_extraction` or `ai_full` receipt; only a self-hosted model is exempt. The question is - answered for the provider actually picked, so it can differ from the published capability when a text-mode - read takes a different provider than a vision read would." + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: AI refusal: the `labsOcr` + capability is unavailable for this record. The envelope carries `meta.errorCode`, `meta.capability` and + `meta.reason` (one of `AiUnavailableReason`). `assistant.disabled.documentAi` — the operator turned off + reading documents, or every AI feature (the master switch). `module.disabled` with `meta.module = \"labs\"` + — the module is off for this record. `ai.record.notPermitted` — AI work is not admitted inside this record + (a delegate acting in somebody else's record). `consent.ai.required` — the provider this request would reach + leaves the machine and the record holds no active `ai_extraction` or `ai_full` receipt; only a self-hosted + model is exempt. The question is answered for the provider actually picked, so it can differ from the + published capability when a text-mode read takes a different provider than a vision read would." content: application/json: schema: @@ -16599,6 +16887,7 @@ paths: schema: $ref: "#/components/schemas/OcrCommitEnvelope" "401": *a1 + "403": *a9 "409": *a7 "422": *a4 "429": *a5 @@ -16626,6 +16915,7 @@ paths: schema: $ref: "#/components/schemas/AiRunEnvelope" "401": *a1 + "403": *a9 "404": description: "`aiRuns.notFound`." content: @@ -16649,7 +16939,7 @@ paths: schema: $ref: "#/components/schemas/ListBiomarkersEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 post: @@ -16674,7 +16964,7 @@ paths: schema: $ref: "#/components/schemas/CreateBiomarkerResponse" "401": *a1 - "403": *a11 + "403": *a12 "409": description: A biomarker with this name already exists for the caller. content: @@ -16682,7 +16972,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 - "429": *a12 + "429": *a13 /api/biomarkers/{id}: get: tags: @@ -16705,8 +16995,8 @@ paths: schema: $ref: "#/components/schemas/GetBiomarkerResponse" "401": *a1 - "403": *a11 - "404": &a28 + "403": *a12 + "404": &a30 description: "Biomarker not found (or owned by another user). `meta.errorCode` = `labs.biomarker.notFound`. Since v1.43 a marker that was merged into another answers with `meta.mergedInto` naming the marker it went into: drop the cached marker and open that one." @@ -16747,8 +17037,8 @@ paths: schema: $ref: "#/components/schemas/UpdateBiomarkerResponse" "401": *a1 - "403": *a11 - "404": *a28 + "403": *a12 + "404": *a30 "409": description: "`labs.biomarker.duplicate`: a rename collides with another of the caller's markers. `labs.biomarker.alias.taken` (v1.43): one of `aliases` is already the name or an alias of another marker; @@ -16781,8 +17071,8 @@ paths: schema: $ref: "#/components/schemas/DeleteBiomarkerResponse" "401": *a1 - "403": *a11 - "404": *a28 + "403": *a12 + "404": *a30 "422": *a4 "429": *a5 /api/biomarkers/{id}/merge: @@ -16819,8 +17109,8 @@ paths: schema: $ref: "#/components/schemas/MergeBiomarkersResponse" "401": *a1 - "403": *a11 - "404": *a28 + "403": *a12 + "404": *a30 "409": *a7 "422": description: "`labs.biomarker.merge.invalid`: a malformed body, or the target among the sources (Zod issues in @@ -16831,7 +17121,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": *a12 + "429": *a13 /api/encounters: get: tags: @@ -16894,7 +17184,7 @@ paths: schema: $ref: "#/components/schemas/ListEncountersEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 post: @@ -16920,10 +17210,10 @@ paths: schema: $ref: "#/components/schemas/CreateEncounterEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "409": *a7 "422": *a4 - "429": *a12 + "429": *a13 /api/encounters/procedures: get: tags: @@ -16954,7 +17244,7 @@ paths: schema: $ref: "#/components/schemas/ListProceduresEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/body-sites: @@ -16991,7 +17281,7 @@ paths: schema: $ref: "#/components/schemas/ListBodySitesEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/encounters/suggest: @@ -17021,7 +17311,7 @@ paths: schema: $ref: "#/components/schemas/SuggestEncounterEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/encounters/{id}: @@ -17044,8 +17334,8 @@ paths: schema: $ref: "#/components/schemas/GetEncounterEnvelope" "401": *a1 - "403": *a11 - "404": &a29 + "403": *a12 + "404": &a31 description: Visit not found (or owned by another user). content: application/json: @@ -17078,8 +17368,8 @@ paths: schema: $ref: "#/components/schemas/UpdateEncounterEnvelope" "401": *a1 - "403": *a11 - "404": *a29 + "403": *a12 + "404": *a31 "422": *a4 "429": *a5 delete: @@ -17103,8 +17393,8 @@ paths: schema: $ref: "#/components/schemas/DeleteEncounterEnvelope" "401": *a1 - "403": *a11 - "404": *a29 + "403": *a12 + "404": *a31 "422": *a4 "429": *a5 /api/encounters/{id}/restore: @@ -17129,8 +17419,8 @@ paths: schema: $ref: "#/components/schemas/RestoreEncounterEnvelope" "401": *a1 - "403": *a11 - "404": *a29 + "403": *a12 + "404": *a31 "422": *a4 "429": *a5 /api/encounters/{id}/links: @@ -17161,7 +17451,8 @@ paths: schema: $ref: "#/components/schemas/LinkEncounterEnvelope" "401": *a1 - "404": *a29 + "403": *a9 + "404": *a31 "422": *a4 "429": *a5 delete: @@ -17190,7 +17481,8 @@ paths: schema: $ref: "#/components/schemas/UnlinkEncounterEnvelope" "401": *a1 - "404": *a29 + "403": *a9 + "404": *a31 "422": *a4 "429": *a5 /api/practitioners: @@ -17230,7 +17522,7 @@ paths: schema: $ref: "#/components/schemas/ListPractitionersEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 post: @@ -17254,7 +17546,7 @@ paths: schema: $ref: "#/components/schemas/CreatePractitionerEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "409": *a7 "422": *a4 "429": *a5 @@ -17278,8 +17570,8 @@ paths: schema: $ref: "#/components/schemas/GetPractitionerEnvelope" "401": *a1 - "403": *a11 - "404": &a30 + "403": *a12 + "404": &a32 description: Practitioner not found (or owned by another user). content: application/json: @@ -17312,8 +17604,8 @@ paths: schema: $ref: "#/components/schemas/UpdatePractitionerEnvelope" "401": *a1 - "403": *a11 - "404": *a30 + "403": *a12 + "404": *a32 "422": *a4 "429": *a5 delete: @@ -17337,8 +17629,8 @@ paths: schema: $ref: "#/components/schemas/DeletePractitionerEnvelope" "401": *a1 - "403": *a11 - "404": *a30 + "403": *a12 + "404": *a32 "422": *a4 "429": *a5 /api/practitioners/{id}/restore: @@ -17363,8 +17655,8 @@ paths: schema: $ref: "#/components/schemas/RestorePractitionerEnvelope" "401": *a1 - "403": *a11 - "404": *a30 + "403": *a12 + "404": *a32 "422": *a4 "429": *a5 /api/vaccinations/custom: @@ -17382,7 +17674,7 @@ paths: schema: $ref: "#/components/schemas/ListCustomVaccinesEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 post: @@ -17407,8 +17699,8 @@ paths: schema: $ref: "#/components/schemas/CreateCustomVaccineEnvelope" "401": *a1 - "403": *a11 - "409": &a31 + "403": *a12 + "409": &a33 description: A live definition on the record already carries this name, compared without case (`vaccination.custom.name-taken`). content: @@ -17416,7 +17708,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 - "429": *a12 + "429": *a13 /api/vaccinations/custom/{id}: patch: tags: @@ -17444,14 +17736,14 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomVaccineEnvelope" "401": *a1 - "403": *a11 - "404": &a32 + "403": *a12 + "404": &a34 description: No live definition with this id on the record (`vaccination.custom.not-found`). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "409": *a31 + "409": *a33 "422": *a4 "429": *a5 delete: @@ -17475,8 +17767,8 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomVaccineEnvelope" "401": *a1 - "403": *a11 - "404": *a32 + "403": *a12 + "404": *a34 "422": *a4 "429": *a5 /api/vaccinations: @@ -17529,7 +17821,7 @@ paths: schema: $ref: "#/components/schemas/ListVaccinationsEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 post: @@ -17556,7 +17848,7 @@ paths: schema: $ref: "#/components/schemas/CreateVaccinationEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "404": description: A practice, visit or own vaccine the body names is not the record's (`vaccination.practitioner-not-found`, `vaccination.encounter-not-found`, `vaccination.custom-vaccine-not-found`). @@ -17566,7 +17858,7 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "409": *a7 "422": *a4 - "429": *a12 + "429": *a13 /api/vaccinations/{id}: get: tags: @@ -17587,8 +17879,8 @@ paths: schema: $ref: "#/components/schemas/GetVaccinationEnvelope" "401": *a1 - "403": *a11 - "404": &a33 + "403": *a12 + "404": &a35 description: Vaccination not found (or owned by another user). content: application/json: @@ -17622,8 +17914,8 @@ paths: schema: $ref: "#/components/schemas/UpdateVaccinationEnvelope" "401": *a1 - "403": *a11 - "404": *a33 + "403": *a12 + "404": *a35 "422": *a4 "429": *a5 delete: @@ -17647,8 +17939,8 @@ paths: schema: $ref: "#/components/schemas/DeleteVaccinationEnvelope" "401": *a1 - "403": *a11 - "404": *a33 + "403": *a12 + "404": *a35 "422": *a4 "429": *a5 /api/vaccinations/{id}/restore: @@ -17672,8 +17964,8 @@ paths: schema: $ref: "#/components/schemas/RestoreVaccinationEnvelope" "401": *a1 - "403": *a11 - "404": *a33 + "403": *a12 + "404": *a35 "422": *a4 "429": *a5 /api/vaccinations/{id}/booster: @@ -17722,8 +18014,10 @@ paths: "401": *a1 "403": description: >- - Refused: the caller's grant does not open this record's measurements, and the booster reminder lives there - (`meta.errorCode` = `vaccination.booster-out-of-scope`). Nothing was written. + `meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: Refused: the caller's grant does + not open this record's measurements, and the booster reminder lives there (`meta.errorCode` = + `vaccination.booster-out-of-scope`). Nothing was written. Refused: this request named a record the caller may not act on (`meta.errorCode` = `sharing.access.denied`). @@ -17739,7 +18033,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "404": *a33 + "404": *a35 "422": *a4 "429": *a5 /api/vaccinations/suggest: @@ -17767,7 +18061,7 @@ paths: schema: $ref: "#/components/schemas/VaccinationSuggestEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/vaccinations/{id}/links: @@ -17799,7 +18093,8 @@ paths: schema: $ref: "#/components/schemas/LinkVaccinationEnvelope" "401": *a1 - "404": *a33 + "403": *a9 + "404": *a35 "422": *a4 "429": *a5 delete: @@ -17828,7 +18123,8 @@ paths: schema: $ref: "#/components/schemas/UnlinkVaccinationEnvelope" "401": *a1 - "404": *a33 + "403": *a9 + "404": *a35 "422": *a4 "429": *a5 /api/illness/episodes: @@ -17892,7 +18188,7 @@ paths: schema: $ref: "#/components/schemas/ListIllnessEpisodesEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 post: @@ -17918,7 +18214,7 @@ paths: schema: $ref: "#/components/schemas/CreateIllnessEpisodeEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "409": *a7 "422": *a4 "429": *a5 @@ -17942,8 +18238,8 @@ paths: schema: $ref: "#/components/schemas/GetIllnessEpisodeEnvelope" "401": *a1 - "403": *a11 - "404": &a34 + "403": *a12 + "404": &a36 description: Illness episode not found (or owned by another user). content: application/json: @@ -17977,8 +18273,8 @@ paths: schema: $ref: "#/components/schemas/UpdateIllnessEpisodeEnvelope" "401": *a1 - "403": *a11 - "404": *a34 + "403": *a12 + "404": *a36 "422": *a4 "429": *a5 delete: @@ -18001,8 +18297,8 @@ paths: schema: $ref: "#/components/schemas/DeleteIllnessEpisodeEnvelope" "401": *a1 - "403": *a11 - "404": *a34 + "403": *a12 + "404": *a36 "422": *a4 "429": *a5 /api/illness/episodes/{id}/restore: @@ -18029,8 +18325,8 @@ paths: schema: $ref: "#/components/schemas/RestoreIllnessEpisodeEnvelope" "401": *a1 - "403": *a11 - "404": *a34 + "403": *a12 + "404": *a36 "409": *a7 "422": *a4 "429": *a5 @@ -18062,8 +18358,8 @@ paths: schema: $ref: "#/components/schemas/ResolveIllnessEpisodeEnvelope" "401": *a1 - "403": *a11 - "404": *a34 + "403": *a12 + "404": *a36 "422": *a4 "429": *a5 /api/illness/episodes/{id}/day-logs: @@ -18113,8 +18409,8 @@ paths: schema: $ref: "#/components/schemas/GetIllnessDayLogEnvelope" "401": *a1 - "403": *a11 - "404": *a34 + "403": *a12 + "404": *a36 "422": *a4 "429": *a5 post: @@ -18150,8 +18446,8 @@ paths: schema: $ref: "#/components/schemas/UpsertIllnessDayLogCreatedEnvelope" "401": *a1 - "403": *a11 - "404": *a34 + "403": *a12 + "404": *a36 "422": *a4 "429": *a5 /api/illness/episodes/{id}/correlation: @@ -18178,7 +18474,8 @@ paths: schema: $ref: "#/components/schemas/IllnessCorrelationEnvelope" "401": *a1 - "404": *a34 + "403": *a9 + "404": *a36 "422": *a4 "429": *a5 /api/illness/insights: @@ -18212,6 +18509,7 @@ paths: schema: $ref: "#/components/schemas/IllnessInsightsEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/mental-health/assessments: @@ -18251,7 +18549,7 @@ paths: schema: $ref: "#/components/schemas/ListMentalHealthAssessmentsEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 post: @@ -18284,7 +18582,7 @@ paths: schema: $ref: "#/components/schemas/CreateMentalHealthAssessmentEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "409": *a7 "422": *a4 "429": *a5 @@ -18311,7 +18609,7 @@ paths: schema: $ref: "#/components/schemas/GetMentalHealthAssessmentEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/allergies: @@ -18344,7 +18642,7 @@ paths: schema: $ref: "#/components/schemas/ListAllergiesEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 post: @@ -18369,10 +18667,10 @@ paths: schema: $ref: "#/components/schemas/CreateAllergyEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "409": *a7 "422": *a4 - "429": *a12 + "429": *a13 /api/allergies/{id}: get: tags: @@ -18393,8 +18691,8 @@ paths: schema: $ref: "#/components/schemas/GetAllergyEnvelope" "401": *a1 - "403": *a11 - "404": &a35 + "403": *a12 + "404": &a37 description: Allergy not found (or owned by another user). content: application/json: @@ -18427,8 +18725,8 @@ paths: schema: $ref: "#/components/schemas/UpdateAllergyEnvelope" "401": *a1 - "403": *a11 - "404": *a35 + "403": *a12 + "404": *a37 "422": *a4 "429": *a5 delete: @@ -18451,8 +18749,8 @@ paths: schema: $ref: "#/components/schemas/DeleteAllergyEnvelope" "401": *a1 - "403": *a11 - "404": *a35 + "403": *a12 + "404": *a37 "422": *a4 "429": *a5 /api/family-history: @@ -18476,7 +18774,7 @@ paths: schema: $ref: "#/components/schemas/ListFamilyHistoryEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 post: @@ -18501,7 +18799,7 @@ paths: schema: $ref: "#/components/schemas/CreateFamilyHistoryEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "409": *a7 "422": *a4 "429": *a5 @@ -18525,8 +18823,8 @@ paths: schema: $ref: "#/components/schemas/GetFamilyHistoryEnvelope" "401": *a1 - "403": *a11 - "404": &a36 + "403": *a12 + "404": &a38 description: Family history entry not found (or owned by another user). content: application/json: @@ -18559,8 +18857,8 @@ paths: schema: $ref: "#/components/schemas/UpdateFamilyHistoryEnvelope" "401": *a1 - "403": *a11 - "404": *a36 + "403": *a12 + "404": *a38 "422": *a4 "429": *a5 delete: @@ -18583,8 +18881,8 @@ paths: schema: $ref: "#/components/schemas/DeleteFamilyHistoryEnvelope" "401": *a1 - "403": *a11 - "404": *a36 + "403": *a12 + "404": *a38 "422": *a4 "429": *a5 /api/documents/inbound: @@ -18699,7 +18997,7 @@ paths: schema: $ref: "#/components/schemas/InboundDocumentListEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 post: @@ -18866,6 +19164,7 @@ paths: schema: $ref: "#/components/schemas/DocumentUploadEnvelope" "401": *a1 + "403": *a9 "409": description: The same bytes are already stored under 20 other import source ids (`documents.inbound.sourceAliasLimit`). Nothing was stored or remembered. @@ -18935,6 +19234,7 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceLookupEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/documents/inbound/capability: @@ -18960,6 +19260,7 @@ paths: schema: $ref: "#/components/schemas/DocumentAiCapabilityEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/documents/inbound/usage: @@ -18982,7 +19283,7 @@ paths: schema: $ref: "#/components/schemas/DocumentUsageEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/documents/inbound/layout: @@ -19000,7 +19301,7 @@ paths: schema: $ref: "#/components/schemas/DocumentsLayoutEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 put: @@ -19023,6 +19324,7 @@ paths: schema: $ref: "#/components/schemas/DocumentsLayoutSavedEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/documents/inbound/bulk: @@ -19047,6 +19349,7 @@ paths: schema: $ref: "#/components/schemas/DocumentBulkEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/documents/inbound/{id}: @@ -19069,7 +19372,7 @@ paths: schema: $ref: "#/components/schemas/InboundDocumentDetailEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 patch: @@ -19098,6 +19401,7 @@ paths: schema: $ref: "#/components/schemas/InboundDocumentUpdateEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 delete: @@ -19120,6 +19424,7 @@ paths: schema: $ref: "#/components/schemas/InboundDiscardEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/documents/inbound/{id}/restore: @@ -19144,6 +19449,7 @@ paths: schema: $ref: "#/components/schemas/InboundRestoreEnvelope" "401": *a1 + "403": *a9 "409": description: "Not restorable: purged past the grace window, or an identical live document exists." content: @@ -19188,7 +19494,7 @@ paths: type: string format: binary "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/documents/inbound/{id}/thumbnail: @@ -19217,7 +19523,7 @@ paths: type: string format: binary "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/documents/inbound/{id}/extract: @@ -19249,7 +19555,7 @@ paths: required: true schema: type: string - - &a38 + - &a40 name: Prefer in: header required: false @@ -19274,18 +19580,20 @@ paths: application/json: schema: $ref: "#/components/schemas/InboundExtractEnvelope" - "202": *a37 + "202": *a39 "401": *a1 - "403": &a39 - description: "AI refusal: the `documentAi` capability is unavailable for this record. The envelope carries - `meta.errorCode`, `meta.capability` and `meta.reason` (one of `AiUnavailableReason`). - `assistant.disabled.documentAi` — the operator turned off reading documents, or every AI feature (the master - switch). `module.disabled` with `meta.module = \"inboundDocuments\"` — the module is off for this record. - `ai.record.notPermitted` — AI work is not admitted inside this record (a delegate acting in somebody else's - record). `consent.ai.required` — the provider this request would reach leaves the machine and the record - holds no active `ai_extraction` or `ai_full` receipt; only a self-hosted model is exempt. The question is - answered for the provider actually picked, so it can differ from the published capability when a text-mode - read takes a different provider than a vision read would." + "403": &a41 + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: AI refusal: the `documentAi` + capability is unavailable for this record. The envelope carries `meta.errorCode`, `meta.capability` and + `meta.reason` (one of `AiUnavailableReason`). `assistant.disabled.documentAi` — the operator turned off + reading documents, or every AI feature (the master switch). `module.disabled` with `meta.module = + \"inboundDocuments\"` — the module is off for this record. `ai.record.notPermitted` — AI work is not + admitted inside this record (a delegate acting in somebody else's record). `consent.ai.required` — the + provider this request would reach leaves the machine and the record holds no active `ai_extraction` or + `ai_full` receipt; only a self-hosted model is exempt. The question is answered for the provider actually + picked, so it can differ from the published capability when a text-mode read takes a different provider than + a vision read would." content: application/json: schema: @@ -19300,7 +19608,7 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "503": &a40 + "503": &a42 description: "`ai.unavailable` — the inputs behind the AI capability could not be read, and the answer failed closed. Nothing was sent. Retry later." content: @@ -19330,7 +19638,7 @@ paths: required: true schema: type: string - - *a38 + - *a40 requestBody: required: false content: @@ -19366,12 +19674,12 @@ paths: application/json: schema: $ref: "#/components/schemas/DocumentSuggestEnvelope" - "202": *a37 + "202": *a39 "401": *a1 - "403": *a39 + "403": *a41 "422": *a4 "429": *a5 - "503": *a40 + "503": *a42 /api/documents/inbound/{id}/summary: post: tags: @@ -19421,7 +19729,7 @@ paths: type: boolean default: false description: With `persist=true`, explicitly replace an existing stored summary. - - *a38 + - *a40 requestBody: required: false content: @@ -19457,12 +19765,12 @@ paths: application/json: schema: $ref: "#/components/schemas/DocumentSummaryEnvelope" - "202": *a37 + "202": *a39 "401": *a1 - "403": *a39 + "403": *a41 "422": *a4 "429": *a5 - "503": *a40 + "503": *a42 /api/documents/inbound/{id}/index: post: tags: @@ -19488,7 +19796,7 @@ paths: required: true schema: type: string - - *a38 + - *a40 requestBody: required: false content: @@ -19524,12 +19832,12 @@ paths: application/json: schema: $ref: "#/components/schemas/DocumentIndexEnvelope" - "202": *a37 + "202": *a39 "401": *a1 - "403": *a39 + "403": *a41 "422": *a4 "429": *a5 - "503": *a40 + "503": *a42 /api/documents/inbound/{id}/chat: post: tags: @@ -19569,10 +19877,10 @@ paths: type: string description: "SSE frames: `data: \\n\\n`. See the operation description for the per-`type` frame shapes." "401": *a1 - "403": *a39 + "403": *a41 "422": *a4 "429": *a5 - "503": *a40 + "503": *a42 get: tags: - Documents @@ -19613,6 +19921,7 @@ paths: schema: $ref: "#/components/schemas/DocumentChatHistoryEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/documents/inbound/reindex: @@ -19635,10 +19944,10 @@ paths: schema: $ref: "#/components/schemas/DocumentReindexEnvelope" "401": *a1 - "403": *a39 + "403": *a41 "422": *a4 "429": *a5 - "503": *a40 + "503": *a42 /api/documents/inbound/{id}/facts/{factId}: patch: tags: @@ -19671,6 +19980,7 @@ paths: schema: $ref: "#/components/schemas/ExtractedFactEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/documents/inbound/{id}/confirm: @@ -19702,6 +20012,7 @@ paths: schema: $ref: "#/components/schemas/InboundConfirmEnvelope" "401": *a1 + "403": *a9 "409": *a7 "422": *a4 "429": *a5 @@ -19720,7 +20031,7 @@ paths: schema: $ref: "#/components/schemas/ListCustomMetricsEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 post: @@ -19745,7 +20056,8 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricResponse" "401": *a1 - "409": &a42 + "403": *a9 + "409": &a44 description: A custom metric with this name already exists for the caller. content: application/json: @@ -19773,8 +20085,8 @@ paths: schema: $ref: "#/components/schemas/GetCustomMetricResponse" "401": *a1 - "403": *a11 - "404": &a41 + "403": *a12 + "404": &a43 description: Custom metric not found (or owned by another user). content: application/json: @@ -19808,8 +20120,9 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricResponse" "401": *a1 - "404": *a41 - "409": *a42 + "403": *a9 + "404": *a43 + "409": *a44 "422": *a4 "429": *a5 delete: @@ -19832,7 +20145,8 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricResponse" "401": *a1 - "404": *a41 + "403": *a9 + "404": *a43 "422": *a4 "429": *a5 /api/custom-metrics/{id}/entries: @@ -19875,8 +20189,8 @@ paths: schema: $ref: "#/components/schemas/ListCustomMetricEntriesEnvelope" "401": *a1 - "403": *a11 - "404": *a41 + "403": *a12 + "404": *a43 "422": *a4 "429": *a5 post: @@ -19905,10 +20219,11 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricEntryResponse" "401": *a1 - "404": *a41 + "403": *a9 + "404": *a43 "409": *a7 "422": *a4 - "429": *a12 + "429": *a13 /api/custom-metrics/{id}/entries/{entryId}: patch: tags: @@ -19941,7 +20256,8 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricEntryResponse" "401": *a1 - "404": &a43 + "403": *a9 + "404": &a45 description: Custom metric entry not found (or owned by another user). content: application/json: @@ -19974,7 +20290,8 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricEntryResponse" "401": *a1 - "404": *a43 + "403": *a9 + "404": *a45 "422": *a4 "429": *a5 /api/custom-metrics/{id}/entries/restore: @@ -20005,6 +20322,7 @@ paths: schema: $ref: "#/components/schemas/RestoreCustomMetricEntriesResponse" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/onboarding/answers: @@ -20045,6 +20363,7 @@ paths: schema: $ref: "#/components/schemas/OnboardingStateEnvelope" "401": *a1 + "403": *a9 "413": description: Body exceeds 16 KiB. content: @@ -20102,6 +20421,7 @@ paths: schema: $ref: "#/components/schemas/OnboardingStateEnvelope" "401": *a1 + "403": *a9 "413": description: Body exceeds 4 KiB. content: @@ -20183,6 +20503,7 @@ paths: schema: $ref: "#/components/schemas/OnboardingCompleteEnvelope" "401": *a1 + "403": *a9 "404": description: "`managedRecordId` names no managed profile the caller actively guards — missing, not a managed record, or not theirs; one answer for all three. `meta.errorCode` = `onboarding.complete.recordNotFound`. Nothing was @@ -20235,6 +20556,7 @@ paths: schema: $ref: "#/components/schemas/DisclaimerAckEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/onboarding/tour: @@ -20259,6 +20581,7 @@ paths: schema: $ref: "#/components/schemas/TourUpdateEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/nutrients/catalog: @@ -20276,11 +20599,12 @@ paths: schema: $ref: "#/components/schemas/NutrientCatalogEnvelope" "401": *a1 - "403": &a44 + "403": &a46 description: >- - The opt-in `nutrients` module is off for this account (errorCode `module.disabled`). Stop syncing and do not - retry; offer the enable-in-settings hint. Only run the HealthKit read-authorization flow while the module is - on. + `meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: The opt-in `nutrients` module is + off for this account (errorCode `module.disabled`). Stop syncing and do not retry; offer the + enable-in-settings hint. Only run the HealthKit read-authorization flow while the module is on. Refused: this request named a record the caller may not act on (`meta.errorCode` = `sharing.access.denied`). @@ -20389,9 +20713,10 @@ paths: $ref: "#/components/schemas/NutrientBatchEnvelope" "401": *a1 "403": - description: The opt-in `nutrients` module is off for this account (errorCode `module.disabled`). Stop syncing and do - not retry; offer the enable-in-settings hint. Only run the HealthKit read-authorization flow while the - module is on. + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: The opt-in `nutrients` module is + off for this account (errorCode `module.disabled`). Stop syncing and do not retry; offer the + enable-in-settings hint. Only run the HealthKit read-authorization flow while the module is on." content: application/json: schema: @@ -20423,7 +20748,7 @@ paths: schema: $ref: "#/components/schemas/NutrientOverviewEnvelope" "401": *a1 - "403": *a44 + "403": *a46 "422": *a4 "429": *a5 /api/nutrients/daily: @@ -20460,7 +20785,7 @@ paths: schema: $ref: "#/components/schemas/NutrientDailySeriesEnvelope" "401": *a1 - "403": *a44 + "403": *a46 "422": *a4 "429": *a5 /api/nutrients/water: @@ -20489,7 +20814,7 @@ paths: schema: $ref: "#/components/schemas/NutrientWaterWriteEnvelope" "401": *a1 - "403": *a44 + "403": *a46 "409": *a7 "422": *a4 "429": *a5 @@ -20508,6 +20833,7 @@ paths: schema: $ref: "#/components/schemas/HealthKitConfigEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 patch: @@ -20531,6 +20857,7 @@ paths: schema: $ref: "#/components/schemas/HealthKitConfigPatchEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/oura/sync: @@ -20548,6 +20875,7 @@ paths: schema: $ref: "#/components/schemas/OuraSyncEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 "502": @@ -20573,6 +20901,7 @@ paths: schema: $ref: "#/components/schemas/PolarSyncEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 "502": @@ -20600,6 +20929,7 @@ paths: schema: $ref: "#/components/schemas/StravaSyncEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 "502": @@ -20626,6 +20956,7 @@ paths: schema: $ref: "#/components/schemas/NightscoutSyncEnvelope" "401": *a1 + "403": *a9 "422": description: The configured Nightscout origin is private (RFC 1918 / loopback / link-local) and the server operator has not approved private-origin access for this deployment. @@ -20659,6 +20990,7 @@ paths: schema: $ref: "#/components/schemas/GoogleHealthSyncStatusEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/nightscout/connect: @@ -20691,6 +21023,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 + "403": *a9 "413": description: Body exceeds 16 KiB. content: @@ -20728,6 +21061,7 @@ paths: schema: $ref: "#/components/schemas/NightscoutStatusEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/nightscout/test: @@ -20746,6 +21080,7 @@ paths: schema: $ref: "#/components/schemas/NightscoutTestEnvelope" "401": *a1 + "403": *a9 "422": description: No Nightscout instance is configured for this user. `meta.errorCode` = `not_configured`. content: @@ -20786,6 +21121,7 @@ paths: schema: $ref: "#/components/schemas/NightscoutDisconnectEnvelope" "401": *a1 + "403": *a9 "404": description: "No Nightscout connection to clear. Note that this is NOT an idempotent no-op: a second disconnect answers 404 rather than 200." @@ -20810,6 +21146,7 @@ paths: schema: $ref: "#/components/schemas/WhoopCredentialsStatusEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 put: @@ -20838,6 +21175,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 + "403": *a9 "413": description: Body exceeds 64 KiB. content: @@ -20867,6 +21205,7 @@ paths: schema: $ref: "#/components/schemas/WhoopCredentialsDeleteEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/whoop/status: @@ -20885,6 +21224,7 @@ paths: schema: $ref: "#/components/schemas/WhoopStatusEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/whoop/disconnect: @@ -20905,6 +21245,7 @@ paths: schema: $ref: "#/components/schemas/WhoopDisconnectEnvelope" "401": *a1 + "403": *a9 "404": description: "No WHOOP connection to remove. NOT an idempotent no-op: a second disconnect answers 404 rather than 200." content: @@ -20930,6 +21271,7 @@ paths: schema: $ref: "#/components/schemas/WithingsStatusEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/withings/disconnect: @@ -20948,6 +21290,7 @@ paths: schema: $ref: "#/components/schemas/WithingsDisconnectEnvelope" "401": *a1 + "403": *a9 "404": description: "No Withings connection to remove. Note that this is NOT an idempotent no-op: a second disconnect answers 404 rather than 200." @@ -20971,6 +21314,7 @@ paths: schema: $ref: "#/components/schemas/WithingsCredentialsStatusEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 put: @@ -20991,26 +21335,27 @@ paths: application/json: schema: $ref: "#/components/schemas/WithingsCredentialsUpdateEnvelope" - "400": &a45 + "400": &a47 description: Body is not parseable JSON. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "413": &a46 + "403": *a9 + "413": &a48 description: Body exceeds 64 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "415": &a47 + "415": &a49 description: Content-Type is not `application/json`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": &a48 + "422": &a50 description: "`clientId` and `clientSecret` are both required and each must be 1–200 characters. The envelope carries a single fixed message, not per-field issues." content: @@ -21033,6 +21378,7 @@ paths: schema: $ref: "#/components/schemas/WithingsCredentialsDeleteEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/withings/sync: @@ -21063,6 +21409,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 + "403": *a9 "413": description: Body exceeds 64 KiB. content: @@ -21111,6 +21458,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 + "403": *a9 "413": description: Body exceeds 64 KiB. content: @@ -21155,6 +21503,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": description: More than 10 ticket mints in 60 s from this user. @@ -21176,6 +21525,7 @@ paths: schema: $ref: "#/components/schemas/FitbitCredentialsStatusEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 put: @@ -21196,11 +21546,12 @@ paths: application/json: schema: $ref: "#/components/schemas/FitbitCredentialsUpdateEnvelope" - "400": *a45 + "400": *a47 "401": *a1 - "413": *a46 - "415": *a47 - "422": *a48 + "403": *a9 + "413": *a48 + "415": *a49 + "422": *a50 "429": *a5 delete: tags: @@ -21217,6 +21568,7 @@ paths: schema: $ref: "#/components/schemas/FitbitCredentialsDeleteEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/fitbit/status: @@ -21234,6 +21586,7 @@ paths: schema: $ref: "#/components/schemas/FitbitStatusEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/fitbit/disconnect: @@ -21253,6 +21606,7 @@ paths: schema: $ref: "#/components/schemas/FitbitDisconnectEnvelope" "401": *a1 + "403": *a9 "404": description: "No Fitbit connection to remove. NOT an idempotent no-op: a second disconnect answers 404 rather than 200." content: @@ -21289,6 +21643,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 + "403": *a9 "410": description: Google turned the legacy Fitbit Web API off on 2026-10-30; nothing is fetched. `meta.errorCode` = `provider_retired`. The data already imported stays; Google Health reads the same devices. @@ -21330,6 +21685,7 @@ paths: schema: $ref: "#/components/schemas/GoogleHealthCredentialsStatusEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 put: @@ -21350,11 +21706,12 @@ paths: application/json: schema: $ref: "#/components/schemas/GoogleHealthCredentialsUpdateEnvelope" - "400": *a45 + "400": *a47 "401": *a1 - "413": *a46 - "415": *a47 - "422": *a48 + "403": *a9 + "413": *a48 + "415": *a49 + "422": *a50 "429": *a5 delete: tags: @@ -21371,6 +21728,7 @@ paths: schema: $ref: "#/components/schemas/GoogleHealthCredentialsDeleteEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/google-health/status: @@ -21389,6 +21747,7 @@ paths: schema: $ref: "#/components/schemas/GoogleHealthStatusEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/google-health/disconnect: @@ -21407,6 +21766,7 @@ paths: schema: $ref: "#/components/schemas/GoogleHealthDisconnectEnvelope" "401": *a1 + "403": *a9 "404": description: "No Google Health connection to remove. NOT an idempotent no-op: a second disconnect answers 404 rather than 200." @@ -21446,6 +21806,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 + "403": *a9 "409": description: Another sync of this account is already running (the history backfill after a reconnect, or the hourly poll), so none was started. `meta.errorCode` = `sync_in_progress`. `GET /api/google-health/sync/status` @@ -21495,6 +21856,7 @@ paths: schema: $ref: "#/components/schemas/OuraCredentialsStatusEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 put: @@ -21515,11 +21877,12 @@ paths: application/json: schema: $ref: "#/components/schemas/OuraCredentialsUpdateEnvelope" - "400": *a45 + "400": *a47 "401": *a1 - "413": *a46 - "415": *a47 - "422": *a48 + "403": *a9 + "413": *a48 + "415": *a49 + "422": *a50 "429": *a5 delete: tags: @@ -21536,6 +21899,7 @@ paths: schema: $ref: "#/components/schemas/OuraCredentialsDeleteEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/oura/status: @@ -21554,6 +21918,7 @@ paths: schema: $ref: "#/components/schemas/OuraStatusEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/oura/disconnect: @@ -21572,6 +21937,7 @@ paths: schema: $ref: "#/components/schemas/OuraDisconnectEnvelope" "401": *a1 + "403": *a9 "404": description: "No Oura token stored. NOT an idempotent no-op: a second disconnect answers 404 rather than 200." content: @@ -21595,21 +21961,22 @@ paths: schema: $ref: "#/components/schemas/OuraTestEnvelope" "401": *a1 - "422": &a49 + "403": *a9 + "422": &a51 description: No usable connection for this provider — nothing stored, or the stored grant could no longer be resolved into an access token. `meta.errorCode` = `not_configured`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": &a50 + "429": &a52 description: More than 5 requests in 60 s from this user. `meta.errorCode` = `rate_limited_self` — this is the local limiter, distinct from a `rate_limited` 502 which is the provider's. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "502": &a51 + "502": &a53 description: "The probe did not complete. `meta.errorCode` names the class: `credentials_rejected` (the provider answered 401/403 — the stored grant is gone), `rate_limited` (the PROVIDER answered 429, which is not this endpoint's own limiter), `upstream_error` (5xx), `timeout` (no answer within 5 s), `connection_failed` @@ -21633,6 +22000,7 @@ paths: schema: $ref: "#/components/schemas/PolarCredentialsStatusEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 put: @@ -21654,11 +22022,12 @@ paths: application/json: schema: $ref: "#/components/schemas/PolarCredentialsUpdateEnvelope" - "400": *a45 + "400": *a47 "401": *a1 - "413": *a46 - "415": *a47 - "422": *a48 + "403": *a9 + "413": *a48 + "415": *a49 + "422": *a50 "429": *a5 delete: tags: @@ -21675,6 +22044,7 @@ paths: schema: $ref: "#/components/schemas/PolarCredentialsDeleteEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/polar/status: @@ -21693,6 +22063,7 @@ paths: schema: $ref: "#/components/schemas/PolarStatusEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/polar/disconnect: @@ -21711,6 +22082,7 @@ paths: schema: $ref: "#/components/schemas/PolarDisconnectEnvelope" "401": *a1 + "403": *a9 "404": description: "No Polar token stored. NOT an idempotent no-op: a second disconnect answers 404 rather than 200." content: @@ -21734,9 +22106,10 @@ paths: schema: $ref: "#/components/schemas/PolarTestEnvelope" "401": *a1 - "422": *a49 - "429": *a50 - "502": *a51 + "403": *a9 + "422": *a51 + "429": *a52 + "502": *a53 /api/strava/credentials: get: tags: @@ -21752,6 +22125,7 @@ paths: schema: $ref: "#/components/schemas/StravaCredentialsStatusEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 put: @@ -21772,11 +22146,12 @@ paths: application/json: schema: $ref: "#/components/schemas/StravaCredentialsUpdateEnvelope" - "400": *a45 + "400": *a47 "401": *a1 - "413": *a46 - "415": *a47 - "422": *a48 + "403": *a9 + "413": *a48 + "415": *a49 + "422": *a50 "429": *a5 delete: tags: @@ -21794,6 +22169,7 @@ paths: schema: $ref: "#/components/schemas/StravaCredentialsDeleteEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/strava/status: @@ -21812,6 +22188,7 @@ paths: schema: $ref: "#/components/schemas/StravaStatusEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/strava/disconnect: @@ -21831,6 +22208,7 @@ paths: schema: $ref: "#/components/schemas/StravaDisconnectEnvelope" "401": *a1 + "403": *a9 "404": description: "No Strava connection. NOT an idempotent no-op: a second disconnect answers 404 rather than 200." content: @@ -21854,9 +22232,10 @@ paths: schema: $ref: "#/components/schemas/StravaTestEnvelope" "401": *a1 - "422": *a49 - "429": *a50 - "502": *a51 + "403": *a9 + "422": *a51 + "429": *a52 + "502": *a53 /api/integrations/status: get: tags: @@ -21873,6 +22252,7 @@ paths: schema: $ref: "#/components/schemas/IntegrationStatusEnvelopeResponse" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/integrations/fitbit/resume: @@ -21891,8 +22271,9 @@ paths: schema: $ref: "#/components/schemas/FitbitResumeEnvelope" "401": *a1 + "403": *a9 "422": *a4 - "429": *a50 + "429": *a52 /api/integrations/fitbit/test: post: tags: @@ -21910,6 +22291,7 @@ paths: schema: $ref: "#/components/schemas/FitbitIntegrationTestEnvelope" "401": *a1 + "403": *a9 "410": description: Google turned the legacy Fitbit Web API off on 2026-10-30; nothing is probed. `meta.errorCode` = `provider_retired`. @@ -21917,9 +22299,9 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a49 - "429": *a50 - "502": *a51 + "422": *a51 + "429": *a52 + "502": *a53 /api/integrations/google-health/resume: post: tags: @@ -21935,8 +22317,9 @@ paths: schema: $ref: "#/components/schemas/GoogleHealthResumeEnvelope" "401": *a1 + "403": *a9 "422": *a4 - "429": *a50 + "429": *a52 /api/integrations/google-health/test: post: tags: @@ -21963,15 +22346,16 @@ paths: schema: $ref: "#/components/schemas/GoogleHealthIntegrationTestEnvelope" "401": *a1 + "403": *a9 "413": description: Body exceeds 64 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a49 - "429": *a50 - "502": *a51 + "422": *a51 + "429": *a52 + "502": *a53 /api/integrations/whoop/resume: post: tags: @@ -21987,8 +22371,9 @@ paths: schema: $ref: "#/components/schemas/WhoopResumeEnvelope" "401": *a1 + "403": *a9 "422": *a4 - "429": *a50 + "429": *a52 /api/integrations/whoop/test: post: tags: @@ -22005,9 +22390,10 @@ paths: schema: $ref: "#/components/schemas/WhoopIntegrationTestEnvelope" "401": *a1 - "422": *a49 - "429": *a50 - "502": *a51 + "403": *a9 + "422": *a51 + "429": *a52 + "502": *a53 /api/integrations/withings/resume: post: tags: @@ -22024,8 +22410,9 @@ paths: schema: $ref: "#/components/schemas/WithingsResumeEnvelope" "401": *a1 + "403": *a9 "422": *a4 - "429": *a50 + "429": *a52 /api/integrations/withings/test: post: tags: @@ -22043,8 +22430,9 @@ paths: schema: $ref: "#/components/schemas/WithingsIntegrationTestEnvelope" "401": *a1 - "422": *a49 - "429": *a50 + "403": *a9 + "422": *a51 + "429": *a52 "502": description: "The probe did not complete. The shared codes apply (`credentials_rejected`, `rate_limited`, `upstream_error`, `timeout`, `connection_failed`) and this path adds one of its own: @@ -22077,7 +22465,7 @@ paths: schema: $ref: "#/components/schemas/DailyDigestEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/daily/digest/dismiss: @@ -22112,6 +22500,7 @@ paths: schema: $ref: "#/components/schemas/DismissPriorityItemEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/dashboard/chart-overlay-prefs: @@ -22146,6 +22535,7 @@ paths: schema: $ref: "#/components/schemas/ChartOverlayPrefsSavedEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/dashboard/summary: @@ -22168,7 +22558,7 @@ paths: schema: $ref: "#/components/schemas/DashboardSummaryEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/dashboard/widgets: @@ -22187,7 +22577,7 @@ paths: schema: $ref: "#/components/schemas/DashboardLayout" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 put: @@ -22213,6 +22603,7 @@ paths: schema: $ref: "#/components/schemas/DashboardLayoutSaved" "401": *a1 + "403": *a9 "409": description: Dashboard layout changed since it was loaded (optimistic-concurrency conflict). No write happened. Re-read the resource, re-apply the user's change against the fresh state, and resend with the new token. @@ -22221,7 +22612,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a15 + "422": *a16 "429": *a5 delete: tags: @@ -22237,6 +22628,7 @@ paths: schema: $ref: "#/components/schemas/DashboardLayoutReset" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/managed-profiles/{id}/handover: @@ -22260,6 +22652,7 @@ paths: schema: $ref: "#/components/schemas/ManagedProfileHandoverStatusEnvelope" "401": *a1 + "403": *a9 "404": description: "No such managed profile, or the caller is not one of its Guardians (`meta.errorCode: managed_profile.not_found`)." @@ -22300,9 +22693,11 @@ paths: $ref: "#/components/schemas/ManagedProfileHandoverEnvelope" "401": *a1 "403": - description: "Neither a password nor single sign-on could redeem a link on this instance (`meta.errorCode: - profile_claim.oidc_only_unsupported`). Since v1.43 a single-sign-on-only instance hands over through the - identity provider, so a working configuration never answers this." + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: Neither a password nor single + sign-on could redeem a link on this instance (`meta.errorCode: profile_claim.oidc_only_unsupported`). Since + v1.43 a single-sign-on-only instance hands over through the identity provider, so a working configuration + never answers this." content: application/json: schema: @@ -22342,6 +22737,7 @@ paths: schema: $ref: "#/components/schemas/RevokeManagedProfileHandoverEnvelope" "401": *a1 + "403": *a9 "404": description: "No such managed profile, or the caller is not one of its Guardians (`meta.errorCode: managed_profile.not_found`)." @@ -22366,6 +22762,7 @@ paths: schema: $ref: "#/components/schemas/PendingHandoverDecisionEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 post: @@ -22389,6 +22786,7 @@ paths: schema: $ref: "#/components/schemas/HandoverDecisionEnvelope" "401": *a1 + "403": *a9 "409": description: "No decision is waiting (`meta.errorCode: managed_profile.handover.no_pending`)." content: @@ -22423,7 +22821,9 @@ paths: $ref: "#/components/schemas/AccountPayloadEnvelope" "401": *a1 "403": - description: "A selector header was attached to an actor surface (`meta.errorCode: sharing.not_permitted`)." + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: A selector header was attached to + an actor surface (`meta.errorCode: sharing.not_permitted`)." content: application/json: schema: @@ -22450,11 +22850,13 @@ paths: schema: $ref: "#/components/schemas/RecordActivityEnvelope" "401": *a1 - "403": &a52 - description: Refused. `meta.errorCode` is `sharing.not_permitted` when the request was made while acting on another - account (grant management is never delegable), or `sharing.access.denied` when the caller may not act as the - account they named. The latter is byte-identical for an account that does not exist and one that granted - nothing — the refusal is not an enumeration oracle. + "403": &a54 + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: Refused. `meta.errorCode` is + `sharing.not_permitted` when the request was made while acting on another account (grant management is never + delegable), or `sharing.access.denied` when the caller may not act as the account they named. The latter is + byte-identical for an account that does not exist and one that granted nothing — the refusal is not an + enumeration oracle." content: application/json: schema: @@ -22475,7 +22877,7 @@ paths: schema: $ref: "#/components/schemas/AccountGrantListEnvelope" "401": *a1 - "403": *a52 + "403": *a54 "422": *a4 "429": *a5 post: @@ -22519,10 +22921,12 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "403": - description: Refused. `meta.errorCode` is `sharing.not_permitted` when the request was made while acting on another - account (grant management is never delegable), or `sharing.invite.manage_browser_only` when a Bearer caller - tried to offer MANAGE — the step-up that gates it is cookie-only by construction, so the native client - cannot mint it and should route the person to a browser rather than surface an error. + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: Refused. `meta.errorCode` is + `sharing.not_permitted` when the request was made while acting on another account (grant management is never + delegable), or `sharing.invite.manage_browser_only` when a Bearer caller tried to offer MANAGE — the step-up + that gates it is cookie-only by construction, so the native client cannot mint it and should route the + person to a browser rather than surface an error." content: application/json: schema: @@ -22577,7 +22981,7 @@ paths: schema: $ref: "#/components/schemas/AcceptedAccountGrantEnvelope" "401": *a1 - "403": *a52 + "403": *a54 "404": description: No such invitation for this account. content: @@ -22621,7 +23025,7 @@ paths: schema: $ref: "#/components/schemas/RevokedAccountGrantEnvelope" "401": *a1 - "403": *a52 + "403": *a54 "404": description: No such grant on this account's record. content: @@ -22662,7 +23066,7 @@ paths: schema: $ref: "#/components/schemas/RenouncedAccountGrantEnvelope" "401": *a1 - "403": *a52 + "403": *a54 "404": description: No such grant held by this account. content: @@ -22717,6 +23121,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" + "403": *a9 "422": *a4 "429": description: "More than ten profiles from one caller within the hour (`meta.errorCode: http.rate_limited`, the status @@ -22750,6 +23155,7 @@ paths: schema: $ref: "#/components/schemas/ManagedProfileEnvelope" "401": *a1 + "403": *a9 "404": description: "No such managed profile, or the caller is not one of its Guardians (`meta.errorCode: managed_profile.not_found`). The two are byte-identical, so the refusal is not an enumeration oracle." @@ -22794,6 +23200,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" + "403": *a9 "404": description: "No such managed profile, or the caller is not one of its Guardians (`meta.errorCode: managed_profile.not_found`). The two are byte-identical." @@ -22837,6 +23244,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" + "403": *a9 "404": description: "No such managed profile, or the caller is not one of its Guardians (`meta.errorCode: managed_profile.not_found`). The two are byte-identical, so the refusal is not an enumeration oracle — and @@ -22875,6 +23283,7 @@ paths: schema: $ref: "#/components/schemas/ManagedProfileGuardianListEnvelope" "401": *a1 + "403": *a9 "404": description: "No such managed profile, or the caller is not one of its Guardians (`meta.errorCode: managed_profile.not_found`). The two are byte-identical." @@ -22919,6 +23328,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" + "403": *a9 "404": description: "Two refusals share this status, each with its own code. `meta.errorCode: sharing.invite.no_account`: no account on this instance carries that identifier — the same deliberate disclosure to an authenticated caller @@ -22989,6 +23399,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" + "403": *a9 "404": description: "`meta.errorCode: managed_profile.not_found`, for four situations the caller cannot tell apart: no such profile, no such grant on it, the caller is not one of its Guardians, or the target grant is the caller's @@ -23047,7 +23458,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a52 + "403": *a54 "409": description: "The record this browser session is on is not the record the request asserted. `meta.errorCode` is `sharing.session.changed`. It is not a refusal of access — the caller may well be entitled to both records — @@ -23127,7 +23538,9 @@ paths: $ref: "#/components/schemas/WebPushSubscribeEnvelope" "401": *a1 "403": - description: The operator switched Web Push off for this instance. Nothing was stored. + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: The operator switched Web Push + off for this instance. Nothing was stored." content: application/json: schema: @@ -23166,6 +23579,7 @@ paths: schema: $ref: "#/components/schemas/WebPushUnsubscribeEnvelope" "401": *a1 + "403": *a9 "413": description: Request body exceeds 65536 bytes. content: @@ -23197,6 +23611,7 @@ paths: schema: $ref: "#/components/schemas/WebPushTestEnvelope" "401": *a1 + "403": *a9 "422": description: "Nothing to test against: the account has no Push subscription (`meta.errorCode` = `not_configured`), or the operator has configured no VAPID keys (`vapid_not_configured`)." @@ -23238,6 +23653,7 @@ paths: schema: $ref: "#/components/schemas/ApnsTestEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": description: More than 5 tests in a minute (`meta.errorCode` = `rate_limited_self`). @@ -23312,6 +23728,7 @@ paths: schema: $ref: "#/components/schemas/GetNotificationPreferences" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 put: @@ -23334,6 +23751,7 @@ paths: schema: $ref: "#/components/schemas/PutNotificationPreferenceResponse" "401": *a1 + "403": *a9 "404": description: No such channel for this caller. content: @@ -23369,6 +23787,7 @@ paths: schema: $ref: "#/components/schemas/GetNotificationStatus" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 post: @@ -23393,6 +23812,7 @@ paths: schema: $ref: "#/components/schemas/ReEnableNotificationChannelResponse" "401": *a1 + "403": *a9 "404": description: No such channel for this caller. content: @@ -23427,10 +23847,11 @@ paths: schema: $ref: "#/components/schemas/McpConnectionListEnvelope" "401": *a1 - "403": &a53 - description: The operator has disabled the API instance-wide. Every route in this module refuses while that setting is - off, for a cookie session as well as for a Bearer token; it is not a per-user permission and retrying will - not help. + "403": &a55 + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: The operator has disabled the API + instance-wide. Every route in this module refuses while that setting is off, for a cookie session as well as + for a Bearer token; it is not a per-user permission and retrying will not help." content: application/json: schema: @@ -23460,7 +23881,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectionRevokeEnvelope" "401": *a1 - "403": *a53 + "403": *a55 "404": description: No live connection with this id for this caller — also the answer for an already-revoked one, so a repeat revoke is 404 rather than an idempotent 200. @@ -23487,7 +23908,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectorTokenListEnvelope" "401": *a1 - "403": *a53 + "403": *a55 "422": *a4 "429": *a5 post: @@ -23520,7 +23941,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a53 + "403": *a55 "413": description: Body exceeds 16 KiB. content: @@ -23563,7 +23984,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectorTokenRevokeEnvelope" "401": *a1 - "403": *a53 + "403": *a55 "404": description: No `health:read` token with this id for this caller. An already-revoked token is still found, so a repeat revoke answers 200. @@ -23590,7 +24011,7 @@ paths: schema: $ref: "#/components/schemas/RecordSettingsEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/record-settings/integrations: @@ -23609,7 +24030,7 @@ paths: schema: $ref: "#/components/schemas/RecordSettingsIntegrationsEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/record-settings/integrations/{provider}/connect: @@ -23625,7 +24046,7 @@ paths: record (audited). Fitbit is not offered: its classic API is retired. Cookie session, while acting on a managed record the caller guards." parameters: - - &a54 + - &a56 name: provider in: path required: true @@ -23647,7 +24068,7 @@ paths: schema: $ref: "#/components/schemas/ManagedConnectStartEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "404": description: The path named a provider outside the closed set. Answered before the caller is authenticated. content: @@ -23672,7 +24093,7 @@ paths: Audited against the record with the Guardian as the actor. Cookie session or Bearer, while acting on a managed record the caller guards." parameters: - - *a54 + - *a56 responses: "200": description: The provider is disconnected. @@ -23681,7 +24102,7 @@ paths: schema: $ref: "#/components/schemas/ManagedDisconnectEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "404": description: The path named a provider outside the closed set, or the record has no connection to it. content: @@ -23698,7 +24119,7 @@ paths: description: Returns the named family's resolved settings for the record the session is acting on. Cookie session or Bearer, while acting on a managed record the caller guards. parameters: - - &a55 + - &a57 name: family in: path required: true @@ -23722,7 +24143,7 @@ paths: schema: $ref: "#/components/schemas/GetRecordSettingsFamilyEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "404": description: Either the path named a family outside the closed set, or the record row itself is gone. The two are not distinguished, and the first is answered before the caller is authenticated at all. @@ -23742,7 +24163,7 @@ paths: ride the audit row. Body capped at 64 KiB. Cookie session or Bearer, while acting on a managed record the caller guards. parameters: - - *a55 + - *a57 requestBody: required: true content: @@ -23763,7 +24184,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "404": description: The path named a family outside the closed set. Answered before authentication. content: @@ -23877,7 +24298,7 @@ paths: schema: $ref: "#/components/schemas/ListPersonalRecordsEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": description: "`metricType` or `limit` carried an unacceptable value. `meta.errorCode` = `personal_records.invalid_query`, with the offending path under `details.issues`." @@ -23930,7 +24351,7 @@ paths: schema: $ref: "#/components/schemas/AchievementsEnvelope" "401": *a1 - "403": *a13 + "403": *a14 "422": *a4 "429": *a5 /api/environment/preferences: @@ -23956,7 +24377,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentPreferencesEnvelope" "401": *a1 - "403": *a19 + "403": *a21 "422": *a4 "429": *a5 /api/environment: @@ -23974,7 +24395,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentOverviewEnvelope" "401": *a1 - "403": *a19 + "403": *a21 "422": *a4 "429": *a5 /api/environment/home: @@ -24011,7 +24432,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentHomeEnvelope" "401": *a1 - "403": *a19 + "403": *a21 "413": description: Body exceeds 4 KiB. content: @@ -24071,7 +24492,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentBackfillEnvelope" "401": *a1 - "403": *a19 + "403": *a21 "409": description: No home location is set, so there is nothing to resolve the days against (`meta.errorCode` = `environment.no_home`), or a backfill for this account is already queued in the current twenty-minute slot @@ -24131,7 +24552,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentGeocodeEnvelope" "401": *a1 - "403": *a19 + "403": *a21 "422": description: "`q` was missing, empty after trimming, or longer than 120 characters. `meta.errorCode` = `environment.invalid`." @@ -24170,7 +24591,7 @@ paths: schema: $ref: "#/components/schemas/CreateEnvironmentTravelEnvelope" "401": *a1 - "403": *a19 + "403": *a21 "413": description: Body exceeds 4 KiB. content: @@ -24223,7 +24644,7 @@ paths: schema: $ref: "#/components/schemas/DeleteEnvironmentTravelEnvelope" "401": *a1 - "403": *a19 + "403": *a21 "404": description: No such override for this account. `meta.errorCode` = `environment.travel.not_found`. Also the answer for an id owned by somebody else — existence is not leaked. @@ -24369,13 +24790,13 @@ paths: application/json: schema: $ref: "#/components/schemas/AdminBackupDocument" - "403": &a56 + "403": &a58 description: Caller is not an admin. `requireAdmin()` is cookie-only, so a Bearer token lands here whatever its scope. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "404": &a57 + "404": &a59 description: No stored copy with this id. content: application/json: @@ -24449,8 +24870,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "403": *a56 - "404": *a57 + "403": *a58 + "404": *a59 "409": description: A restore of the same account is already queued or running (`meta.errorCode` = `backup.restore.active`, `meta.jobId` names it), the stored copy was replaced by a newer one while it was being checked @@ -24494,7 +24915,7 @@ paths: application/json: schema: $ref: "#/components/schemas/AdminBackupRestoreJobListResponse" - "403": *a56 + "403": *a58 /api/admin/backups/restores/{jobId}: get: tags: @@ -24517,7 +24938,7 @@ paths: application/json: schema: $ref: "#/components/schemas/AdminBackupRestoreJobResponse" - "403": *a56 + "403": *a58 "404": description: No restore job with this id. content: @@ -24536,7 +24957,7 @@ paths: request leaves the server. Needs neither the Documents module nor the list: with the list unset, `available` is false and any connection still stored is listed (with `originAllowed: false`) so it can be removed. Cookie session only; not delegable." - security: &a58 + security: &a60 - cookieAuth: [] responses: "200": @@ -24546,10 +24967,12 @@ paths: schema: $ref: "#/components/schemas/DocumentSourcesStatusEnvelope" "401": *a1 - "403": &a59 - description: "Refused before any request to the source: `documents.sources.browserOnly` for a Bearer caller, - `sharing.not_permitted` while acting on another person's record (a delegate or guardian never uses the - owner's archive), or `module.disabled` when the Documents module is off." + "403": &a61 + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: Refused before any request to the + source: `documents.sources.browserOnly` for a Bearer caller, `sharing.not_permitted` while acting on another + person's record (a delegate or guardian never uses the owner's archive), or `module.disabled` when the + Documents module is off." content: application/json: schema: @@ -24569,9 +24992,9 @@ paths: `documents.sources.tokenRequired`), so a stored token is never sent to an address it was not saved for. A Papra organization id is letters, digits, `_` and `-`. The token is stored encrypted (AES-256-GCM) and never returned. Ten saves and tests a minute per person. Cookie session only; not delegable." - security: *a58 + security: *a60 parameters: - - &a60 + - &a62 name: system in: path required: true @@ -24610,8 +25033,8 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceConnectionEnvelope" "401": *a1 - "403": *a59 - "404": &a61 + "403": *a61 + "404": &a63 description: "`documents.sources.unavailable`: the operator has not listed any origin in `DOCUMENT_SOURCE_ORIGINS`, so the picker is off. `documents.sources.notConnected`: no connection is saved for this system. `documents.sources.notFound`: the source has no such document, or (Papra) no such organization, including @@ -24630,7 +25053,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 - "502": &a62 + "502": &a64 description: "The source could not answer usefully, with the reason in `meta.errorCode` and, when the source answered at all, its HTTP status in `meta.upstreamStatus` (its body is never passed on): `documents.sources.unreachable` (no connection, timeout, or a destination refused at dial time), `documents.sources.redirected` (the source @@ -24649,9 +25072,9 @@ paths: description: Deletes the caller's connection for this system, and its token with it. Works even after the operator removed the origin from the list, and with the Documents module switched off. `disconnected` is false when there was nothing to delete. Cookie session only; not delegable. - security: *a58 + security: *a60 parameters: - - *a60 + - *a62 responses: "200": description: Removed, or there was nothing to remove. @@ -24660,7 +25083,7 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceDisconnectEnvelope" "401": *a1 - "403": *a59 + "403": *a61 "422": *a4 "429": *a5 /api/documents/sources/{system}/test: @@ -24671,9 +25094,9 @@ paths: description: One request to the source with the stored token. Answers `ok` with the round-trip time and stamps `lastVerifiedAt`, or the reason in `meta.errorCode`. Shares the save's allowance of ten a minute. Cookie session only; not delegable. - security: *a58 + security: *a60 parameters: - - *a60 + - *a62 responses: "200": description: The source answered. @@ -24682,9 +25105,9 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceTestEnvelope" "401": *a1 - "403": *a59 - "404": *a61 - "422": &a63 + "403": *a61 + "404": *a63 + "422": &a65 description: "`documents.sources.originNotAllowed`: the base address's origin is not on the operator's list (checked on every request, so a connection saved earlier is cut off once its origin is removed), or another validation failure." @@ -24693,7 +25116,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 - "502": *a62 + "502": *a64 /api/documents/sources/{system}/tags: get: tags: @@ -24701,9 +25124,9 @@ paths: summary: Tags of a connected archive description: The source's tags for the picker's filter, the first 250 by name, in the source's own ids. Shares the search allowance of sixty a minute. Cookie session only; not delegable. - security: *a58 + security: *a60 parameters: - - *a60 + - *a62 responses: "200": description: The tags. @@ -24712,11 +25135,11 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceTagsEnvelope" "401": *a1 - "403": *a59 - "404": *a61 - "422": *a63 + "403": *a61 + "404": *a63 + "422": *a65 "429": *a5 - "502": *a62 + "502": *a64 /api/documents/sources/{system}/search: get: tags: @@ -24730,9 +25153,9 @@ paths: under that source key: `new`, `imported` with the vault document's `documentId`, or `deleted` (deleted in HealthLog; importing it again stores nothing). Nothing is stored. Sixty searches a minute, shared with the tag read. Cookie session only; not delegable." - security: *a58 + security: *a60 parameters: - - *a60 + - *a62 - name: q in: query required: false @@ -24773,11 +25196,11 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceSearchEnvelope" "401": *a1 - "403": *a59 - "404": *a61 - "422": *a63 + "403": *a61 + "404": *a63 + "422": *a65 "429": *a5 - "502": *a62 + "502": *a64 /api/documents/sources/{system}/import: post: tags: @@ -24796,9 +25219,9 @@ paths: fetched), including a duplicate. `kind` sets the document type (default OTHER). Automatic AI reading follows the person's own setting. Counts against the person's upload allowance (60 an hour) and storage quota. 201 for a new document, 200 otherwise. Cookie session only; not delegable." - security: *a58 + security: *a60 parameters: - - *a60 + - *a62 requestBody: required: true content: @@ -24856,7 +25279,7 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceImportEnvelope" "401": *a1 - "403": *a59 + "403": *a61 "404": description: "`documents.sources.unavailable`: the operator has not listed any origin in `DOCUMENT_SOURCE_ORIGINS`, so the picker is off. `documents.sources.notConnected`: no connection is saved for this system. @@ -24887,7 +25310,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a63 + "422": *a65 "429": description: "`documents.inbound.rateLimited` (the person's hourly upload allowance, with the rate-limit headers) or `documents.inbound.uploadBusy` (too many uploads in progress; `Retry-After: 1`)." @@ -24895,7 +25318,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "502": *a62 + "502": *a64 /api/symptoms/definitions: get: tags: @@ -24919,10 +25342,11 @@ paths: schema: $ref: "#/components/schemas/ListSymptomDefinitionsEnvelope" "401": *a1 - "403": &a64 + "403": &a66 description: >- - `illness.disabled`: the record has the illness module switched off (or the operator turned it off - server-wide); symptoms ride that module. + `meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: `illness.disabled`: the record + has the illness module switched off (or the operator turned it off server-wide); symptoms ride that module. Refused: this request named a record the caller may not act on (`meta.errorCode` = `sharing.access.denied`). @@ -24962,7 +25386,7 @@ paths: schema: $ref: "#/components/schemas/CreateSymptomDefinitionEnvelope" "401": *a1 - "403": *a64 + "403": *a66 "409": *a7 "422": *a4 "429": *a5 @@ -24992,8 +25416,8 @@ paths: schema: $ref: "#/components/schemas/UpdateSymptomDefinitionEnvelope" "401": *a1 - "403": *a64 - "404": &a65 + "403": *a66 + "404": &a67 description: "`symptoms.definition.notFound`: no definition with this id on the record." content: application/json: @@ -25027,8 +25451,8 @@ paths: schema: $ref: "#/components/schemas/DeleteSymptomDefinitionEnvelope" "401": *a1 - "403": *a64 - "404": *a65 + "403": *a66 + "404": *a67 "422": *a4 "429": *a5 /api/symptoms/events: @@ -25077,7 +25501,7 @@ paths: schema: $ref: "#/components/schemas/ListSymptomEventsEnvelope" "401": *a1 - "403": *a64 + "403": *a66 "422": *a4 "429": *a5 post: @@ -25103,8 +25527,8 @@ paths: schema: $ref: "#/components/schemas/CreateSymptomEventEnvelope" "401": *a1 - "403": *a64 - "404": *a65 + "403": *a66 + "404": *a67 "409": *a7 "422": *a4 "429": *a5 @@ -25134,8 +25558,8 @@ paths: schema: $ref: "#/components/schemas/UpdateSymptomEventEnvelope" "401": *a1 - "403": *a64 - "404": &a66 + "403": *a66 + "404": &a68 description: "`symptoms.event.notFound`: no occurrence with this id on the record." content: application/json: @@ -25162,8 +25586,8 @@ paths: schema: $ref: "#/components/schemas/DeleteSymptomEventEnvelope" "401": *a1 - "403": *a64 - "404": *a66 + "403": *a66 + "404": *a68 "422": *a4 "429": *a5 /api/day/index: @@ -25196,7 +25620,7 @@ paths: schema: $ref: "#/components/schemas/DayIndexEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/day/notable: @@ -25231,7 +25655,7 @@ paths: schema: $ref: "#/components/schemas/DayNotableWindowEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/day/{date}: @@ -25261,7 +25685,7 @@ paths: schema: $ref: "#/components/schemas/DayEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/timeline: @@ -25314,10 +25738,12 @@ paths: schema: $ref: "#/components/schemas/TimelineEnvelope" "401": *a1 - "403": &a67 + "403": &a69 description: >- - `module.disabled` with `meta.module = timeline`: the record has the timeline switched off (it is opt-in), or - the operator turned it off server-wide. + `meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: `module.disabled` with + `meta.module = timeline`: the record has the timeline switched off (it is opt-in), or the operator turned it + off server-wide. Refused: this request named a record the caller may not act on (`meta.errorCode` = `sharing.access.denied`). @@ -25351,7 +25777,7 @@ paths: schema: $ref: "#/components/schemas/TimelineReadinessEnvelope" "401": *a1 - "403": *a67 + "403": *a69 "422": *a4 "429": *a5 /api/life-events: @@ -25369,10 +25795,12 @@ paths: schema: $ref: "#/components/schemas/LifeEventListEnvelope" "401": *a1 - "403": &a68 + "403": &a70 description: >- - Owner-only in v1.42: no share reaches life events, not a `profile` share, a whole-record share or MANAGE, - and not a guardian acting for a managed profile. + `meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: Owner-only in v1.42: no share + reaches life events, not a `profile` share, a whole-record share or MANAGE, and not a guardian acting for a + managed profile. Refused: the request was made while the session is acting on another account (`meta.errorCode` = @@ -25409,10 +25837,10 @@ paths: schema: $ref: "#/components/schemas/CreateLifeEventEnvelope" "401": *a1 - "403": *a68 + "403": *a70 "409": *a7 "422": *a4 - "429": *a12 + "429": *a13 /api/life-events/{id}: patch: tags: @@ -25440,8 +25868,8 @@ paths: schema: $ref: "#/components/schemas/UpdateLifeEventEnvelope" "401": *a1 - "403": *a68 - "404": &a69 + "403": *a70 + "404": &a71 description: No such life event on this account. content: application/json: @@ -25468,8 +25896,8 @@ paths: schema: $ref: "#/components/schemas/DeleteLifeEventEnvelope" "401": *a1 - "403": *a68 - "404": *a69 + "403": *a70 + "404": *a71 "422": *a4 "429": *a5 /api/search: @@ -25515,7 +25943,7 @@ paths: schema: $ref: "#/components/schemas/SearchEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/auth/me/ai-usage: @@ -25540,6 +25968,7 @@ paths: schema: $ref: "#/components/schemas/PutAiUsageResponse" "401": *a1 + "403": *a9 "422": description: "`ai.usage.invalid` (body) or `ai.usage.aboveCeiling` (`meta.maxLevel` names the ceiling)." content: @@ -25582,8 +26011,9 @@ paths: "401": *a1 "403": description: >- - The opt-in `nutrients` module is off for this account: 403 `module.disabled` with `meta.module: - "nutrients"`. + `meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: The opt-in `nutrients` module is + off for this account: 403 `module.disabled` with `meta.module: "nutrients"`. Refused: this request named a record the caller may not act on (`meta.errorCode` = `sharing.access.denied`). @@ -25642,16 +26072,17 @@ paths: required: - file responses: - "202": *a37 + "202": *a39 "401": *a1 "403": - description: "The opt-in `nutrients` module is off for this account: 403 `module.disabled` with `meta.module: - \"nutrients\"`. The `supplementLabel` AI capability is unavailable for this record. `meta.capability` = - `supplementLabel` and `meta.reason` says which layer refused: the operator's switch - (`assistant.disabled.documentAi`, or `assistant.disabled.enabled` for the master switch), an owning module - or the person's own AI opt-out (`module.disabled` with `meta.module`), a delegate inside somebody else's - record (`ai.record.notPermitted`), or a missing consent receipt (`consent.ai.required`). Nothing was - generated, read from a stored model answer, or enqueued." + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: The opt-in `nutrients` module is + off for this account: 403 `module.disabled` with `meta.module: \"nutrients\"`. The `supplementLabel` AI + capability is unavailable for this record. `meta.capability` = `supplementLabel` and `meta.reason` says + which layer refused: the operator's switch (`assistant.disabled.documentAi`, or `assistant.disabled.enabled` + for the master switch), an owning module or the person's own AI opt-out (`module.disabled` with + `meta.module`), a delegate inside somebody else's record (`ai.record.notPermitted`), or a missing consent + receipt (`consent.ai.required`). Nothing was generated, read from a stored model answer, or enqueued." content: application/json: schema: @@ -25677,7 +26108,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 - "503": *a18 + "503": *a20 /api/anamnesis/life-circumstances: get: tags: @@ -25692,7 +26123,7 @@ paths: schema: $ref: "#/components/schemas/LifeCircumstancesEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "422": *a4 "429": *a5 /api/anamnesis/life-circumstances/ai-sections: @@ -25716,6 +26147,7 @@ paths: schema: $ref: "#/components/schemas/LifeAiSectionsEnvelope" "401": *a1 + "403": *a9 "409": description: "`about_me_conflict`." content: @@ -25745,6 +26177,7 @@ paths: schema: $ref: "#/components/schemas/LifeConfirmEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/anamnesis/audit-c: @@ -25779,7 +26212,7 @@ paths: schema: $ref: "#/components/schemas/AuditCCreatedEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "409": *a7 "422": *a4 "429": *a5 @@ -25803,7 +26236,7 @@ paths: schema: $ref: "#/components/schemas/AuditCRemovedEnvelope" "401": *a1 - "403": *a11 + "403": *a12 "404": description: "`anamnesis.auditC.notFound`." content: @@ -25834,7 +26267,7 @@ paths: schema: $ref: "#/components/schemas/DawarichConnectEnvelope" "401": *a1 - "403": *a19 + "403": *a21 "422": *a4 "429": *a5 /api/dawarich/status: @@ -25850,7 +26283,7 @@ paths: schema: $ref: "#/components/schemas/DawarichStatusEnvelope" "401": *a1 - "403": *a19 + "403": *a21 "422": *a4 "429": *a5 /api/dawarich/places: @@ -25874,7 +26307,7 @@ paths: schema: $ref: "#/components/schemas/DawarichPlacesEnvelope" "401": *a1 - "403": *a19 + "403": *a21 "409": description: "`dawarich.not_connected`." content: @@ -25912,7 +26345,7 @@ paths: schema: $ref: "#/components/schemas/DawarichSettingsEnvelope" "401": *a1 - "403": *a19 + "403": *a21 "409": description: "`dawarich.not_connected`." content: @@ -25944,7 +26377,7 @@ paths: schema: $ref: "#/components/schemas/DawarichProbeEnvelope" "401": *a1 - "403": *a19 + "403": *a21 "422": *a4 "429": *a5 "502": @@ -25980,7 +26413,7 @@ paths: schema: $ref: "#/components/schemas/DawarichSyncEnvelope" "401": *a1 - "403": *a19 + "403": *a21 "409": description: "`dawarich.not_connected`." content: @@ -26015,7 +26448,7 @@ paths: schema: $ref: "#/components/schemas/DawarichDisconnectEnvelope" "401": *a1 - "403": *a19 + "403": *a21 "422": *a4 "429": *a5 /api/environment/travel/suggestions: @@ -26033,7 +26466,7 @@ paths: schema: $ref: "#/components/schemas/TravelSuggestionsEnvelope" "401": *a1 - "403": *a19 + "403": *a21 "422": *a4 "429": *a5 /api/environment/travel/suggestions/{id}/dismiss: @@ -26056,7 +26489,7 @@ paths: schema: $ref: "#/components/schemas/TravelSuggestionDismissEnvelope" "401": *a1 - "403": *a19 + "403": *a21 "404": description: "`environment.travel_suggestion.not_found`." content: @@ -26093,6 +26526,7 @@ paths: schema: $ref: "#/components/schemas/MedicationLinkResponse" "401": *a1 + "403": *a9 "404": description: The medication was not found. content: @@ -26128,6 +26562,7 @@ paths: schema: $ref: "#/components/schemas/MedicationUnlinkResponse" "401": *a1 + "403": *a9 "404": description: The medication was not found. content: @@ -26164,6 +26599,7 @@ paths: schema: $ref: "#/components/schemas/LabResultLinkResponse" "401": *a1 + "403": *a9 "404": description: The lab result was not found. content: @@ -26199,6 +26635,7 @@ paths: schema: $ref: "#/components/schemas/LabResultUnlinkResponse" "401": *a1 + "403": *a9 "404": description: The lab result was not found. content: diff --git a/docs/contributing/testing.md b/docs/contributing/testing.md index fddd0c974..741528ed9 100644 --- a/docs/contributing/testing.md +++ b/docs/contributing/testing.md @@ -89,10 +89,12 @@ Every call is also a contract check. The status must be one the registry (`src/lib/openapi/routes/`) declares for that path and method, a JSON body must parse with the registered schema, and a key the schema does not declare fails the call: a client generated from the contract cannot see that field, so -nobody promised to keep it. The one generic answer accepted without its own -entry is the scope gate's 403 `auth.scope.insufficient`, which the shared 401 -documents for every authenticated operation. A test that calls an unregistered -route on purpose passes `contract: { skip: "" }`. +nobody promised to keep it. There is no generic answer the kit accepts on an +operation's behalf: the scope gate's 403 `auth.scope.insufficient` is declared +on every operation that passes the gate (`withScopeRefusal` in +`src/lib/openapi/routes/shared.ts`), so it is checked like any other status. A +test that calls an unregistered route on purpose passes +`contract: { skip: "" }`. `tests/integration/routes/kit-contract.test.ts` proves the check can fail. Assert what a person or a client would notice: the stored row, the response, diff --git a/src/lib/openapi/__tests__/gate-refusal-publication.test.ts b/src/lib/openapi/__tests__/gate-refusal-publication.test.ts new file mode 100644 index 000000000..3a70c7c04 --- /dev/null +++ b/src/lib/openapi/__tests__/gate-refusal-publication.test.ts @@ -0,0 +1,134 @@ +/** + * The generic auth gate's 403, declared on every operation that passes it. + * + * `requireAuth` answers 403 `auth.scope.insufficient` to a narrow Bearer token + * on a route that names no scope it holds, and `requireAdmin` answers 403 + * `auth.admin.required` to a session that is not an administrator's. Both run + * before a route's own body, so every operation behind them can send the + * status; `withScopeRefusal` declares it on the assembled table. These checks + * hold the published document to that, and hold the helper to not trampling + * the 403s operations already publish for reasons of their own. + */ +import { describe, expect, it } from "vitest"; + +import { openApiPaths } from "../routes"; +import { + adminRefusalResponse, + errorEnvelope, + scopeRefusalResponse, + stdResponses, + withScopeRefusal, +} from "../routes/shared"; + +const METHODS = ["get", "put", "post", "patch", "delete"] as const; + +interface Op { + security?: unknown[]; + responses?: Record; +} + +function operations(): Array<{ path: string; method: string; op: Op }> { + const out: Array<{ path: string; method: string; op: Op }> = []; + for (const [path, item] of Object.entries(openApiPaths)) { + for (const method of METHODS) { + const op = (item as Record)[method]; + if (op?.responses) out.push({ path, method, op }); + } + } + return out; +} + +const URL_CREDENTIAL_OR_OWN_GATE = new Set([ + "/api/c/{token}/unlock", + "/api/notifications/action/{token}", + "/api/ingest/medication", +]); + +describe("the gate's 403 in the published contract", () => { + it("is declared, naming its code, on every operation behind the gate", () => { + const behind = operations().filter( + ({ path, op }) => + op.responses?.["401"] && + !(Array.isArray(op.security) && op.security.length === 0) && + !URL_CREDENTIAL_OR_OWN_GATE.has(path), + ); + expect(behind.length).toBeGreaterThan(500); + + const silent = behind.filter(({ path, op }) => { + const code = path.startsWith("/api/admin/") + ? "auth.admin.required" + : "auth.scope.insufficient"; + return !op.responses?.["403"]?.description?.includes(code); + }); + expect(silent.map(({ path, method }) => `${method} ${path}`)).toEqual([]); + }); + + it("is not declared on an operation that takes no credential", () => { + const open = operations().filter( + ({ op }) => Array.isArray(op.security) && op.security.length === 0, + ); + expect(open.length).toBeGreaterThan(0); + const claimed = open.filter(({ op }) => + op.responses?.["403"]?.description?.includes("auth.scope.insufficient"), + ); + expect(claimed.map(({ path, method }) => `${method} ${path}`)).toEqual([]); + }); + + it("keeps an operation's own 403 and its wording", () => { + const calendar = ( + openApiPaths["/api/cycle/calendar"] as unknown as { + get: Op; + } + ).get; + const description = calendar.responses?.["403"]?.description ?? ""; + expect(description).toContain("cycle.disabled"); + expect(description).toContain("auth.scope.insufficient"); + }); +}); + +describe("withScopeRefusal", () => { + const ownForbidden = { + description: "Module off. `meta.errorCode` = `module.disabled`.", + content: { "application/json": { schema: errorEnvelope } }, + }; + const table = withScopeRefusal({ + "/api/a": { + get: { responses: { "200": {}, ...stdResponses } }, + post: { responses: { "200": {}, ...stdResponses, "403": ownForbidden } }, + }, + "/api/b": { + get: { responses: { "200": {}, "403": ownForbidden, ...stdResponses } }, + }, + "/api/admin/x": { get: { responses: { "200": {}, ...stdResponses } } }, + "/api/open": { + get: { security: [], responses: { "200": {}, ...stdResponses } }, + }, + "/api/no-auth-declared": { get: { responses: { "200": {} } } }, + }) as unknown as Record>; + + it("adds the shared response where an operation has none", () => { + expect(table["/api/a"].get.responses?.["403"]).toBe(scopeRefusalResponse); + expect(table["/api/admin/x"].get.responses?.["403"]).toBe( + adminRefusalResponse, + ); + }); + + it("prefixes an own 403, once per shared object, and leaves its text", () => { + const a = table["/api/a"].post.responses?.["403"]; + const b = table["/api/b"].get.responses?.["403"]; + expect(a).toBe(b); + expect(a?.description?.startsWith("`meta.errorCode` = `auth.scope")).toBe( + true, + ); + expect(a?.description?.endsWith(ownForbidden.description)).toBe(true); + // The input object is not mutated. + expect(ownForbidden.description).not.toContain("auth.scope"); + }); + + it("leaves an operation that takes no credential or declares no 401 alone", () => { + expect(table["/api/open"].get.responses?.["403"]).toBeUndefined(); + expect( + table["/api/no-auth-declared"].get.responses?.["403"], + ).toBeUndefined(); + }); +}); diff --git a/src/lib/openapi/routes/index.ts b/src/lib/openapi/routes/index.ts index e72f9655a..31da5dec6 100644 --- a/src/lib/openapi/routes/index.ts +++ b/src/lib/openapi/routes/index.ts @@ -96,9 +96,9 @@ import { settingsPaths } from "./settings"; import { symptomPaths } from "./symptoms"; import { syncPaths } from "./sync"; import { workoutPaths } from "./workouts"; -import { coachPrefsSchema } from "./shared"; +import { coachPrefsSchema, withScopeRefusal } from "./shared"; -export const openApiPaths: NonNullable = { +const registeredPaths: NonNullable = { ...cyclePaths, ...metaPaths, ...healthRecordPaths, @@ -214,6 +214,11 @@ export const openApiPaths: NonNullable = { ...retiredPaths, }; +// The generic auth gate's 403 is declared on the assembled table, not spread +// from `stdResponses`; `scopeRefusalResponse` in `./shared` says why. +export const openApiPaths: NonNullable = + withScopeRefusal(registeredPaths); + export const openApiComponents: NonNullable = { // Schemas listed here are forced into `components.schemas` even when no // route references them directly. `Medication` lives in this slot diff --git a/src/lib/openapi/routes/shared.ts b/src/lib/openapi/routes/shared.ts index 1e929b18e..8dfe8b32b 100644 --- a/src/lib/openapi/routes/shared.ts +++ b/src/lib/openapi/routes/shared.ts @@ -351,6 +351,128 @@ export const stdResponses = { }, }; +// ── The generic 403 ────────────────────────────────────────────────── + +/** + * The 403 the generic auth gates answer before a route's own body runs. + * + * `requireAuth` refuses a narrow Bearer token on a route that names no scope + * the token holds (`auth.scope.insufficient`), and `requireAdmin` refuses a + * session that is not an administrator's (`auth.admin.required`). The shared + * 401 described both in its prose and no operation declared the status, so a + * decoder generated from the contract had no typed branch for a refusal any + * authenticated route can send. + * + * Neither is a member of `stdResponses`, on purpose. That set is spread into + * several hundred operations, about three hundred of them declare a 403 of + * their own, and most spread the set AFTER their own entries — a shared 403 + * in it would silently overwrite every module-off and sharing refusal in the + * document. `withScopeRefusal` adds them to the assembled table instead, where + * the order of a spread cannot decide it. + */ +export const scopeRefusalResponse = { + description: + "The credential is valid but its scope does not reach this operation. `meta.errorCode` = `auth.scope.insufficient`: a narrow Bearer token on a route that names no scope it holds, or a connector-bound token on a write. Retrying with the same token will not help; use a session, or a token minted with the scope the route names.", + content: { "application/json": { schema: errorEnvelope } }, +}; + +/** + * The admin surfaces' variant. `requireAdmin` reads only the session cookie, + * so a Bearer caller never gets this far (it is a 401 there); the 403 is a + * signed-in account that is not an administrator. + */ +export const adminRefusalResponse = { + description: + "The session is valid but its account is not an administrator. `meta.errorCode` = `auth.admin.required`.", + content: { "application/json": { schema: errorEnvelope } }, +}; + +const GATE_SENTENCE = { + scope: + "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the generic auth gate answers it before the route's own checks run. Otherwise: ", + admin: + "`meta.errorCode` = `auth.admin.required` when the session's account is not an administrator; the admin gate answers it before the route's own checks run. Otherwise: ", +} as const; + +const GATE_CODE = { + scope: "auth.scope.insufficient", + admin: "auth.admin.required", +} as const; + +/** + * Surfaces that authenticate by a credential of their own and never pass the + * generic gate, so they cannot answer its 403: a URL credential (the + * clinician share link, the notification quick answer) or the external ingest + * token, which runs its own scope check and documents its own 403. + */ +const OUTSIDE_THE_GENERIC_GATE = new Set([ + "/api/c/{token}/unlock", + "/api/notifications/action/{token}", + "/api/ingest/medication", +]); + +/** + * Declare the gate's 403 on every operation that passes the generic auth + * gate: one that declares a 401 and does not opt out of authentication with + * `security: []`. Under `/api/admin/` the gate is `requireAdmin`, everywhere + * else `requireAuth`. An operation without a 403 of its own gets the shared + * response; one with its own keeps it, and its description is prefixed with + * the gate's code, because a client branching on that 403 has to tell a + * module-off refusal from a gate refusal. Prefixed, not appended: the sharing + * refusal has to stay the description's closing sentence, byte for byte + * (`openapi-sharing-denial.test.ts`). + * + * Response objects shared between operations stay shared (one augmented copy + * per original), so the emitted document keeps its anchors. + */ +export function withScopeRefusal>( + paths: T, +): T { + const augmented = { + scope: new WeakMap(), + admin: new WeakMap(), + }; + const out: Record = {}; + for (const [path, item] of Object.entries(paths)) { + if (!isPlainRecord(item) || OUTSIDE_THE_GENERIC_GATE.has(path)) { + out[path] = item; + continue; + } + const gate = path.startsWith("/api/admin/") ? "admin" : "scope"; + const nextItem: Record = { ...item }; + for (const method of ["get", "put", "post", "patch", "delete"] as const) { + const op = item[method]; + if (!isPlainRecord(op) || !isPlainRecord(op.responses)) continue; + if (Array.isArray(op.security) && op.security.length === 0) continue; + if (!op.responses["401"]) continue; + const own = op.responses["403"]; + let forbidden: unknown = + gate === "admin" ? adminRefusalResponse : scopeRefusalResponse; + if (isPlainRecord(own)) { + forbidden = augmented[gate].get(own); + if (!forbidden) { + const description = + typeof own.description === "string" ? own.description : ""; + forbidden = description.includes(GATE_CODE[gate]) + ? own + : { ...own, description: GATE_SENTENCE[gate] + description }; + augmented[gate].set(own, forbidden as object); + } + } + nextItem[method] = { + ...op, + responses: { ...op.responses, "403": forbidden }, + }; + } + out[path] = nextItem; + } + return out as T; +} + +function isPlainRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + /** * The 429 the shared single-record write bucket answers with. * diff --git a/tests/integration/routes/kit-contract.test.ts b/tests/integration/routes/kit-contract.test.ts index 99e0106e1..7359258c1 100644 --- a/tests/integration/routes/kit-contract.test.ts +++ b/tests/integration/routes/kit-contract.test.ts @@ -76,15 +76,17 @@ describe("callRoute's contract check", () => { ).rejects.toThrow(/answered 409, which the contract does not declare/); }); - it("refuses a 403 that is not the generic scope refusal", async () => { + it("refuses the scope gate's 403 on an operation that declares none", async () => { + // `/api/version` takes no credential, so no 403 is declared on it; the + // generic refusal is accepted only where the contract says so. await expect( callRoute( answering(403, { data: null, - error: "nope", - meta: { errorCode: "http.forbidden" }, + error: "Insufficient scope", + meta: { errorCode: "auth.scope.insufficient" }, }), - { method: "GET", route: "/api/tokens", auth: anonymous }, + { method: "GET", route: "/api/version", auth: anonymous }, ), ).rejects.toThrow(/answered 403/); }); diff --git a/tests/kit/call-route.ts b/tests/kit/call-route.ts index 1634c3e87..d4ac7f0b6 100644 --- a/tests/kit/call-route.ts +++ b/tests/kit/call-route.ts @@ -207,20 +207,10 @@ interface ResponseObject { $ref?: string; } -/** - * The generic scope gate answers 403 `auth.scope.insufficient` on any route a - * narrow Bearer token does not reach. The registry documents that refusal in - * the prose of the shared 401 (`stdResponses`) rather than as a 403 entry on - * every operation, so an operation that declares the shared 401 is taken to - * declare this 403 as well, and nothing else is. - */ -const SCOPE_REFUSAL_CODE = "auth.scope.insufficient"; - function lookupResponse( method: HttpMethod, route: string, status: number, - body: unknown, ): ResponseObject { const pathItem = (openApiPaths as Record)[route]; if (!isRecord(pathItem)) { @@ -235,17 +225,7 @@ function lookupResponse( ); } const responses = isRecord(operation.responses) ? operation.responses : {}; - let declared = responses[String(status)] ?? responses.default; - if ( - !isRecord(declared) && - status === 403 && - isRecord(responses["401"]) && - isRecord(body) && - isRecord(body.meta) && - body.meta.errorCode === SCOPE_REFUSAL_CODE - ) { - declared = responses["401"]; - } + const declared = responses[String(status)] ?? responses.default; if (!isRecord(declared)) { throw new ContractViolation( `${method} ${route} answered ${status}, which the contract does not declare (declared: ${Object.keys(responses).join(", ")}).`, @@ -274,7 +254,7 @@ function checkContract( body: unknown, isJson: boolean, ): void { - const declared = lookupResponse(method, route, status, body); + const declared = lookupResponse(method, route, status); const content = declared.content ?? {}; const mediaTypes = Object.keys(content); if (!isJson) {