openapi: 3.1.0 info: title: HealthLog API version: 1.46.0 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. ## Date-time contract Every field marked `format: date-time` is an ISO-8601 instant. The schema pattern accepts both `Z` (UTC) and `±HH:MM` (offset) suffixes; clients must accept either. Server responses since v1.4.25 emit timestamps with the requesting user's display-timezone offset when a user context exists (exports, doctor-report PDF). Background-job-authored payloads (backups, admin tools) emit `Z`. Native iOS clients should always parse via a Foundation ISO-8601 formatter with the `withInternetDateTime` + `withTimeZone` options enabled. license: name: PolyForm-Noncommercial-1.0.0 url: https://github.com/MBombeck/HealthLog/blob/main/LICENSE contact: name: HealthLog url: https://healthlog.dev servers: - url: https://healthlog.example.com description: Your self-hosted instance - url: http://localhost:3000 description: Local dev tags: - name: Auth - name: Measurements - name: Medications - name: Mood - name: Analytics - name: Insights - name: Consent - name: Dashboard - name: Notifications - name: Devices - name: Export - name: Sync - name: Cycle - name: MeasurementReminders - name: Labs - name: Illness - name: Documents - name: Records - name: Admin - name: Meta - name: Retired description: Paths that were published and have been removed. They answer 410 Gone with `meta.errorCode` = `route.retired`, the removing version, and the replacement path where there is one. Listed so a client generated from this contract learns the path is gone from the contract rather than from a 404 in production. security: - bearerAuth: [] - cookieAuth: [] paths: /api/cycle/symptoms/custom: get: tags: - Cycle summary: List the record's custom cycle symptoms (v1.15.1) description: "Returns the active custom symptoms in `sortOrder`, labels decrypted, so the log-day sheet can merge them into the seeded chip grid. Deactivated symptoms are excluded. Gated: `cycle.disabled` 403 when cycle tracking is off for the RECORD. Delegable at READ level over the `cycle` section. Cookie or Bearer auth." responses: "200": description: The active custom symptoms (possibly empty). content: application/json: schema: $ref: "#/components/schemas/CycleCustomSymptomListEnvelope" "401": &a1 description: "Authentication required or invalid credentials. The generic auth gates — the ones every route passes through before its own body runs — name the reason in `meta.errorCode`, and that is the field to branch on there; the English sentence beside it may be reworded or localised at any time. `auth.missing`: no session cookie and no Bearer header; send the user to sign in. `auth.token.expired`: the Bearer token's lifetime has passed; refresh and retry the request. `auth.token.invalid`: unknown, revoked, or the owning account is gone; discard the credential and sign in again — retrying will not help. `auth.mfa.code_invalid`: a second factor presented on a step-up flow did not verify; re-prompt for a code and keep the session. The related 403 refusals carry `auth.scope.insufficient` (the credential is valid but its scope does not reach this route, including any Bearer token against an admin surface) and `auth.admin.required` (a cookie session that is not an admin's). A route that checks a credential of its own — sign-in, the MFA challenge exchange, a password confirmation — may answer 401 with the generic `http.unauthenticated`, which means the route refused the credential it was handed. A session that could not be checked because the session store was unreachable is NOT a 401: it answers 503 with `auth.session.unavailable`, and the client keeps its credential and retries." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "403": &a2 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. 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: $ref: "#/components/schemas/ErrorEnvelope" "422": &a4 description: Request validation failed. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": &a5 description: Rate limit exceeded. The response carries `Retry-After` (whole seconds, rounded up and never below 1 — wait at least that long before retrying), `X-RateLimit-Limit` (the bucket's cap), `X-RateLimit-Remaining` and `X-RateLimit-Reset` (the reset instant, ISO-8601, not epoch seconds). Back off on `Retry-After` rather than guessing. A 429 raised by a ceiling that is not one of these buckets — a daily AI budget, an upstream provider's own refusal relayed onward — carries none of them, because there is no bucket to describe. headers: &a8 Retry-After: description: Whole seconds to wait before retrying. Rounded up, never below 1. schema: type: integer minimum: 1 X-RateLimit-Limit: description: Requests the bucket allows per window. schema: type: integer minimum: 1 X-RateLimit-Remaining: description: Requests left in the current window; 0 on a refusal. schema: type: integer minimum: 0 X-RateLimit-Reset: description: Instant the current window rolls over, ISO-8601 — not epoch seconds. schema: type: string format: date-time content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" post: tags: - Cycle summary: Create a custom cycle symptom (v1.15.1) description: "Mints a `custom:` key, encrypts the label at rest, and stores the row under the global `custom` category owned by the record. Returns 201 with the created symptom, the submitted label echoed back in plaintext. Capped at 50 active custom symptoms per record. Rate-limited 30/min keyed on the ACTOR, so a delegate burns their own allowance rather than locking the owner out and cannot collect a fresh one by switching records. Audits as `cycle.symptom.custom.create` with the icon only — never the label. Delegable at MANAGE level over the `cycle` section: the record's own symptom vocabulary is what the day-log writes need in order to say anything." parameters: - &a6 name: Idempotency-Key in: header required: false schema: type: string pattern: ^[A-Za-z0-9_\-:.]{8,128}$ minLength: 8 maxLength: 128 description: "Makes this write safe to retry. Send the same key with the same body again and the first response is replayed rather than the work repeated, marked with `X-Idempotent-Replay: true`. The body is compared as JSON with key order ignored; a different body under the same key is not a retry and runs as a new write, while the first response stays stored for retries of the first request. A key that does not match the pattern is IGNORED rather than refused — the write then proceeds unprotected and looks exactly like a request that carried no key, so validate the key on your side. Scoped per user and per (method, path): the same key on a different route is a different key. While a first request under this key is still in flight, a second with the same body is refused with 409 rather than queued." requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CreateCycleCustomSymptomRequest" responses: "201": description: The created custom symptom. content: application/json: schema: $ref: "#/components/schemas/CycleCustomSymptomCreatedEnvelope" "401": *a1 "403": *a2 "409": &a7 description: "A request with this `Idempotency-Key` is already in progress. Nothing was written by this call. Retry after a short delay; the response carries `X-Idempotent-Replay: false` to separate it from a replay." headers: X-Idempotent-Replay: required: true description: "`true` when this response was replayed from a stored one rather than produced now; `false` on the 409 that refuses a duplicate still in flight. Absent when the request carried no usable key." schema: type: string enum: - "true" - "false" description: "`true` when this response was replayed from a stored one rather than produced now; `false` on the 409 that refuses a duplicate still in flight. Absent when the request carried no usable key." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": description: Either the body failed validation — the multi-issue envelope with `meta.errorCode` = `cycle.symptom.custom.invalid` (an empty or over-40-character label, an icon outside the allow-list, a `categoryKey` other than `custom`) — or the record already holds 50 active custom symptoms, in which case `meta.errorCode` = `cycle.symptom.custom.limit` and there is no issue list. Branch on the code, not on the presence of issues. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": description: More than 30 creates in the trailing minute for this ACTOR (`cycle:symptom:custom:`). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/cycle/symptoms/custom/{key}: patch: tags: - Cycle summary: Rename, re-icon or hide a custom cycle symptom (v1.15.1) description: "Edits one custom symptom the CALLER owns. The key must carry the `custom:` prefix — a catalogue key is refused as not-found before anything is read — and it is resolved against the caller's own rows only, so another account's key is a 404 and never a 403. A re-activation re-checks the 50-symptom cap, because a hidden row does not count toward it and skipping the check would make hide-then-enable a way around the limit. The audit row records which FIELDS the write touched and the resulting active state, never the decrypted label. Rate-limited 30/min. Gated: `cycle.disabled` 403 when cycle tracking is off. NOT delegable — unlike the collection this hangs off, both verbs here resolve the caller as themselves, so a delegate can create a symptom in a shared record and cannot then rename or hide it. Cookie or Bearer auth." parameters: - in: path name: key schema: type: string description: The `custom:` key. A catalogue key 404s. required: true description: The `custom:` key. A catalogue key 404s. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpdateCycleCustomSymptomRequest" responses: "200": description: The edited symptom, with the stored label decrypted and the resulting `isActive` — a field the create response does not carry. content: application/json: schema: $ref: "#/components/schemas/CycleCustomSymptomUpdatedEnvelope" "401": *a1 "403": &a3 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: $ref: "#/components/schemas/ErrorEnvelope" "404": description: The key is not a `custom:` key, or names no symptom this account owns. The two are indistinguishable. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": description: Either the body failed validation — the multi-issue envelope with `meta.errorCode` = `cycle.symptom.custom.invalid` — or re-activating would exceed the 50 active custom symptoms, in which case `meta.errorCode` = `cycle.symptom.custom.limit` and there is no issue list. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": description: More than 30 edits or deletes in the trailing minute for this account (`cycle:symptom:custom:`, shared with the DELETE below — and note this bucket keys on the USER, where the create on the collection keys on the ACTOR). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" delete: tags: - Cycle summary: Hide or purge a custom cycle symptom (v1.15.1) description: "Two different operations behind one verb, chosen by `purge`. By DEFAULT this is not a delete at all: the row is deactivated, so it leaves the chip grid while every day log that recorded it keeps its reference and its history reads unchanged. With `purge=true` the row is HARD-deleted and the foreign-key cascade takes every `cycle_symptom_link` with it — the days that recorded the symptom lose that fact, and nothing reconstructs it. There is no confirmation step on the wire and no undo. The response reports which of the two happened in `purged`. The key must carry the `custom:` prefix and resolves against the caller's own rows only. The audit row records the mode and nothing else — never the decrypted label. Rate-limited 30/min. Gated: `cycle.disabled` 403. NOT delegable. Cookie or Bearer auth." parameters: - in: path name: key schema: type: string description: The `custom:` key. A catalogue key 404s. required: true description: The `custom:` key. A catalogue key 404s. - in: query name: purge schema: description: Send exactly `true` for the hard delete with its cascade. Any other value, and omitting it, deactivates instead — the test is an equality against the string, not a boolean parse. type: string const: "true" description: Send exactly `true` for the hard delete with its cascade. Any other value, and omitting it, deactivates instead — the test is an equality against the string, not a boolean parse. responses: "200": description: "Done. `purged: true` means the row and its day-log links are gone; `false` means it was deactivated and its history is intact." content: application/json: schema: $ref: "#/components/schemas/CycleCustomSymptomDeletedEnvelope" "401": *a1 "403": *a3 "404": description: The key is not a `custom:` key, or names no symptom this account owns. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": description: More than 30 edits or deletes in the trailing minute for this account. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/cycle/day-logs: get: tags: - Cycle summary: Read a single day's cycle day-log (v1.15.0) description: Returns the full `CycleDayLogDTO` for the tz-anchored `date`, or `null` when nothing is logged that day. Lets a client pre-fill an edit sheet. Gated; owner-scoped; soft-deleted rows excluded. parameters: - in: query name: date schema: type: string pattern: ^\d{4}-\d{2}-\d{2}$ required: true responses: "200": description: The day-log for that date, or null. content: application/json: schema: $ref: "#/components/schemas/CycleDayLogReadEnvelope" "401": *a1 "403": *a2 "422": *a4 "429": *a5 post: tags: - Cycle summary: Capture a single cycle day-log (v1.15.0) description: "Upserts on `(userId, source, externalId)` when externalId present, else `(userId, date)`. `note` encrypts at rest. 201 on insert, 200 on update. Gated: `cycle.disabled` 403 when the feature is off." parameters: - *a6 requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CycleDayLogInput" responses: "200": description: Existing day-log updated. content: application/json: schema: $ref: "#/components/schemas/CycleDayLogEnvelope" "201": description: New day-log created. content: application/json: schema: $ref: "#/components/schemas/CycleDayLogCreatedEnvelope" "401": *a1 "403": *a2 "409": *a7 "422": *a4 "429": &a16 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 bucket (the bulk ingest endpoints and the model-turn chats) and a POST that writes nothing stay outside it. If the bucket cannot be read, the write goes ahead unmetered rather than failing. Nothing was written. `meta.errorCode` is `record_write.rate_limited`, which is what tells this refusal apart from a route's own narrower bucket when both can answer 429. The `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` headers describe that bucket; back off on `Retry-After` rather than guessing. Every request counts against the bucket, refused ones included: the check runs before the body is read, so a client looping on a 422 spends the same allowance as one that writes rows. A client with more than a handful of rows to send should use the batch endpoint for its domain instead of looping this one." headers: *a8 content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/cycle/day-logs/{id}: patch: tags: - Cycle summary: Edit a single cycle day-log (v1.15.0) description: Partial edit; an omitted field is left untouched. Owner-scoped (a cross-user id 404s). Gated. parameters: - in: path name: id schema: type: string required: true requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CycleDayLogPatchRequest" responses: "200": description: Day-log updated. content: application/json: schema: $ref: "#/components/schemas/CycleDayLogPatchEnvelope" "401": *a1 "403": *a2 "404": description: Day-log not found / not owned. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 delete: tags: - Cycle summary: Soft-delete a cycle day-log (v1.15.0) description: "Sets `deletedAt` + bumps `syncVersion`; surfaces as a tombstone on the next `/api/sync/changes` page. A day that opened a cycle takes that cycle with it, which gives back any start that cycle had folded in. 204, or 200 with `CycleDeleteOutcome` under `Prefer: return=representation`. Idempotent." parameters: - in: path name: id schema: type: string required: true - name: Prefer in: header required: false schema: type: string example: return=representation description: "v1.43 — send `return=representation` (RFC 7240) to get 200 with what the delete changed (`CycleDeleteOutcome`) and a `Preference-Applied: return=representation` header. Without it the route answers 204 with no body, as before." responses: "200": description: "v1.43 — with `Prefer: return=representation`: the removed cycle (null when the delete removed none, e.g. a day that opened no cycle or a repeated delete) and the starts the removal gave back." headers: Preference-Applied: required: true description: "The route honoured `Prefer: return=representation`." schema: type: string const: return=representation description: "The route honoured `Prefer: return=representation`." content: application/json: schema: $ref: "#/components/schemas/CycleDeleteOutcomeEnvelope" "204": description: Soft-deleted (no body). "401": *a1 "403": *a2 "404": description: Day-log not found / not owned. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/cycle/day-logs/bulk: post: tags: - Cycle summary: Bulk drain cycle day-logs (Outbox / HealthKit) (v1.15.0) description: "Up to 500 entries; `withIdempotency`; rate-limited `cycle:day-logs:bulk:` 60/min. Per-entry status: inserted | duplicate | updated | skipped. Always 200." parameters: - *a6 requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CycleDayLogBulkRequest" responses: "200": description: Batch processed (per-entry results). content: application/json: schema: $ref: "#/components/schemas/CycleBulkEnvelope" "401": *a1 "403": *a3 "409": *a7 "422": description: "The whole batch was refused and nothing was written: more than 500 entries (`meta.errorCode` = `cycle.bulk.too_large`, no issue list), or the body failed validation (`meta.errorCode` = `cycle.bulk.invalid`, every issue under `details.issues`). Both are permanent for the batch as sent. A problem with one entry never refuses the batch; it comes back as that entry's `skipped` status." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 /api/cycle/period: post: tags: - Cycle summary: Period-boundary shortcut (v1.15.0) description: One-tap started/ended period. `start` opens a new cycle (closing the prior); `end` stamps periodEndDate. Writes the boundary day-log. Gated. parameters: - *a6 requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CyclePeriodRequest" responses: "200": description: Cycle + boundary day-log. content: application/json: schema: $ref: "#/components/schemas/CyclePeriodEnvelope" "401": *a1 "403": *a2 "409": *a7 "422": *a4 "429": *a5 /api/cycle/calendar: get: tags: - Cycle summary: Predicted cycle calendar (v1.15.0) description: "Runs the deterministic engine to build `{ profile, prediction, days }`. Fertile-window fields are server-suppressed unless goal is TRYING_TO_CONCEIVE. Default range: today − 90d … +180d. Gated." parameters: - in: query name: from schema: type: string - in: query name: to schema: type: string responses: "200": description: Calendar grid + forecast. content: application/json: schema: $ref: "#/components/schemas/CycleCalendarEnvelope" "401": *a1 "403": *a2 "422": *a4 "429": *a5 /api/cycle/cycles: get: tags: - Cycle summary: Cycle history + stats (v1.15.0) description: Most-recent cycles (newest first) + `{ avgLengthDays, lengthVariabilityDays (MAD), avgPeriodLengthDays, regularity }`. `limit` default 24. Gated. parameters: - in: query name: limit schema: type: integer minimum: 1 maximum: 60 responses: "200": description: Cycle history. content: application/json: schema: $ref: "#/components/schemas/CycleHistoryEnvelope" "401": *a1 "403": *a2 "422": *a4 "429": *a5 /api/cycle/insights: get: tags: - Cycle summary: Cycle-phase correlation insights (v1.15.0) description: "FDR-guarded luteal-vs-follicular phase contrast per outcome metric (RHR / HRV / sleep / steps / weight / temperatures), plus the single headline finding (resting-heart-rate-by-phase, falling back to HRV). The same Welch t-test + Benjamini-Hochberg machinery the mood-factor crosstab runs; only rows with p < 0.05 AND q ≤ 0.10 surface. Strictly gender-gated — phase never appears on the general `/api/insights/correlations` route. Observational only, never causal. Gated: `cycle.disabled` 403 when the feature is off. Declared for the `cycle` section; the outcome columns are fenced per section at the query, so a grant scoped to the cycle alone receives the phase rows with no `lutealAvg` / `follicularAvg` / `delta` for measurement or mood outcomes and no lagged discovery, exactly as a record with nothing logged would read. The owner and a whole-record grant are unchanged." responses: "200": description: Phase-correlation rows + headline. content: application/json: schema: $ref: "#/components/schemas/CycleInsightsEnvelope" "401": *a1 "403": *a2 "422": *a4 "429": *a5 /api/cycle/cycles/{id}: delete: tags: - Cycle summary: Soft-delete a menstrual cycle (v1.15.0) description: "Sets `deletedAt` + bumps `syncVersion`; tombstones on the next sync page. Any start this cycle had folded in stands again. 204, or 200 with `CycleDeleteOutcome` under `Prefer: return=representation`. Idempotent." parameters: - in: path name: id schema: type: string required: true - name: Prefer in: header required: false schema: type: string example: return=representation description: "v1.43 — send `return=representation` (RFC 7240) to get 200 with what the delete changed (`CycleDeleteOutcome`) and a `Preference-Applied: return=representation` header. Without it the route answers 204 with no body, as before." responses: "200": description: "v1.43 — with `Prefer: return=representation`: the removed cycle (null when the delete removed none, e.g. a day that opened no cycle or a repeated delete) and the starts the removal gave back." headers: Preference-Applied: required: true description: "The route honoured `Prefer: return=representation`." schema: type: string const: return=representation description: "The route honoured `Prefer: return=representation`." content: application/json: schema: $ref: "#/components/schemas/CycleDeleteOutcomeEnvelope" "204": description: Soft-deleted (no body). "401": *a1 "403": *a2 "404": description: Cycle not found / not owned. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/cycle/all: delete: tags: - Cycle summary: Hard-purge all cycle data (v1.15.0) description: "One-click privacy purge: HARD-deletes every cycle day-log (+ symptom links by cascade), menstrual cycle, prediction, the cycle audit trail, and the cycle reminder rows in the push-attempts ledger — no dated reproductive trace survives. The CycleProfile row is left in place. Gated + owner-scoped + audited." responses: "200": description: Purge counts. content: application/json: schema: $ref: "#/components/schemas/CyclePurgeEnvelope" "401": *a1 "403": *a3 "422": *a4 "429": *a5 /api/cycle/profile: get: tags: - Cycle summary: Read the full cycle profile (v1.15.0) description: Returns the resolved CycleProfileDTO. Gated. responses: "200": description: Cycle profile. content: application/json: schema: $ref: "#/components/schemas/CycleProfileEnvelope" "401": *a1 "403": *a2 "422": *a4 "429": *a5 /api/auth/me/cycle-prefs: get: tags: - Cycle summary: Read cycle preferences (v1.15.0) description: Returns the resolved CycleProfileDTO. NOT gated — this is the surface that flips the gate. responses: "200": description: Cycle preferences. content: application/json: 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: tags: - Cycle summary: Update cycle preferences (v1.15.0) description: Deep-merges the supplied fields. `enabled` flips `cycleTrackingEnabled`. Returns the merged CycleProfileDTO. NOT gated. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CyclePrefsRequest" responses: "200": description: Merged cycle preferences. content: application/json: schema: $ref: "#/components/schemas/CyclePrefsPatchEnvelope" "400": &a18 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. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/measurement-categories: get: tags: - Meta summary: Measurement categorisation overlay description: "Returns the UI-side measurement categorisation as an HTTP contract: the ordered category list (id + i18n label key + order) plus the MeasurementType → category assignments. iOS reads this on cold start to drive the HealthKit permission picker (one consent screen per category) and caches it client-side (`Cache-Control: public, max-age=600`). Auth via cookie or Bearer (not admin); no PII." responses: "200": description: Categorisation overlay. content: application/json: schema: $ref: "#/components/schemas/MeasurementCategoriesEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/meta/capabilities: get: tags: - Meta summary: Live server capability / id-vocabulary discovery description: Returns the server's REAL id vocabularies (derived-metric ids, vitals-baseline types, layout tile-ids, metric-status ids, the HealthKit ingest mapping, the FHIR coding constants) plus the running API contract version. Every list is derived server-side from the canonical registry it documents, so a client can gate its UI / decoder against what the server actually ships rather than a hand-maintained copy. Auth via cookie or Bearer (not admin). responses: "200": description: Capability snapshot. content: application/json: schema: $ref: "#/components/schemas/CapabilitiesEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/version: get: security: [] tags: - Meta summary: The running build's version, and how it was built description: >- Public — no session, no token, no account data. This is the endpoint a deploy is verified against: after pushing a new image, read `version` plus `buildSha` on every target, because the `:latest` pull is the source of truth and a queued deploy status is not. `buildSha` and `builtAt` are baked at image build time and are null for a local development run, which is how a client tells the two apart. `offlineGeoEnabled` and `geoProviderHost` describe where login-IP geolocation resolves: with the GeoLite2 databases present nothing leaves the host, and without them the named provider sees every login address. responses: "200": description: Build identity and offline-geolocation state. content: application/json: schema: $ref: "#/components/schemas/VersionEnvelope" /api/feature-flags: get: tags: - Meta summary: The operator's assistant switches (deprecated) deprecated: true description: >- Deprecated: read `ai` on GET /api/auth/me instead, which resolves each AI capability for the record from the switches, the record's modules, the provider, consent and who is asking. This route projects the operator switches alone and is removed in the first release after the native build that reads `ai` ships. Responses carry `Deprecation` (the date it was deprecated, as `@`), `Sunset` (the first day it may stop answering) and `Link: ; rel="successor-version"`. The master is applied server-side, so every sub-switch is already false when it is off. An ACTOR surface: it answers about the deployment, not about a record, so it keeps answering while the caller is acting on somebody else's — the Coach launcher and the assistant chrome are gated on it, and a refusal here would delete a piece of the shell rather than a piece of the data. A request that attaches the per-request account selector (the `AccountSelector` header parameter) is refused with 403 `sharing.not_permitted` rather than quietly answered. Served with `Cache-Control: private, max-age=60`: an operator's toggle propagates within a minute, and the flag read stays off the hot mount path in the meantime. responses: "200": description: The resolved assistant matrix. headers: Deprecation: description: "When this route was deprecated, as an RFC 9745 date: `@` and Unix seconds." schema: type: string pattern: ^@[0-9]+$ Sunset: description: The first day this route may stop answering (RFC 8594 HTTP-date). schema: type: string Link: description: The successor, as `; rel="successor-version"`. schema: type: string content: application/json: schema: $ref: "#/components/schemas/AssistantFlagsEnvelope" "401": *a1 "403": 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: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/version/check-updates: get: tags: - Meta summary: Check whether a newer release exists description: >- A server-side proxy for the project's public release feed. It exists because the production CSP allows almost nothing on `connect-src`, so a browser calling the feed directly is silently blocked — which is exactly how the first version of the “check for updates” button appeared to do nothing. Authenticated (cookie or a wildcard Bearer token): there is no reason an anonymous caller should poll a third-party API through this instance. No credential is forwarded to the feed, so the request runs under the host's anonymous quota — roughly sixty an hour per address, shared by everyone on the instance. It is deliberately hard to fail. A network error, an outage, or a rate-limited feed all return 200 with `status: "unknown"` and a machine-readable `reason`, so the UI can offer a retry instead of a red banner nobody can act on. The one genuine error is a feed that answers successfully with no version tag in it. Nothing is cached server-side; the client is expected to hold the result. An operator who sets `UPDATE_CHECK_DISABLED` turns the outbound request off: the route then answers `status: "unknown"` with `reason: "disabled"` and contacts nobody. responses: "200": description: Up to date, an update is available, or the check could not be completed. content: application/json: schema: $ref: "#/components/schemas/UpdateCheckEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 "502": description: 'The release feed answered but carried no version tag. Distinct from `status: "unknown"`, which covers not reaching it at all.' content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/export/health-record: post: tags: - Export summary: Generate a health-record export (PDF / FHIR / package) description: "Returns the doctor-handover artefact in the requested `format`: `pdf` → application/pdf, `fhir` → application/fhir+json (HL7 FHIR R4 document Bundle), `package` → application/zip (PDF + FHIR + README). `selection` is REQUIRED and carries the leaf inclusion list — membership is inclusion, absence is exclusion, and there is no server-side default. An unknown leaf id is a 422 that names it; the retired grouped `sections` shape is a 422 too, because accepting it would mean choosing a scope the caller did not express. Auth via cookie or Bearer; shared `export:` rate bucket (10/h). Strict validation: unknown keys 422. A browser session needs a fresh proof as well as a live one: a sign-in or `POST /api/auth/reproof` within the last five minutes (with a second factor, on an account that has one). Without it the answer is 401 `auth.reproof.required`, with `meta.methods` naming the proofs the account can give. When a manager exports a record they manage, it is their own session that is asked. A Bearer caller is not asked for more than its token." requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/HealthRecordExportRequest" responses: "200": description: "Export generated. Content-Type varies by `format`: application/pdf, application/fhir+json, or application/zip." content: application/pdf: schema: type: string format: binary application/fhir+json: schema: type: string format: binary application/zip: schema: type: string format: binary "401": *a1 "403": &a15 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: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/share-links: post: tags: - Export summary: Create a clinician share link (v1.11.0) description: "Owner-only. Mints an `hls_` token (192-bit), stores only its HMAC hash, and returns the raw token EXACTLY ONCE in the response, together with a passphrase second factor and a `qrUrl` carrying that passphrase in the URL fragment; a leaked URL without the passphrase cannot open the record. The window and the `selection` are frozen write-once — there is no widen path. `selection` omitted means an empty scope, not a default one, and the `INSURANCE` leaf is refused with a 422 because the share view never decrypts the insurance number. An optional `documentIds` array (bounded ≤50) is validated as the caller's own live documents and frozen onto the link; a documents-only share (no report scope, non-empty `documentIds`) is valid. `expiresAt` is required and capped at 90 days. Auth via cookie or Bearer; rate-limited (`share-link:`, 20/h). Strict: unknown keys 422. A browser session needs a fresh proof as well as a live one: a sign-in or `POST /api/auth/reproof` within the last five minutes (with a second factor, on an account that has one). Without it the answer is 401 `auth.reproof.required`, with `meta.methods` naming the proofs the account can give. A Bearer caller is not asked for more than its token yet." requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CreateShareLinkRequest" responses: "201": description: Share link created. `token` carries the raw `hls_` value and is unrecoverable after this response. content: application/json: schema: $ref: "#/components/schemas/ShareLinkCreated" "401": *a1 "403": *a9 "422": *a4 "429": *a5 get: tags: - Export summary: List own clinician share links (v1.11.0) description: Owner-only. Returns the caller's own share links (never the raw token — it is unrecoverable after creation). Auth via cookie or Bearer. responses: "200": description: Share links owned by the caller. content: application/json: schema: $ref: "#/components/schemas/ShareLinkList" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/share-links/{id}: delete: tags: - Export summary: Revoke a clinician share link (v1.11.0) description: Owner-only. Sets `revokedAt` on the caller's own link. A cross-user or unknown id is sealed as 404. Auth via cookie or Bearer; rate-limited. parameters: - in: path name: id schema: type: string required: true responses: "200": description: Link revoked. content: application/json: schema: $ref: "#/components/schemas/ShareLinkRevoked" "401": *a1 "403": *a9 "404": description: Link not found (or owned by another user). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/c/{token}/unlock: post: tags: - Export summary: Verify a share-link passphrase (v1.18.7, public) description: Anonymous, no-session passphrase gate for a protected share link. The raw path token plus the submitted passphrase are the only credentials. On success the server sets a short-lived (30 min), token-scoped, httpOnly cookie so `/c/` renders the record. Rate-limited (`share-unlock::`); every failure class (bad token, no passphrase set, wrong passphrase) answers one blunt 401. parameters: - in: path name: token schema: type: string required: true requestBody: required: true content: application/json: schema: type: object properties: passphrase: type: string minLength: 1 maxLength: 64 required: - passphrase additionalProperties: false responses: "200": description: Passphrase accepted; short-lived token-scoped unlock cookie set. content: application/json: schema: $ref: "#/components/schemas/ShareLinkUnlock" "401": description: Invalid passphrase (blunt — no failure detail leaked). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /c/{token}/d/{id}: get: tags: - Export summary: Serve a shared document blob (v1.28, public) description: "Anonymous, no-session share-scoped document serve route. The raw `hls_` share token in the path is the ONLY credential — never a session; it never elevates a normal authed route. Serves ONLY a document id present in the link's frozen `documentIds` set (a guessed / foreign / soft-deleted id → flat 404). A passphrase-protected link additionally requires the short-lived, token-scoped unlock cookie; revocation and expiry collapse to the same flat 404. The serving-class posture is identical to the owner `/api/documents/inbound/{id}/original` route: Class A (PDF/JPEG/PNG/WebP/GIF) inline with its true type, everything else `application/octet-stream` + `Content-Disposition: attachment` + `X-Content-Type-Options: nosniff`; `Cache-Control: private, no-store`. Images (JPEG/PNG/WebP) are EXIF/XMP/GPS-stripped at egress (the stored original is untouched); PDF/TIFF/HEIC/Office metadata passes through. Per-link rate-limited." parameters: - in: path name: token schema: type: string description: Raw `hls_` share token. required: true description: Raw `hls_` share token. - in: path name: id schema: type: string description: A document id on the link's frozen set. required: true description: A document id on the link's frozen set. responses: "200": description: The document bytes. Content-Type is the true stored type for Class A (inline) or `application/octet-stream` for Class B (attachment). content: application/octet-stream: schema: type: string format: binary "404": description: Flat 404 for every miss class (unknown/revoked/expired token, locked passphrase gate, or an id not on the frozen set). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": description: Per-link serve rate limit exceeded. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /c/{token}/report.pdf: get: tags: - Export summary: Download a share link's record as the clinical PDF (public) description: "Anonymous, no-session download of exactly the record the page at `/c/{token}` renders — the same frozen selection, resolved through the same `selectionFromStoredBlob`, so the file can never be wider than the page above it. The raw `hls_` share token in the path is the ONLY credential; a passphrase-protected link additionally requires the short-lived, token-scoped unlock cookie the browser already holds after unlocking. Unknown / revoked / expired token, a locked gate, and a documents-only link (which carries no record at all) all collapse to the same flat 404. Charts are embedded. Dates print in the OWNER's timezone and clock preference, not the reader's, so a practice west of the record's own zone files a document dated the way the page it came from was. The insurance number is never carried: the `INSURANCE` leaf is refused at share-link creation. Rate-limited per link at 20/h — its own bucket, because generating a report is far more expensive than serving a stored blob." parameters: - in: path name: token schema: type: string description: Raw `hls_` share token. required: true description: Raw `hls_` share token. responses: "200": description: "The report as `application/pdf`, `Content-Disposition: attachment`." content: application/pdf: schema: type: string format: binary "404": description: Flat 404 for every miss class (unknown / revoked / expired token, locked passphrase gate, or a documents-only link with no record behind it). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": description: Per-link report-download rate limit exceeded (20/h). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /c/{token}/fhir: get: tags: - Export summary: Download a share link's record as an HL7 FHIR R4 Bundle (public) description: "The machine-readable twin of `/c/{token}/report.pdf`: the same gate, the same frozen selection, the same flat 404 for every miss class, the same 20/h per-link bucket. Returns an HL7 FHIR R4 **document** Bundle as a download. This is a DOWNLOAD, not a FHIR REST face — a share token authenticates nothing beyond these three routes and `/c/{token}/d/{id}`, and it never becomes a Bearer credential against `/api/fhir/*`. Allergies and family history are deliberately absent from the bundle: they reach the owner's own export through a read this surface does not perform, so the bundle carries the aggregated payload the page renders and nothing beside it. No insurance number, for the same structural reason as the PDF." parameters: - in: path name: token schema: type: string description: Raw `hls_` share token. required: true description: Raw `hls_` share token. responses: "200": description: "FHIR R4 document Bundle (`application/fhir+json`), `Content-Disposition: attachment`." content: application/fhir+json: schema: type: string format: binary "404": description: Flat 404 for every miss class (unknown / revoked / expired token, locked passphrase gate, or a documents-only link with no record behind it). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": description: Per-link report-download rate limit exceeded (20/h). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/fhir/metadata: get: tags: - FHIR summary: FHIR R4 CapabilityStatement (v1.11.0) description: "Read-only FHIR R4 capability statement for the REST face. Declares the served resource types (Patient, Coverage, Observation, MedicationStatement, MedicationAdministration), their `read` and `search-type` interactions, the search parameters (`_count`, `_offset`; `date` and `code` on Observation), the `Patient/$everything` operation, and the `application/fhir+json` format. Every advertised interaction has a route. Auth: `fhir:read` scope (cookie sessions also pass)." responses: "200": description: CapabilityStatement (application/fhir+json). content: application/fhir+json: schema: type: string format: binary "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/fhir/Patient: get: tags: - FHIR summary: FHIR R4 Patient search (v1.11.0) description: "Read-only `searchset` Bundle of the caller's own Patient resource. Auth: `fhir:read` scope. Offset paging via `_count` (clamped ≤200) / `_offset`. `userId` is narrowed from auth." parameters: - in: query name: _count schema: type: number - in: query name: _offset schema: description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome. type: number description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome. responses: "200": description: searchset Bundle (application/fhir+json). content: application/fhir+json: schema: type: string format: binary "400": &a10 description: A search parameter the server cannot honour, such as an `_offset` above 9007199254740991. The body is an `OperationOutcome` (`issue[0].code` = `invalid`, the reason in `diagnostics`) served as `application/fhir+json`. content: application/fhir+json: schema: type: string format: binary "401": *a1 "403": &a11 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: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": &a12 description: "Rate limit exceeded: the per-account `fhir:` bucket, 120 requests per hour, shared by every `/api/fhir/*` data route. The body is an `OperationOutcome` (`issue[0].code` = `throttled`) served as `application/fhir+json`, not the JSON error envelope. The `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` headers describe the bucket; back off on `Retry-After`." headers: *a8 content: application/fhir+json: schema: type: string format: binary /api/fhir/Observation: get: tags: - FHIR summary: FHIR R4 Observation search (v1.11.0) description: "Read-only `searchset` Bundle of the caller's own Observations (vitals / activity / lab / survey). Auth: `fhir:read` scope. Offset paging via `_count` (clamped ≤200) / `_offset`. Without `date` the answer is the snapshot it has always been: one latest reading per type plus the derived panels, ids `obs-N`. With `date` (v1.44, repeatable, FHIR prefixes eq/gt/ge/lt/le, a value without an offset read as UTC) the answer is the time series instead: every reading in the range plus workouts, newest first, paged in the database, ids `m-` / `w-` that read back through `/api/fhir/Observation/{id}`. The series follows the same saved report selection as the snapshot, leaves sleep out (stored as stage segments), pairs a systolic and diastolic recorded at the same instant into the blood-pressure panel, and codes a workout as an `activity` Observation (LOINC 73985-4 Exercise activity, duration 55411-3 and energy 41981-2 as components). `code` (repeatable, `system|code` or a bare code, comma-separated) narrows either answer; an unknown code matches nothing. A malformed `date` is a 400 OperationOutcome." parameters: - in: query name: _count schema: type: number - in: query name: _offset schema: description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome. type: number description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome. - in: query name: date schema: type: string - in: query name: code schema: type: string responses: "200": description: searchset Bundle (application/fhir+json). content: application/fhir+json: schema: type: string format: binary "400": *a10 "401": *a1 "403": *a11 "422": *a4 "429": *a12 /api/fhir/MedicationStatement: get: tags: - FHIR summary: FHIR R4 MedicationStatement search (v1.11.0) description: "Read-only `searchset` Bundle of the caller's own active-medication statements. Auth: `fhir:read` scope. Offset paging via `_count` (≤200) / `_offset`." parameters: - in: query name: _count schema: type: number - in: query name: _offset schema: description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome. type: number description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome. responses: "200": description: searchset Bundle (application/fhir+json). content: application/fhir+json: schema: type: string format: binary "400": *a10 "401": *a1 "403": *a11 "422": *a4 "429": *a12 /api/fhir/MedicationAdministration: get: tags: - FHIR summary: FHIR R4 MedicationAdministration search (v1.11.0) description: "Read-only `searchset` Bundle of the caller's own acted intakes (completed / not-done). Auth: `fhir:read` scope. Offset paging via `_count` (≤200) / `_offset`." parameters: - in: query name: _count schema: type: number - in: query name: _offset schema: description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome. type: number description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome. responses: "200": description: searchset Bundle (application/fhir+json). content: application/fhir+json: schema: type: string format: binary "400": *a10 "401": *a1 "403": *a11 "422": *a4 "429": *a12 /api/fhir/Coverage: get: tags: - FHIR summary: FHIR R4 Coverage search description: "Read-only `searchset` Bundle of the caller's own Coverage resource, when an insurer is recorded and the insurance leaf is in the saved selection. Auth: `fhir:read` scope. Offset paging via `_count` (clamped ≤200) / `_offset`. `userId` is narrowed from auth." parameters: - in: query name: _count schema: type: number - in: query name: _offset schema: description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome. type: number description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome. responses: "200": description: searchset Bundle (application/fhir+json). content: application/fhir+json: schema: type: string format: binary "400": *a10 "401": *a1 "403": *a11 "422": *a4 "429": *a12 /api/fhir/Patient/{id}: get: tags: - FHIR summary: FHIR R4 Patient read description: "Read-only `Patient` by id, out of the caller's own record. An id belonging to someone else is indistinguishable from one that never existed — both answer a `not-found` OperationOutcome. Auth: `fhir:read` scope; `userId` is narrowed from auth." parameters: - in: path name: id schema: type: string required: true responses: "200": description: The resource (application/fhir+json). content: application/fhir+json: schema: type: string format: binary "401": *a1 "403": *a11 "404": description: OperationOutcome (application/fhir+json). content: application/fhir+json: schema: type: string format: binary "422": *a4 "429": *a12 /api/fhir/Coverage/{id}: get: tags: - FHIR summary: FHIR R4 Coverage read description: "Read-only `Coverage` by id, out of the caller's own record. An id belonging to someone else is indistinguishable from one that never existed — both answer a `not-found` OperationOutcome. Auth: `fhir:read` scope; `userId` is narrowed from auth." parameters: - in: path name: id schema: type: string required: true responses: "200": description: The resource (application/fhir+json). content: application/fhir+json: schema: type: string format: binary "401": *a1 "403": *a11 "404": description: OperationOutcome (application/fhir+json). content: application/fhir+json: schema: type: string format: binary "422": *a4 "429": *a12 /api/fhir/Observation/{id}: get: tags: - FHIR summary: FHIR R4 Observation read description: "Read-only `Observation` by id, out of the caller's own record: a snapshot id (`obs-N`) or a series id (`m-` / `w-`, v1.44), the latter read inside the same saved selection the series search applies. An id belonging to someone else is indistinguishable from one that never existed — both answer a `not-found` OperationOutcome. Auth: `fhir:read` scope; `userId` is narrowed from auth." parameters: - in: path name: id schema: type: string required: true responses: "200": description: The resource (application/fhir+json). content: application/fhir+json: schema: type: string format: binary "401": *a1 "403": *a11 "404": description: OperationOutcome (application/fhir+json). content: application/fhir+json: schema: type: string format: binary "422": *a4 "429": *a12 /api/fhir/MedicationStatement/{id}: get: tags: - FHIR summary: FHIR R4 MedicationStatement read description: "Read-only `MedicationStatement` by id, out of the caller's own record. An id belonging to someone else is indistinguishable from one that never existed — both answer a `not-found` OperationOutcome. Auth: `fhir:read` scope; `userId` is narrowed from auth." parameters: - in: path name: id schema: type: string required: true responses: "200": description: The resource (application/fhir+json). content: application/fhir+json: schema: type: string format: binary "401": *a1 "403": *a11 "404": description: OperationOutcome (application/fhir+json). content: application/fhir+json: schema: type: string format: binary "422": *a4 "429": *a12 /api/fhir/MedicationAdministration/{id}: get: tags: - FHIR summary: FHIR R4 MedicationAdministration read description: "Read-only `MedicationAdministration` by id, out of the caller's own record. An id belonging to someone else is indistinguishable from one that never existed — both answer a `not-found` OperationOutcome. Auth: `fhir:read` scope; `userId` is narrowed from auth." parameters: - in: path name: id schema: type: string required: true responses: "200": description: The resource (application/fhir+json). content: application/fhir+json: schema: type: string format: binary "401": *a1 "403": *a11 "404": description: OperationOutcome (application/fhir+json). content: application/fhir+json: schema: type: string format: binary "422": *a4 "429": *a12 /api/fhir/Patient/$everything: get: tags: - FHIR summary: FHIR R4 $everything (v1.11.0) description: "Read-only `Patient/$everything`: every resource the document bundle carries — Composition, Device, Patient, Coverage, Observations (including the cycle ones), MedicationStatements, MedicationAdministrations, Conditions, Encounters, AllergyIntolerances, FamilyMemberHistories and the DiagnosticReport (present when the window holds a vital sign) — flattened into one `searchset` Bundle. Scoped to the owner's SAVED report selection; an account that never saved one gets nothing. Auth: `fhir:read` scope. Offset paging via `_count` (≤200) / `_offset`. A browser session needs a fresh proof as well as a live one: a sign-in or `POST /api/auth/reproof` within the last five minutes (with a second factor, on an account that has one). Without it the answer is 401 `auth.reproof.required`, with `meta.methods` naming the proofs the account can give. A token, including a `fhir:read` one, is not asked for more." parameters: - in: query name: _count schema: type: number - in: query name: _offset schema: description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome. type: number description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome. responses: "200": description: searchset Bundle (application/fhir+json). content: application/fhir+json: schema: type: string format: binary "400": *a10 "401": *a1 "403": *a11 "422": *a4 "429": *a12 /api/export: get: tags: - Export summary: Download measurements, medications, intake and mood description: "The plaintext download. Returns `application/json` or `text/csv` (never the standard response envelope) with a `Content-Disposition: attachment` filename of `healthlog-export-.`. Measurements stream page by page, so a multi-year account does not buffer its whole history in memory; the other sections are assembled in full first. The CSV variant concatenates the selected sections under `# Measurements` / `# Medications` / `# Intake Events` / `# Mood Entries` headings, so it is a CONCATENATION of tables rather than one table — a single-table CSV parser will choke on it. Mood notes are decrypted into the download; measurement notes ride their exported column. Shares the `export:` bucket with the encrypted variant (10 per hour) and audits every attempt as `export.download`. Cookie or Bearer auth; the caller is always resolved as themselves, so this download cannot be delegated to a shared record. Not module-gated: a disabled module's rows are still exported, because this is the account's own data leaving on request. `type=all`, the default, is the whole record and asks for a fresh proof like the full backup; the single-type exports do not. A browser session needs a fresh proof as well as a live one: a sign-in or `POST /api/auth/reproof` within the last five minutes (with a second factor, on an account that has one). Without it the answer is 401 `auth.reproof.required`, with `meta.methods` naming the proofs the account can give. A Bearer caller asking for `type=all` presents an `X-Step-Up` elevation from `POST /api/auth/step-up` (a second-factor one on an account with a second factor) and receives 401 `auth.stepup.required` without it." parameters: - in: query name: format schema: description: Serialisation. Omitted means `json`. Anything else is refused with 422 — the value is NOT clamped to the default. type: string enum: - csv - json description: Serialisation. Omitted means `json`. Anything else is refused with 422 — the value is NOT clamped to the default. - in: query name: type schema: description: Which sections to include. Omitted means `all`. Anything else is refused with 422. type: string enum: - measurements - medications - intake - mood - all description: Which sections to include. Omitted means `all`. Anything else is refused with 422. responses: "200": description: The export. `Content-Type` follows `format`; the body is `ExportJsonBody` for JSON and a multi-section CSV document for CSV. content: application/json: schema: $ref: "#/components/schemas/ExportJsonBody" text/csv: 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 single-message error envelope and NOT the multi-issue 422 the rest of the surface returns." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": description: More than 10 exports in the trailing hour for this account (`export:`, shared with POST /api/export/encrypted). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/export/full-backup: get: tags: - Export summary: Download the restore-compatible full backup description: "The disaster-recovery-shaped JSON dump, in its PORTABLE form. It matches the canonical backup payload byte for byte with the weekly worker's file, so a user can hand this to an operator and the admin restore accepts it without conversion. Two things the shape cannot state: this route ALWAYS produces the portable mode — it calls the builder with no options, so there is no parameter that reaches the disaster-recovery mode, which is the worker and admin path alone — and the file is NOT the standard response envelope but the raw payload, because the restore expects the payload and not `{ data: … }`. Encrypted columns arrive decrypted, so the file is as sensitive as the record it came from; the `manifest` key inside it declares what was deliberately left out. `Content-Disposition: attachment` with `healthlog-backup--.json`, `Cache-Control: no-store`. Shares the `export:` bucket (10 per hour) with every other export route. Audits as `user.export.full-backup` with the row counts. Cookie or Bearer auth; the caller is always resolved as themselves, so this download cannot be delegated to a shared record. A browser session needs a fresh proof as well as a live one: a sign-in or `POST /api/auth/reproof` within the last five minutes (with a second factor, on an account that has one). Without it the answer is 401 `auth.reproof.required`, with `meta.methods` naming the proofs the account can give. A Bearer caller presents an `X-Step-Up` elevation from `POST /api/auth/step-up` (a second-factor one on an account with a second factor) and receives 401 `auth.stepup.required` without it." responses: "200": description: The backup payload. `application/json; charset=utf-8`, served as an attachment. content: application/json: 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). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/export/measurements: get: tags: - Export summary: Download measurements as CSV description: "Streams the account's measurements as a single-table CSV, page by page, so a multi-year history never buffers whole. Soft-deleted rows are excluded, so an undone entry or a pending-sync tombstone never reaches the file. Sleep is night-aggregated by default; `granularity=raw` emits the per-stage rows instead. Glucose is written in the account's display unit rather than the canonical storage unit — which is the one way this file differs from the mixed `/api/export` download, whose measurement section pins mg/dL. `since` and `until` are FORGIVING: an unparseable date is dropped from the filter rather than refused, so a typo widens the export silently instead of erroring. Errors from authentication and the first page are returned normally; a failure after the first page arrives mid-stream, with a 200 already sent. `Content-Disposition: attachment` with `healthlog-measurements--.csv`. Shares the `export:` bucket (10 per hour). Audits as `user.export.measurements`. Cookie or Bearer auth; not delegable." parameters: - in: query name: since schema: description: Inclusive lower bound. Parsed with the plain Date constructor, so an ISO date (`2026-07-14`) works as well as a full instant — and an UNPARSEABLE value is silently DROPPED from the filter rather than refused, which widens the export instead of erroring. Validate on your side. type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ description: Inclusive lower bound. Parsed with the plain Date constructor, so an ISO date (`2026-07-14`) works as well as a full instant — and an UNPARSEABLE value is silently DROPPED from the filter rather than refused, which widens the export instead of erroring. Validate on your side. - in: query name: until schema: description: Inclusive upper bound, with the same forgiving parse as `since`. type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ description: Inclusive upper bound, with the same forgiving parse as `since`. - in: query name: granularity schema: description: Send `raw` for one row per stored sample, per-sleep-stage rows included. Anything else — including a misspelling — falls through to the default night aggregation rather than 422-ing. type: string const: raw description: Send `raw` for one row per stored sample, per-sleep-stage rows included. Anything else — including a misspelling — falls through to the default night aggregation rather than 422-ing. responses: "200": description: "The CSV. `text/csv; charset=utf-8`, served as an attachment, `Cache-Control: no-store`." content: text/csv: schema: type: string "401": *a1 "403": *a9 "429": description: More than 10 exports in the trailing hour for this account. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/export/medications: get: tags: - Export summary: Download medications (and their intake history) as CSV description: "The medication list, plus the intake history unless it is switched off. The two ride one file as SEPARATE tables under `# Medications` and `# Intake history` headings, so a single-table CSV parser will choke on it. `medicationId` scopes both sections to one medication — the id is always narrowed by the account, so a foreign or unknown id resolves to no medication and answers 404 rather than an empty file. `since` / `until` bound the intake section only, on `scheduledFor`, and are FORGIVING: an unparseable date is dropped from the filter rather than refused. Tombstoned intake rows are excluded. Note the default: `intake` is on unless the caller sends exactly `false`, so any other value — including `0` or `no` — leaves the history in. `Content-Disposition: attachment` with `healthlog-medications--.csv`. Shares the `export:` bucket (10 per hour). Audits as `user.export.medications`. Cookie or Bearer auth; not delegable." parameters: - in: query name: intake schema: description: Send exactly `false` to omit the intake-history section. Any other value, and omitting it, leaves the history IN — the test is an equality against the string, not a boolean parse. type: string const: "false" description: Send exactly `false` to omit the intake-history section. Any other value, and omitting it, leaves the history IN — the test is an equality against the string, not a boolean parse. - in: query name: medicationId schema: description: Scope both sections to one medication. Always narrowed by the account, so an id this account does not hold answers 404. type: string description: Scope both sections to one medication. Always narrowed by the account, so an id this account does not hold answers 404. - in: query name: since schema: description: Inclusive lower bound. Parsed with the plain Date constructor, so an ISO date (`2026-07-14`) works as well as a full instant — and an UNPARSEABLE value is silently DROPPED from the filter rather than refused, which widens the export instead of erroring. Validate on your side. type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ description: Inclusive lower bound. Parsed with the plain Date constructor, so an ISO date (`2026-07-14`) works as well as a full instant — and an UNPARSEABLE value is silently DROPPED from the filter rather than refused, which widens the export instead of erroring. Validate on your side. - in: query name: until schema: description: Inclusive upper bound, with the same forgiving parse as `since`. type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ description: Inclusive upper bound, with the same forgiving parse as `since`. responses: "200": description: "The CSV. `text/csv; charset=utf-8`, served as an attachment, `Cache-Control: no-store`." content: text/csv: 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." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": description: More than 10 exports in the trailing hour for this account. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/export/mood: get: tags: - Export summary: Download mood entries as CSV description: "The account's mood entries as a single-table CSV, newest first, with the note DECRYPTED into the file — the whole point of a portable export, and the reason the file is as sensitive as the entries themselves. Tombstoned rows are excluded. `since` / `until` bound `moodLoggedAt` and are FORGIVING: an unparseable date is dropped from the filter rather than refused. Assembled in full before it is sent, unlike the measurement download. `Content-Disposition: attachment` with `healthlog-mood--.csv`. Shares the `export:` bucket (10 per hour). Audits as `user.export.mood`. NOT module-gated — a switched-off mood module still exports its entries, because this is the account's own data leaving on request. Cookie or Bearer auth; not delegable." parameters: - in: query name: since schema: description: Inclusive lower bound. Parsed with the plain Date constructor, so an ISO date (`2026-07-14`) works as well as a full instant — and an UNPARSEABLE value is silently DROPPED from the filter rather than refused, which widens the export instead of erroring. Validate on your side. type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ description: Inclusive lower bound. Parsed with the plain Date constructor, so an ISO date (`2026-07-14`) works as well as a full instant — and an UNPARSEABLE value is silently DROPPED from the filter rather than refused, which widens the export instead of erroring. Validate on your side. - in: query name: until schema: description: Inclusive upper bound, with the same forgiving parse as `since`. type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ description: Inclusive upper bound, with the same forgiving parse as `since`. responses: "200": description: "The CSV. `text/csv; charset=utf-8`, served as an attachment, `Cache-Control: no-store`." content: text/csv: schema: type: string "401": *a1 "403": *a9 "429": description: More than 10 exports in the trailing hour for this account. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/export/encrypted: post: tags: - Export summary: Download the full backup as a passphrase-encrypted archive description: "v1.23. Same restore-compatible payload as the plaintext full backup, sealed into an `HLX1` archive (Argon2id-derived key + AES-256-GCM) under the supplied `passphrase`. Returns `application/octet-stream` (a `.hlx` file). Asks for a fresh proof, like the plaintext full backup. A browser session needs a fresh proof as well as a live one: a sign-in or `POST /api/auth/reproof` within the last five minutes (with a second factor, on an account that has one). Without it the answer is 401 `auth.reproof.required`, with `meta.methods` naming the proofs the account can give. On Bearer, an account without a second factor passes on its token, and one with a second factor presents a second-factor `X-Step-Up` elevation (401 `auth.stepup.required` without it). Shared `export:` rate bucket (10/h). The passphrase is never stored — a forgotten passphrase makes the archive unrecoverable." requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/EncryptedExportRequest" responses: "200": description: Encrypted archive. `application/octet-stream`; the body is the `HLX1` binary (magic | version | Argon2 params | salt | iv | tag | ciphertext). content: application/octet-stream: schema: type: string format: binary "401": *a1 "403": 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 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` = `sharing.not_permitted`). This operation resolves the CALLER and never a named record, so under a switch it refuses instead of quietly answering with the caller's own — leave the shared record first. No grant at any level opens it. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/auth/claim/sso: post: security: [] tags: - Auth summary: Claim a managed profile through single sign-on (v1.43) description: "Web only. Checks the link and the username, sets a sealed `hl_claim` cookie (10 minutes, `httpOnly`) and answers where to continue: `/api/auth/oidc/login?intent=claim`. The OIDC callback binds the identity provider's subject to the profile and signs the new owner in; a subject already bound to an account, or a verified email another account holds, is refused there. Works on a single-sign-on-only instance. When the callback refuses, the browser lands on `/auth/claim?error=` with reason `claim_expired` (the link was used, withdrawn, replaced or expired in the meantime, or the cookie is missing, expired or belongs to another start), `claim_sso_subject_bound`, `claim_email_taken`, `claim_username_taken` (taken during the round trip), `claim_signed_in`, or one of the sign-in reasons (`oidc_denied`, `oidc_state`, `oidc_failed`, `oidc_rate_limited`, `oidc_disabled`); on success it lands on `/onboarding/handover`, where the new owner decides each guardian's access. The cookie is cleared either way. 404 `profile_claim.invalid` (no usable link) or `profile_claim.sso_unavailable` (no identity provider configured); 409 `profile_claim.taken` (username) or `auth.already_authenticated`; rate-limited per source address like the password claim." requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/ProfileClaimSsoRequest" responses: "200": description: Continue at `redirectTo`. content: application/json: schema: $ref: "#/components/schemas/ProfileClaimSsoEnvelope" "401": *a1 "404": description: "`profile_claim.invalid` or `profile_claim.sso_unavailable`." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "409": description: "`profile_claim.taken` or `auth.already_authenticated`." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/auth/claim/preview: post: security: [] tags: - Auth summary: Preview a managed-profile handover link description: "What a handover link would hand over: the profile's display name, the link's expiry, and each guardian's proposal. Anonymous; the token travels in the body. An unknown, expired, used or withdrawn token, and a link whose creator lost access, all answer the same 404. Rate-limited per source address. Since v1.43 answered on a single-sign-on-only instance too, with `methods` naming the ways in." requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/ProfileClaimPreviewRequest" responses: "200": description: The preview. content: application/json: schema: $ref: "#/components/schemas/ProfileClaimPreviewEnvelope" "401": *a1 "404": description: "No usable link behind this token (`meta.errorCode: profile_claim.invalid`). Every failure class answers identically." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "409": description: "The request carries a signed-in session (`meta.errorCode: auth.already_authenticated`); sign out first." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/auth/claim: post: security: [] tags: - Auth summary: Claim a managed profile description: "Turns the managed profile behind a handover link into the claimant's own account, in one transaction: sets the username, email and password on the profile's record, clears the managed marker, withdraws pending invitations, applies each guardian's proposed access, and resets the disclaimer, onboarding and AI consent so the new owner gives them personally. The new owner then decides each guardian's access on first sign-in (`/api/account/handover-decision`). No data moves. Signs the new owner in. Anonymous; the token travels in the body, every token failure answers the same 404, and five attempts per fifteen minutes per source address are allowed." requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/ProfileClaimRequest" responses: "201": description: Claimed; the response sets the session cookie. content: application/json: schema: $ref: "#/components/schemas/ProfileClaimEnvelope" "401": *a1 "403": description: "The instance allows single sign-on only (`meta.errorCode: profile_claim.oidc_only_unsupported`)." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "404": description: "No usable link behind this token (`meta.errorCode: profile_claim.invalid`)." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "409": description: "The request carries a signed-in session (`meta.errorCode: auth.already_authenticated`), or the username or email is taken (`meta.errorCode: profile_claim.taken`)." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/auth/login: post: security: [] tags: - Auth summary: Email-or-username login (password) description: >- Browser callers receive a session cookie. Native callers (X-Client-Type: native or HealthLog-iOS UA prefix) additionally receive a paired access + refresh token. v1.23 — when the account has a confirmed second factor, the response carries no session/token. It returns HTTP 200 with `data: null, error: null` and `meta.mfaRequired: true` plus a single-use `meta.mfaTicket` and the `meta.methods` list. The client must POST the ticket + a code to `/api/auth/mfa/verify` to obtain the token bundle. Accounts without MFA are unchanged. Two limits apply. Per source address, five attempts in fifteen minutes. Per account, failed passwords from a place the account has not signed in from before make the next attempt wait: five failures a day are free, then each one doubles the wait, from thirty seconds up to at most fifteen minutes. A place the account has signed in from is not counted and not held back: a remembered browser (trusted-device cookie), a phone that sends the `X-Device-Id` a login of this account was issued to, or an address a session or device login of this account came from in the last thirty days. Both answer 429 with `Retry-After`. An identifier that names no account is limited the same way, so a 429 says nothing about whether it exists. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/LoginPasswordRequest" responses: "200": description: Login succeeded (token bundle / cookie) — or a second factor is required (`meta.mfaRequired`). content: application/json: schema: anyOf: - $ref: "#/components/schemas/LoginResponse" - $ref: "#/components/schemas/MfaRequiredResponse" "401": *a1 "403": description: >- Password login is disabled — the operator runs `OIDC_ONLY=true`. `meta.errorCode` = `oidc_only`; sign in through SSO instead. The one exception is a browser the SSO callback sent back with a pending link to confirm an existing account: it may sign in with that one account's password, which links the account. A sign-in to any other account on that browser is refused before its password is checked, so the answer is the same whether the password was right or wrong. Also 403 `auth.account.deletion_pending`, after the password was proved and before a second factor is asked for: the account is pending deletion and locked. `meta.purgeAt` is when it is erased; an admin can undo the deletion until then. Every other sign-in surface (passkey, second-factor completion, the native token exchanges) refuses with the same code. A session, refresh token or API token issued before the deletion is revoked by it, so those answer 401 (`auth.refresh.revoked` on refresh), the same as for a signed-out session. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "409": &a13 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 verified, no MFA ticket was minted or consumed, no code was spent. A request without `clientTransport` (the iOS app, scripts) is never refused this way.' content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/auth/mfa/verify: post: tags: - Auth summary: Complete a second-factor login challenge description: Presents the `mfaTicket` from the login `meta.mfaRequired` response plus a TOTP or recovery code. On success returns the SAME token bundle / session the password path issues, with the session marked second-factor-verified. The ticket is single-use; wrong codes are throttled and the ticket is burned at the attempt cap. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/MfaVerifyRequest" responses: "200": description: Second factor verified — session + optional bearer issued. content: application/json: schema: $ref: "#/components/schemas/MfaVerifyResponse" "401": *a1 "403": *a9 "409": *a13 "422": *a4 "429": *a5 /api/auth/oidc/native/token: post: tags: - Auth summary: Exchange a native OIDC handoff code for the token bundle description: >- The cookie-less native leg of the OIDC SSO flow. The iOS app opens `GET /api/auth/oidc/login?client=native&code_challenge=` inside an `ASWebAuthenticationSession`; the callback returns a one-time handoff code on `healthlog://oidc-callback?code=hlh_…` (or an `mfa_ticket` when the account has a second factor, completed at /api/auth/mfa/verify). This endpoint exchanges the code + its PKCE `codeVerifier` for the SAME native bundle password login issues. Requires the native transport (no cookie, non-browser UA) — a browser is rejected. The code is single-use and expires in ~90 seconds; a replay of a consumed code revokes the pair the first exchange issued. A single generic 401 covers every invalid/expired/used/PKCE-mismatch case. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/OidcNativeTokenRequest" responses: "200": description: Handoff accepted — native access + refresh bundle. content: application/json: schema: $ref: "#/components/schemas/OidcNativeTokenResponse" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/auth/native/login: get: tags: - Auth summary: Start the first-party web-handoff login (browser navigation) description: "First leg of the native web-handoff login (iOS #65). On a self-hosted domain the iOS app opens this URL inside an `ASWebAuthenticationSession` with its PKCE `code_challenge`, so the login runs in the instance's real web origin (fixing password autofill + passkeys). The endpoint validates the challenge, sets a short-lived encrypted state cookie carrying the challenge + a DB-clock start time, and 302-redirects to `/auth/login?flow=native`. It writes no database rows. Every error 302-redirects to `healthlog://login-callback?error=` (a closed set: `invalid_request`, `rate_limited`) — an error carries no code or session. Anonymous; rate-limited 10 / 15 min." parameters: - in: query name: code_challenge schema: type: string minLength: 43 maxLength: 128 description: The app's PKCE S256 challenge (RFC 7636). `plain` is unsupported. required: true description: The app's PKCE S256 challenge (RFC 7636). `plain` is unsupported. responses: "302": description: Redirect to `/auth/login?flow=native` (state cookie set) on success, or to `healthlog://login-callback?error=` on failure. /api/auth/native/complete: get: tags: - Auth summary: Complete the web-handoff login and mint the code (browser navigation) description: "Second leg of the native web-handoff login (iOS #65). After an interactive login on `/auth/login?flow=native`, the page navigates the browser here as a top-level GET. The endpoint validates the web session against the database, enforces the freshness binding (`session.createdAt >= startedAt`, both DB-clock — a pre-existing session is refused), mints a single-use PKCE-locked handoff code, deletes the state cookie, destroys the scaffold web session, and 302-redirects to `healthlog://login-callback?code=hlh_…`. The token pair never rides the URL; only the opaque code does. The app never calls this directly. Every failure 302-redirects to `healthlog://login-callback?error=` (`invalid_state`, `no_session`, `stale_session`, `rate_limited`). Rate-limited 20 / 15 min." responses: "302": description: Redirect to `healthlog://login-callback?code=hlh_…` on success, or `…?error=` on any failure. /api/auth/native/token: post: tags: - Auth summary: Exchange a web-handoff code for the token bundle description: >- Third leg of the native web-handoff login (iOS #65). The app exchanges the one-time handoff code from `healthlog://login-callback?code=hlh_…` plus its PKCE `codeVerifier` for the SAME native bundle password login issues. Requires the native transport (no cookie, non-browser UA) — a browser is rejected. The code is single-use and expires in ~90 seconds; a replay of a consumed code revokes the pair the first exchange issued. The exchange is flow-gated: a code minted by the OIDC native leg is not redeemable here (and vice versa). A single generic 401 covers every invalid / expired / used / PKCE-mismatch / cross-flow case. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/NativeHandoffTokenRequest" responses: "200": description: Handoff accepted — native access + refresh bundle. content: application/json: schema: $ref: "#/components/schemas/NativeHandoffTokenResponse" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/auth/step-up/options: post: tags: - Auth summary: Begin a WebAuthn re-proof for a step-up elevation (Bearer only) description: "Returns SimpleWebAuthn assertion options plus a challenge id to present at POST /api/auth/step-up. `method: \"passkey\"` scopes the assertion to the account's primary passkeys; `method: \"webauthn\"` to its registered second-factor security keys. Bearer-only: a cookie session is refused, because a browser re-proves its factor at login and carries the result on its session row. Returns 409 when the account has no credential of the requested kind — fall back to another arm of the mint." requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/StepUpOptionsRequest" responses: "200": description: Assertion options issued. content: application/json: schema: $ref: "#/components/schemas/StepUpOptionsEnvelope" "401": *a1 "403": *a9 "409": description: No passkey registered on this account. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/auth/step-up: post: tags: - Auth summary: Mint a single-use step-up elevation (Bearer only) description: >- Re-prove a factor and receive an opaque elevation that authorises exactly ONE second-factor-management call. WHICH factor you re-prove decides WHAT the elevation reaches. `password` reaches the same routes a plain cookie session reaches. `totp`, `webauthn`, and `passkey` additionally satisfy the fresh-factor routes — MFA disable, recovery-code regeneration, security-key removal — which is precisely the set of ceremonies for which the web marks a session second-factor-verified. The response carries `satisfiesFreshFactor` so a client can choose the right ceremony up front rather than discovering the refusal after spending a proof. A recovery code is NOT accepted here; an account that has lost its authenticator manages its second factor on the web. The elevation is bound to the exact token that minted it (another token, including the same account's, cannot redeem it), single-use, and valid for five minutes — the same window the cookie path uses. Present it as `X-Step-Up: hle_…` alongside the normal `Authorization: Bearer` header. It is spent only when the target route is about to act, so a 429, a 422, or a wrong code does not burn it. Presenting the token alone mints nothing: the body must carry a fresh factor proof. Every failure — wrong password, no password set on an SSO-provisioned account, an assertion for another account, a stale challenge, a replayed TOTP step — returns the same 401 with the same prose, and is audited server-side. Rate-limited per source address and per account (5 / 15 min); the per-account budget is shared with the web's credential-enrollment proofs. A `totp` proof counts only from an authenticator whose enrollment was confirmed: a code from a pending secret is refused like a wrong one. Accepting routes (the complete set): POST /api/auth/me/mfa/totp/setup; POST /api/auth/me/mfa/totp/confirm; POST /api/auth/me/mfa/disable; POST /api/auth/me/mfa/recovery-codes/regenerate; POST /api/auth/me/mfa/webauthn/register/options; POST /api/auth/me/mfa/webauthn/register/verify; PATCH and DELETE /api/auth/me/mfa/webauthn/{id}; DELETE /api/settings/account; DELETE /api/settings/data; GET /api/export/full-backup; POST /api/export/encrypted. The last three MFA routes, disable, and both erasure routes require a fresh-factor proof. The two whole-record exports take any elevation on an account without a second factor and a fresh-factor one on an account with one; the full backup requires an elevation on every Bearer call, the encrypted export only on an account with a second factor. GET /api/auth/me/mfa needs no elevation at all. Nothing else accepts one — admin endpoints stay cookie-only. The two erasure routes joined the set so an account with a second factor can be deleted from a native app: their gate was cookie-only, which left an enrolled user unable to delete their own account from the app at all. They take the elevation the same way as the rest — fresh factor required, spent only when the erasure is about to run. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/StepUpMintRequest" responses: "200": description: Factor re-proved — elevation issued. content: application/json: schema: $ref: "#/components/schemas/StepUpMintEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/auth/reproof: post: tags: - Auth summary: Re-prove a credential on a browser session (cookie only) description: >- Stamps the calling browser session as recently re-proved, so for the next five minutes it may run the actions that ask for a fresh proof: GET /api/export/full-backup, POST /api/export/encrypted, POST /api/share-links, POST /api/mcp/tokens, POST /api/tokens/measurements, POST /api/tokens/workouts, POST /api/tokens/documents, and the admin backup download, upload and restore, the admin data wipe and the admin password reset. Those answer 401 `auth.reproof.required` with `meta.methods` (the proofs this account can give) when the session signed in or re-proved more than five minutes ago. A `totp`, `webauthn` or `passkey` proof here also clears the routes that ask for a fresh second factor (the managed-profile acts, the second-factor and passkey changes, the password change, account deletion, the data wipe and key rotation): on a browser session those answer 401 `auth.stepup.required` with `meta.methods` naming the possession proofs the account holds, and the web re-proves here and retries. The body takes the same shapes as POST /api/auth/step-up. On an account with a second factor only `totp`, `webauthn` (a security key) or `passkey` are accepted, because a password has never stood in for the second factor; on one without, `password` or `passkey`. A method the account cannot use here is refused with 422 `auth.reproof.too_weak` and `meta.methods`, before any guess is spent. A second-factor or passkey proof also refreshes the session's second-factor stamp, exactly as signing in again would. Assertions begin at POST /api/auth/passkey/register-options with `{ method: "passkey" | "webauthn" }`. Proofs draw on the account's shared re-proof budget (five per fifteen minutes, 429 once spent). A proof that does not verify is 401 `auth.reproof.failed` and is audited. Bearer callers use POST /api/auth/step-up instead. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/StepUpMintRequest" responses: "200": description: Proof accepted; the session is stamped. content: application/json: schema: $ref: "#/components/schemas/ReproofEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 get: tags: - Auth summary: Ask whether this browser session's proof is recent description: "The question the gated routes ask, without acting: 200 `{ recent: true }` when the session signed in or re-proved within five minutes (a second factor on an account that has one), otherwise 401 `auth.reproof.required` with `meta.methods`. Lets a client re-prove before sending a large upload rather than after it was refused." responses: "200": description: The session's proof is recent. content: application/json: schema: $ref: "#/components/schemas/ReproofStatusEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/auth/me/mfa/totp/setup: post: tags: - Auth summary: Begin TOTP enrollment (cookie session or step-up elevation) description: >- Generates and stores a pending (encrypted) TOTP secret and returns the otpauth URI + Base32 secret. MFA is not active until /confirm, and a pending secret is not a factor anywhere: a code from it is refused by every route that verifies one. Accepts a cookie session, or a Bearer token presenting a single-use elevation from POST /api/auth/step-up in the `X-Step-Up` header; a Bearer token alone is still refused. Adding a factor needs a fresh proof, not just a session. On the Bearer path the elevation is that proof: a password-proved one is enough on an account without a second factor, and on an account that already has one the elevation must come from that factor or a passkey. On the cookie path, for an account without a second factor, the request passes when the session signed in, or completed a passkey sign-in, within the last five minutes; otherwise the body must carry a proof in the shape POST /api/auth/step-up takes (`password`, or a passkey assertion begun at POST /api/auth/passkey/register-options). For an account that already has a second factor, only that factor counts: a second factor completed within the last five minutes, or a `totp` code, security-key or passkey assertion in the body. A password or a young session (for example one that skipped the factor on a remembered browser) is not enough there. Without one the answer is 401 `auth.reproof.required` with `meta.methods` naming the proofs the account can give; a proof that does not verify is 401 `auth.reproof.failed` and is audited. Proofs draw on the account's shared re-proof budget of five per fifteen minutes (429 once spent), the same budget as the step-up mint. requestBody: required: false content: application/json: schema: $ref: "#/components/schemas/StepUpMintRequest" responses: "200": description: Pending secret created. content: application/json: schema: $ref: "#/components/schemas/TotpSetupEnvelope" "401": &a14 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: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/auth/me/mfa/totp/confirm: post: tags: - Auth summary: Confirm TOTP enrollment (cookie session or step-up elevation) description: Verifies a code against the pending secret, activates the factor, and returns the one-time recovery codes. Accepts a cookie session, or a Bearer token presenting a single-use elevation from POST /api/auth/step-up in the `X-Step-Up` header; a Bearer token alone is still refused. requestBody: required: true content: application/json: schema: type: object properties: code: type: string pattern: ^\d{6}$ required: - code responses: "200": description: Factor activated — recovery codes returned once. content: application/json: 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: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/auth/me/mfa/disable: post: tags: - Auth summary: Disable the second factor (step-up gated) description: Requires a fresh second-factor step-up AND a current TOTP or recovery code. Clears the secret and deletes recovery codes. Accepts a cookie session, or a Bearer token presenting a single-use elevation from POST /api/auth/step-up in the `X-Step-Up` header; a Bearer token alone is still refused. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/MfaDisableRequest" responses: "200": description: Factor disabled. content: application/json: schema: $ref: "#/components/schemas/MfaDisableEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/auth/me/mfa/recovery-codes/regenerate: post: tags: - Auth summary: Regenerate recovery codes (step-up gated) description: Invalidates the entire prior recovery-code set and returns a fresh batch once. Step-up gated. Accepts a cookie session, or a Bearer token presenting a single-use elevation from POST /api/auth/step-up in the `X-Step-Up` header; a Bearer token alone is still refused. responses: "200": description: Fresh recovery codes issued. content: application/json: schema: $ref: "#/components/schemas/MfaRecoveryRegenEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/auth/me/mfa: get: tags: - Auth summary: Second-factor status (cookie session or Bearer token) description: Whether TOTP is active, how many recovery codes remain, and the registered WebAuthn security keys. Metadata only — no secret, no code, no public key, no credential id. Plain authentication (cookie session or cookie-equivalent token); unlike every mutation on this surface it needs no step-up elevation, because the payload carries no credential material. responses: "200": description: Second-factor status. content: application/json: schema: $ref: "#/components/schemas/MfaStatusEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/auth/me/mfa/webauthn/register/options: post: tags: - Auth summary: Begin registering a security key as a second factor description: >- Returns SimpleWebAuthn creation options + a challenge id. Accepts a cookie session, or a Bearer token presenting a single-use elevation from POST /api/auth/step-up in the `X-Step-Up` header; a Bearer token alone is still refused. Adding a factor needs a fresh proof, not just a session. On the Bearer path the elevation is that proof: a password-proved one is enough on an account without a second factor, and on an account that already has one the elevation must come from that factor or a passkey. On the cookie path, for an account without a second factor, the request passes when the session signed in, or completed a passkey sign-in, within the last five minutes; otherwise the body must carry a proof in the shape POST /api/auth/step-up takes (`password`, or a passkey assertion begun at POST /api/auth/passkey/register-options). For an account that already has a second factor, only that factor counts: a second factor completed within the last five minutes, or a `totp` code, security-key or passkey assertion in the body. A password or a young session (for example one that skipped the factor on a remembered browser) is not enough there. Without one the answer is 401 `auth.reproof.required` with `meta.methods` naming the proofs the account can give; a proof that does not verify is 401 `auth.reproof.failed` and is audited. Proofs draw on the account's shared re-proof budget of five per fifteen minutes (429 once spent), the same budget as the step-up mint. requestBody: required: false content: application/json: schema: $ref: "#/components/schemas/StepUpMintRequest" responses: "200": description: Registration options issued. content: application/json: schema: $ref: "#/components/schemas/MfaWebauthnRegisterOptionsEnvelope" "401": *a14 "403": *a9 "422": *a4 "429": *a5 /api/auth/me/mfa/webauthn/register/verify: post: tags: - Auth summary: Finish registering a security key as a second factor description: Verifies the attestation against the user-bound challenge and stores the credential in the second-factor store (separate from primary passkeys). Accepts a cookie session, or a Bearer token presenting a single-use elevation from POST /api/auth/step-up in the `X-Step-Up` header; a Bearer token alone is still refused. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/MfaWebauthnRegisterVerifyRequest" responses: "200": description: Security key registered. content: application/json: schema: $ref: "#/components/schemas/MfaWebauthnRegisterVerifyEnvelope" "400": description: Security key verification failed (bad attestation). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/auth/me/mfa/webauthn/{id}: patch: tags: - Auth summary: Rename a registered security key description: Rename a registered second-factor security key. Accepts a cookie session, or a Bearer token presenting a single-use elevation from POST /api/auth/step-up in the `X-Step-Up` header; a Bearer token alone is still refused. parameters: - in: path name: id schema: type: string required: true requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/MfaWebauthnRenameRequest" responses: "200": description: Security key renamed. content: application/json: schema: $ref: "#/components/schemas/MfaWebauthnRenameEnvelope" "401": *a1 "403": *a9 "404": description: No such security key for this account. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 delete: tags: - Auth summary: Remove a registered security key (step-up gated) description: Requires a fresh second-factor step-up. Accepts a cookie session, or a Bearer token presenting a single-use elevation from POST /api/auth/step-up in the `X-Step-Up` header; a Bearer token alone is still refused. parameters: - in: path name: id schema: type: string required: true responses: "200": description: Security key removed. content: application/json: schema: $ref: "#/components/schemas/MfaWebauthnRemoveEnvelope" "401": *a1 "403": *a9 "404": description: No such security key for this account. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/auth/mfa/webauthn/verify/options: post: tags: - Auth summary: Begin a mid-login security-key assertion description: Presents the login `mfaTicket` and returns assertion options scoped to the password-identified user's registered security keys. Anonymous surface; rate-limited. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/MfaWebauthnLoginOptionsRequest" responses: "200": description: Assertion options issued. content: application/json: 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: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/auth/mfa/webauthn/verify: post: tags: - Auth summary: Complete a security-key second-factor login challenge description: Presents the login `mfaTicket` plus the assertion. On success returns the SAME token bundle / session the password path issues, with the session marked second-factor-verified. The ticket is single-use; failures are throttled and the ticket is burned at the attempt cap. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/MfaWebauthnLoginVerifyRequest" responses: "200": description: Second factor verified — session + optional bearer issued. content: application/json: schema: $ref: "#/components/schemas/MfaWebauthnVerifyResponse" "401": *a1 "403": *a9 "409": *a13 "422": *a4 "429": *a5 /api/auth/passkey/login-verify: post: security: [] tags: - Auth summary: Passkey assertion verification requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/PasskeyLoginVerifyRequest" responses: "200": description: Assertion verified — session + optional bearer issued. content: application/json: schema: $ref: "#/components/schemas/PasskeyLoginVerifyResponse" "401": description: The sign-in did not prove anything (v1.42.0 codes; before, the first case answered 500). `meta.errorCode` = `passkey.challenge.expired` when the challenge is gone — it expired after five minutes, was already used, or was begun for a different account — so start a new sign-in; `passkey.verification.failed` when the assertion was checked and refused (signature, origin, relying-party id or challenge mismatch). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "403": description: Passkey login is disabled — the operator runs `OIDC_ONLY=true`. `meta.errorCode` = `oidc_only`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "404": description: "`meta.errorCode` = `passkey.unknown` (v1.42.0): no passkey with this credential id is registered on this server — it was removed here, or belongs to another server. Tell the person to sign in another way. Without a code: the assertion resolved to a user row that no longer exists." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": description: "`challengeId` or `credential` is missing, or (`meta.errorCode` = `passkey.response.invalid`, v1.42.0) `credential` is not a WebAuthn authentication response." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 /api/auth/refresh: post: tags: - Auth summary: Rotate refresh token (one-time use) description: >- Reuse of a consumed refresh token revokes every refresh token still active for the originating device (per-device blast radius from v1.4.23). Legacy tokens issued before v1.4.23 with a null deviceId fall back to revoke-all-for-user. On a 401, `meta.errorCode` is a stable machine code so the client can branch terminal re-auth from a transient blip without parsing the prose `error`: `auth.refresh.reuse` (a consumed token was replayed — device family revoked, re-pair required), `auth.refresh.revoked` (family revoked out-of-band — re-pair required), `auth.refresh.invalid` (not found / expired — drop the token and re-authenticate). requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/RefreshTokenRequest" responses: "200": description: "Rotation succeeded — new pair issued. When the request carried `revoke: true` the body is `{ revoked }` instead: the token family is invalidated and no new pair is minted." content: application/json: schema: anyOf: - $ref: "#/components/schemas/RefreshResponse" - $ref: "#/components/schemas/RefreshRevokeResponse" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/auth/passkeys: get: tags: - Auth summary: List the caller's registered passkeys description: The passkeys registered for primary sign-in, newest first. Distinct from the second-factor security keys in `GET /api/auth/2fa/status` (`webauthn`) — a passkey replaces the password, a security key is asserted after one. `lastUsedAt` is stamped on every verified assertion and is null only for a passkey that has never signed in. responses: "200": description: The caller's passkeys, newest first. content: application/json: schema: $ref: "#/components/schemas/PasskeyListEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/auth/me/sessions: get: tags: - Auth summary: List active web sessions description: "v1.23 — the user-facing active-session list (issue #64). One row per browser login with a coarse device label, masked IP, resolved location, sliding last-active time, and the current-session marker. Distinct from /api/auth/me/devices (notification devices)." responses: "200": description: Active sessions for the caller. content: application/json: schema: $ref: "#/components/schemas/SessionListEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 delete: tags: - Auth summary: Sign out everywhere else description: "Ends every credential except the caller's own: every OTHER web session, every other device login (refresh token and its paired access token), every trusted device and step-up elevation, every connected AI assistant (the OAuth connection, so it cannot mint a new access token), every programmatic token (connector, measurement and document tokens), and every clinician share link. Share links are revoked by default because a link made by whoever held a lost session or device would otherwise keep opening the record; send `keepShareLinks=1` to keep them. Record-sharing invitations this account offered that nobody has accepted yet are withdrawn too, because an invitation sent from a stolen session would otherwise wait for the thief's own account. Accepted grants are not ended: the response lists them as `grantsKept` so the client can show who can still read the record and offer to end each one. A Bearer caller keeps its own device login." parameters: - in: query name: keepShareLinks schema: description: "`1` keeps the clinician share links. Anything else, or nothing, revokes them." type: string enum: - "1" description: "`1` keeps the clinician share links. Anything else, or nothing, revokes them." responses: "200": description: Other sessions revoked. content: application/json: schema: $ref: "#/components/schemas/SignOutEverywhereEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/auth/me/sessions/{id}: delete: tags: - Auth summary: Revoke a single web session description: v1.23 — revokes one session by id, scoped to the authenticated user (a foreign id returns 404, never another user's row). parameters: - in: path name: id schema: type: string required: true responses: "200": description: Session revoked. content: application/json: schema: $ref: "#/components/schemas/SessionRevokeEnvelope" "401": *a1 "403": *a9 "404": description: Session not found or not owned by the caller. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/auth/me/trusted-devices: get: tags: - Auth summary: List trusted devices description: v1.23 — the 'remember this device' list. A trusted device skips the second factor for 30 days (the password is still required). Returns only an IP-free device label + lifecycle timestamps, never the token. responses: "200": description: Trusted devices for the caller. content: application/json: schema: $ref: "#/components/schemas/TrustedDeviceListEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 delete: tags: - Auth summary: Forget every trusted device description: v1.23 — revokes all trusted devices for the caller and clears the caller's own trusted-device cookie. responses: "200": description: All trusted devices revoked. content: application/json: schema: $ref: "#/components/schemas/TrustedDeviceRevokeAllEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/auth/me/trusted-devices/{id}: delete: tags: - Auth summary: Revoke a single trusted device description: v1.23 — revokes one trusted device by id, scoped to the authenticated user (a foreign id returns 404). parameters: - in: path name: id schema: type: string required: true responses: "200": description: Trusted device revoked. content: application/json: schema: $ref: "#/components/schemas/TrustedDeviceRevokeEnvelope" "401": *a1 "403": *a9 "404": description: Trusted device not found or not owned by the caller. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/auth/me/security-activity: get: tags: - Auth summary: List recent account-security activity description: "v1.23 — the SHARED security-activity feed: the caller's recent auth + export + deletion audit events with timestamp, resolved location, and a host-masked IP. `limit` is 1-100 (default 50); a value outside that is a 422 naming it. Reuses the AuditLog store; no event detail bodies are surfaced." parameters: - in: query name: limit schema: description: Page size, 1-100; defaults to 50. Out of range is a 422. type: integer minimum: 1 maximum: 100 description: Page size, 1-100; defaults to 50. Out of range is a 422. responses: "200": description: Recent security events for the caller. content: application/json: schema: $ref: "#/components/schemas/SecurityActivityEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/auth/logout: post: tags: - Auth summary: End the calling session, and the calling token with it description: >- Clears the browser session cookie. When the request ALSO carries `Authorization: Bearer hlk_…` it revokes that access token and the refresh sibling paired with it, so a native client signs out in one call rather than through the refresh endpoint's `revoke: true` arm. Takes no body and needs no credential: an unauthenticated call answers 200 exactly like an authenticated one. Whether the Bearer revocation actually found a row is recorded server-side and is deliberately absent from the response — a caller that could read it would learn whether a token it presented was live. responses: "200": description: Sign-out performed as far as the presented credentials allowed. content: application/json: schema: $ref: "#/components/schemas/LogoutEnvelope" /api/auth/oidc/status: get: security: [] tags: - Auth summary: Whether SSO is configured, and whether it is the only way in description: "Public and pre-session: the login surface reads it to decide whether to render the SSO button and whether to hide the password / passkey controls. Pure environment reads, no database, so it has no failure mode — it answers 200 on every call." responses: "200": description: The SSO posture of this instance. content: application/json: schema: $ref: "#/components/schemas/OidcStatusEnvelope" /api/auth/registration-status: get: security: [] tags: - Auth summary: Whether self-registration is open description: "Public and pre-session: the sign-up surface reads it before offering a registration form. Fails CLOSED — when the settings read throws, the response is a well-formed 200 carrying `registrationEnabled: false` rather than an error, so a client cannot tell a genuinely closed instance from a database blip and must treat both as closed." responses: "200": description: Registration posture. Also the shape returned when the settings read failed. content: application/json: schema: $ref: "#/components/schemas/RegistrationStatusEnvelope" /api/auth/passkey/login-options: post: security: [] tags: - Auth summary: Begin a passkey sign-in (discoverable credentials) description: Returns SimpleWebAuthn assertion options plus a server-issued challenge id to present at POST /api/auth/passkey/login-verify. Anonymous, takes no body (the web client may send an optional `clientTransport`), and scopes the assertion to no account — the options carry no `allowCredentials`, so the authenticator picks the credential and the endpoint is not an account-existence oracle. Rate-limited 10 / 15 min through the anonymous auth-surface bucket, which collapses every caller into one bucket if the proxy trust chain is misconfigured rather than falling open. requestBody: required: false content: application/json: schema: $ref: "#/components/schemas/PasskeyLoginOptionsRequest" responses: "200": description: Assertion options issued. content: application/json: schema: $ref: "#/components/schemas/PasskeyLoginOptionsEnvelope" "403": description: Passkey login is disabled — the operator runs `OIDC_ONLY=true`. `meta.errorCode` = `oidc_only`. Checked BEFORE the rate limit, so an instance in this mode answers 403 rather than 429 however hard it is polled. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "409": *a13 "429": *a5 /api/auth/passkeys/{id}: patch: tags: - Auth summary: Rename a registered passkey description: >- Relabels one primary sign-in credential, scoped to the authenticated user. Returns the full passkey row, so the client can replace its list entry rather than re-read. The name is trimmed before validation and must be 1 to 64 characters once trimmed. A rejected name earns the standard multi-issue 422, so a form can put the reason beside the field rather than showing a bare “invalid request”. parameters: - in: path name: id schema: type: string required: true requestBody: required: true content: application/json: schema: type: object properties: name: type: string minLength: 1 maxLength: 64 required: - name responses: "200": description: Passkey renamed. content: application/json: schema: $ref: "#/components/schemas/PasskeyRenameEnvelope" "401": *a1 "403": *a9 "404": description: No such passkey for this account. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 delete: tags: - Auth summary: Remove a registered passkey description: >- Deletes one primary sign-in credential, scoped to the authenticated user. Refuses to remove the LAST one when the account has no password — an account must keep at least one way in. Step-up gated, on the same mechanism and at the same strength as removing a second-factor security key (DELETE /api/auth/me/mfa/webauthn/{id}). The caller must present a fresh possession proof: on the cookie path a session that completed one within the last five minutes, on Bearer an `X-Step-Up` elevation minted at POST /api/auth/step-up against a re-proved factor. A password-proved elevation is NOT enough. A PASSKEY IS SUCH A PROOF here, unlike on the second-factor routes. An account whose only credential is a passkey can satisfy this gate: a passkey login stamps the session, and POST /api/auth/step-up accepts `method: "passkey"` against the account's primary passkeys. So its refusal is `meta.errorCode: auth.stepup.required` — re-prove and retry — and not the dead-end `auth.stepup.mfa_not_enrolled`, which is reserved for an account holding no credential this gate could ever accept. The elevation is spent only when the deletion is about to happen. A 404 for an unknown id, or the last-credential refusal, leaves it unconsumed and reusable. parameters: - in: path name: id schema: type: string required: true responses: "200": description: Passkey removed. content: application/json: schema: $ref: "#/components/schemas/PasskeyRemoveEnvelope" "400": description: This is the account's only passkey and it has no password — at least one authentication method must remain. Set a password, or register a second passkey, before retrying. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": description: Not authenticated, or the request carried no fresh possession proof (`meta.errorCode` = `auth.stepup.required`). Re-prove and retry — on a native client by minting an elevation at POST /api/auth/step-up, on the web by signing in again, which is what stamps the session. `auth.stepup.mfa_not_enrolled` is the other arm and means the account holds no credential this gate can accept, so retrying will not help. Nothing was removed either way. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "403": *a9 "404": description: No such passkey for this account. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/tokens: get: tags: - Auth summary: List the caller's API tokens description: >- Every `ApiToken` row belonging to the caller, newest first, revoked ones included — presence in this list is not validity, `revoked` is. The hash is never returned and there is no path that re-reveals a token's plaintext. Three things the name understates. The list is not limited to tokens a person minted from the settings surface: a native login mints a wildcard access token as an `ApiToken` row, so a signed-in phone shows up here too. There is no GENERIC mint any more — the POST at this exact path issued `["medication:ingest"]` and was removed, because that scope reached no ingest route while the pre-fail-closed default let it reach everything else. The credentials that work are minted where they are scoped: the per-medication API-endpoint toggle, `POST /api/mcp/tokens` for a connector, the sibling `POST /api/tokens/measurements` for third-party measurement ingest, `POST /api/tokens/workouts` for workout ingest from a bridge, and `POST /api/tokens/documents` for document upload from another system. And unlike the revoke, this read is NOT gated on the operator's instance-wide API switch: that switch governs the surfaces a token is for, not a token's ability to authenticate, so tokens stay live while it is off and their owner has to be able to see them. responses: "200": description: The caller's tokens, newest first. content: application/json: schema: $ref: "#/components/schemas/ApiTokenListEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/tokens/measurements: get: tags: - Auth summary: Readings no measurement token reaches description: Counts the caller's live EXTERNAL readings that are linked to no token. Readings are linked to the token that sent them since v1.46; older ones were linked by a one-time pass only when the account held exactly one measurement token, so the rest belong to none and no token's Apple Health setting reaches them. Cookie session only. responses: "200": description: The count. content: application/json: schema: $ref: "#/components/schemas/MeasurementTokenSummaryEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 post: tags: - Auth summary: Mint a measurement-ingest token description: >- Mints a Bearer scoped to exactly `measurements:write` and audits the mint. THE RESPONSE CARRIES THE RAW TOKEN — the only place it ever exists; it is stored as an HMAC and no path re-reveals it. **What it can do.** `POST /api/measurements` and `POST /api/measurements/batch`, on its owner's own record. Rows it writes carry `source: EXTERNAL`, resolved from the credential rather than taken from the body, so a reading pushed by a bridge stays distinguishable from one typed in by hand and can be picked out with the source filter. A body naming ANY source is refused 422 rather than relabelled — honouring it would defeat the label, overriding it silently would hand the caller rows it did not ask for. `APPLE_HEALTH` is the one that would do real damage: it is half a dedup key the phone also writes into, it participates in the cross-source merge, and it is what decides the Apple Health card may claim a sync happened. For the same reason a write through this credential does not move the native client's sync checkpoint. The rows stay editable by their owner, unlike a connected provider's — the hardware behind the token is the user's own. **What it cannot do.** Everything else, including the measurement reads on the same paths, the edit and delete legs, and the export — a scope grants what it names and nothing adjacent. It cannot be pointed at a shared record: a request carrying the account selector is refused 403 before any grant is read, whatever grants its holder actually has. And it cannot mint another token, this endpoint included. Minting requires a COOKIE SESSION with a fresh proof: a sign-in or `POST /api/auth/reproof` within the last five minutes (with a second factor, on an account that has one), otherwise 401 `auth.reproof.required` with `meta.methods`. No Bearer credential reaches this endpoint at any scope, wildcard included — not because a wildcard lacks the reach, but because of the lifetimes involved: a native access token lives a day and what it could mint here lives a year, so admitting one would let a short-lived compromise leave behind a credential that outlives revoking it. Gated by the operator's instance-wide API switch. Body capped at 16 KiB; 10 mints per user per minute, and at most 10 live tokens held at once. Tokens appear in `GET /api/tokens` and are revoked at `DELETE /api/tokens/{id}` like any other — revoking frees a slot against the ceiling. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CreateMeasurementTokenRequest" responses: "201": description: Token minted. The `token` field is the only copy of the secret. content: application/json: schema: $ref: "#/components/schemas/CreateMeasurementTokenEnvelope" "400": description: Body is not parseable JSON. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 "403": 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: $ref: "#/components/schemas/ErrorEnvelope" "409": description: "The account already holds 10 live measurement tokens (`meta.errorCode` = `tokens.measurements.ceiling_reached`). Nothing was minted. Distinct from the 429 above: that one is a rate and clears by waiting, this one is a conflict with the account's current state and clears by revoking a token at `DELETE /api/tokens/{id}`. Only LIVE tokens count — revoked and expired rows do not occupy a slot — and only those carrying `measurements:write`, so the wildcard tokens a sign-in mints never consume the budget." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "413": description: Body exceeds 16 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "415": description: Content-Type is not `application/json`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": description: Validation failed. The envelope carries every offending issue, not just the first. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": description: More than 10 mints from this account in a minute. Nothing was minted. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/tokens/workouts: post: tags: - Auth summary: Mint a workout-ingest token description: >- Mints a Bearer scoped to exactly `workouts:write` and audits the mint. THE RESPONSE CARRIES THE RAW TOKEN, once; it is stored as an HMAC and no path re-reveals it. **What it can do.** `POST /api/workouts/batch`, on its owner's own record, and nothing else. It is the door a workout bridge pushes through: a watch vendor's sync relayed by a small service, or a script replaying an exported history. Workouts it writes carry `source: EXTERNAL`, resolved from the credential rather than taken from the body, and a batch in which any entry names a source is refused 422 (`workout.batch.source_not_permitted`) rather than relabelled. `EXTERNAL` is on no default source ladder, so when the phone also recorded the same session the phone's copy is the one the workout list shows. **What it cannot do.** List or read a workout, reach the measurement or document ingest routes, act on a shared record, or mint another token. It is a separate scope from `measurements:write` on purpose: a token already handed to a scale does not start accepting workouts, and a `measurements:write` token is refused 403 on the workout batch. Every other route names no scope and refuses it 403. Minting requires a COOKIE SESSION with a fresh proof, as for the measurement mint, for the reason it gives: a day-lived native access token must not be able to leave behind a year-lived credential. Gated by the operator's instance-wide API switch. Body capped at 16 KiB; 10 mints per user per minute, and at most 10 live workout tokens held at once. Tokens appear in `GET /api/tokens` and are revoked at `DELETE /api/tokens/{id}`. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CreateWorkoutTokenRequest" responses: "201": description: Token minted. The `token` field is the only copy of the secret. content: application/json: schema: $ref: "#/components/schemas/CreateWorkoutTokenEnvelope" "400": description: Body is not parseable JSON. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 "403": 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: $ref: "#/components/schemas/ErrorEnvelope" "409": description: The account already holds 10 live workout tokens (`meta.errorCode` = `tokens.workouts.ceiling_reached`). Nothing was minted. Revoke one at `DELETE /api/tokens/{id}`; only live tokens carrying `workouts:write` count. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "413": description: Body exceeds 16 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "415": description: Content-Type is not `application/json`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": description: Validation failed. The envelope carries every offending issue. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": description: More than 10 mints from this account in a minute. Nothing was minted. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/tokens/documents: post: tags: - Auth summary: Mint a document-upload token description: >- Mints a Bearer scoped to exactly `documents:write` and audits the mint. THE RESPONSE CARRIES THE RAW TOKEN, once; it is stored as an HMAC and no path re-reveals it. **What it can do.** `POST /api/documents/inbound` on its owner's own record, and `GET /api/documents/inbound/source`, which answers whether a source key is already held so an importer can skip a download. Nothing else. It is the door another document system pushes through: a Paperless-ngx workflow posting a newly tagged document, or an import script copying an archive. Uploads through it draw on a bucket of their own, keyed on the token (default 120 an hour, operator-tunable via `DOCUMENT_UPLOAD_LIMIT_PER_HOUR`), and get back a receipt (`id`, `duplicate`, and `deleted` when the source key belongs to a document the owner deleted) rather than the stored row. **What it cannot do.** List, read, download, preview, edit, delete or bulk-act on a document, reach the document-AI routes, act on a shared record, or mint another token. Every other route names no scope and refuses it 403. Minting requires a COOKIE SESSION with a fresh proof, as for the measurement mint, for the reason it gives: a day-lived native access token must not be able to leave behind a year-lived credential. Gated by the operator's instance-wide API switch. Body capped at 16 KiB; 10 mints per user per minute, and at most 10 live document tokens held at once. Tokens appear in `GET /api/tokens` and are revoked at `DELETE /api/tokens/{id}`. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CreateDocumentTokenRequest" responses: "201": description: Token minted. The `token` field is the only copy of the secret. content: application/json: schema: $ref: "#/components/schemas/CreateDocumentTokenEnvelope" "400": description: Body is not parseable JSON. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 "403": 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: $ref: "#/components/schemas/ErrorEnvelope" "409": description: The account already holds 10 live document tokens (`meta.errorCode` = `tokens.documents.ceiling_reached`). Nothing was minted. Revoke one at `DELETE /api/tokens/{id}`; only live tokens carrying `documents:write` count. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "413": description: Body exceeds 16 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "415": description: Content-Type is not `application/json`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": description: Validation failed. The envelope carries every offending issue. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": description: More than 10 mints from this account in a minute. Nothing was minted. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/tokens/{id}: delete: tags: - Auth summary: Revoke an API token description: >- Marks one token revoked, scoped to the authenticated user (a foreign id returns 404, never another user's row). The row is kept rather than deleted so it stays visible in the list with `revoked: true`. Nothing exempts the caller's own credential: a Bearer client that revokes the token it is presenting completes the call and is unauthenticated from the next one onwards. parameters: - in: path name: id schema: type: string required: true responses: "200": description: Token revoked. content: application/json: schema: $ref: "#/components/schemas/ApiTokenRevokeEnvelope" "401": *a1 "403": 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: $ref: "#/components/schemas/ErrorEnvelope" "404": description: Token not found or not owned by the caller. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 patch: tags: - Auth summary: Change a measurement token's Apple Health setting description: "Sets whether the HealthLog app writes the readings this measurement token sent into Apple Health. The readings keep their `EXTERNAL` source and are not rewritten: every measurement row resolves `appleHealthMirror` from the token at read time, and a paired client learns of the change through the `measurementMirror` domain of `GET /api/sync/changes`. Turning it off stops further writes; samples already in Apple Health stay. A revoked token is accepted, since its readings stay in the record. Cookie session only, the caller's own tokens only (another account's answers 404). Audited as `tokens.measurements.mirror.update` when the value changes. 30 changes per account per minute. Not gated on the instance-wide API switch: no credential is created or extended." parameters: - in: path name: id schema: type: string required: true requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpdateMeasurementTokenRequest" responses: "200": description: The setting as it now stands. content: application/json: schema: $ref: "#/components/schemas/UpdateMeasurementTokenEnvelope" "401": *a1 "403": *a9 "404": description: Token not found or not owned by the caller. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": description: "Validation failed, or the token is not a measurement token (`meta.errorCode` = `tokens.mirror.not_measurement_token`): a wildcard, connector or other narrow token has no such setting." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 /api/auth/register: post: security: [] tags: - Auth summary: Create an account description: >- Anonymous. Creates the account, issues a browser session cookie, and returns the new account's identity. No token is minted here even for a native caller — an app registers and then signs in through /api/auth/login. Admission has three gates and a bootstrap. `OIDC_ONLY=true` refuses outright. Otherwise the surface is rate-limited to 5 attempts per 15 minutes; on a misconfigured proxy trust chain every anonymous caller collapses into ONE bucket rather than falling open. When the operator has closed self-registration a valid invite token still admits the signup, and a token is consumed whenever one is sent — even under open registration, so the issuer's ledger stays complete. The bootstrap: on an instance with zero users registration is always allowed and the first account is minted ADMIN, decided under an advisory lock so two racing signups cannot both become one. The password is checked for strength against the chosen username and email, and against a breach corpus over k-anonymity. The breach check fails OPEN — an unreachable corpus never blocks a signup. A supplied `timezone` is validated against the runtime IANA list and silently replaced with the operator default when it is not one; it is never a reason to refuse. Existence is not disclosed: a taken email and a taken username return the same 409 with the same prose. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/RegisterRequest" responses: "201": description: Account created; the session cookie is set. content: application/json: schema: $ref: "#/components/schemas/RegisterEnvelope" "401": *a1 "403": description: Refused before any work. Either the operator runs `OIDC_ONLY=true` (`meta.errorCode` = `oidc_only`), or self-registration is closed and the request carried no invite, or the invite it carried was invalid, expired or exhausted. The three are distinguishable by prose but not by code. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "409": description: The email or the username is taken. Deliberately one message for both, so this is not an account-existence oracle. Also the answer when two concurrent signups race past the pre-check and the database unique index catches the second. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/auth/password: post: tags: - Auth summary: Change the account password description: >- A change, not a reset: it is authenticated, and it re-proves the CURRENT password in the body. There is no anonymous forgot-password flow on this surface. For an account with a second factor enrolled it additionally requires a fresh factor proof, which is cookie-only by construction — a Bearer transport carries no `mfaVerifiedAt` and cannot satisfy it at any scope. Accounts with no second factor keep the current-password-only contract on either transport. On success the blast radius is deliberate and wide: every step-up elevation the account holds is revoked, every session is destroyed, and a fresh session is issued to THIS caller. Other browsers and other devices are signed out. The new password is strength-checked and screened against a breach corpus (fail-open on an unreachable corpus). Rate-limited 5 per 15 minutes per user. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/ChangePasswordRequest" responses: "200": description: Password rotated. Every other session is gone and this caller holds a new one. content: application/json: schema: $ref: "#/components/schemas/PasswordChangeEnvelope" "400": description: The account has no password to change — it was provisioned through SSO. There is no set-a-first-password arm on this route. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": description: Not authenticated, the current password is wrong, or the account has a second factor and the session carries no proof of it fresh enough (`meta.errorCode` = `auth.stepup.required`). A wrong current password is 401 rather than 422 — it is a failed credential check, not a malformed body. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "403": *a9 "422": *a4 "429": *a5 /api/auth/profile: put: tags: - Auth summary: Patch the account profile description: >- Partial and field-by-field: only the keys present in the body are considered, and a key that fails validation is SKIPPED rather than failing its siblings. The response still carries 200 and lists what was dropped in `rejectedFields` — so a client that treats any 200 as a clean save will silently discard the user's correction. Read the key. Two cases do fail the whole request: a body that is not an object at all, and a body in which every supplied field was rejected (`meta.errorCode` = `profile.update.nothingSaved`) — because reporting a partial success that saved nothing would be a lie. The insurance number is validated with its check digit, normalised, and stored encrypted; it is never returned. `hasInsuranceNumber` reports presence only. A null or empty string clears a nullable field rather than being refused, so a form that renders “no answer” as `""` does not 422 the whole patch. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/ProfileUpdateRequest" responses: "200": description: The profile after the patch. A `rejectedFields` key means the save was PARTIAL. content: application/json: 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. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": description: Either the body was not a usable object (`meta.errorCode` = `profile.update.invalidBody`) or every supplied field was rejected (`profile.update.nothingSaved`). Both carry the per-field issue list. A body where SOME fields validate never reaches this response — it succeeds with `rejectedFields`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": &a20 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. `meta.errorCode` is `profile.update.emailRateLimited`. Only a request whose `email` actually differs from the address on file is counted: re-saving a form that carries the unchanged address is free, and so is any update that does not touch `email` at all. A request that carries other fields beside the refused address does NOT get this response — it answers 200, saves those fields, and names `email` in `rejectedFields` with code `rate_limited`, so one spent budget cannot block an unrelated profile edit for an hour. The `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` headers describe that bucket; back off on `Retry-After` rather than guessing." headers: *a8 content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/auth/oidc/login: get: security: [] tags: - Auth summary: Start an SSO sign-in (browser redirect, or the native leg) description: >- A full-page navigation, never a fetch: every outcome is a 302, and no branch returns the JSON envelope. Anonymous by definition — it is what authenticates the caller. Rate-limited 10 per 15 minutes on the anonymous auth-surface bucket. **Browser arm.** No parameters, or `next=` to return somewhere specific afterwards (sanitised to an in-app path — an absolute URL is discarded, not honoured). Redirects to the provider's authorization endpoint. Every failure redirects to `/auth/login?error=`. **Native arm.** The iOS app opens this URL inside an `ASWebAuthenticationSession` with `client=native` and its own PKCE `code_challenge`. The challenge is mandatory on this arm and must be S256, 43–128 characters; `plain` is structurally unsupported because the exchange only ever verifies S256. `next` is ignored and pinned to `/` — a post-login web path means nothing to the app. Every failure redirects to `healthlog://oidc-callback?error=`, and an error redirect carries no code, no ticket and no session, which is why it is safe that `client` is caller-supplied. **What comes back.** The provider returns to the server's own callback, not to the app. On success the callback redirects to the compiled-in `healthlog://oidc-callback` with a one-time `code=hlh_…`, which the app exchanges at POST /api/auth/oidc/native/token. When the account has a second factor the callback instead returns `mfa_ticket=…`, which the app completes at /api/auth/mfa/verify to obtain the same bundle. The token pair NEVER rides a URL — only the opaque code or ticket does. There is no `redirect_uri` parameter and no allowlist to configure: the scheme is a compile-time constant, so the open-redirect class is removed by construction. **An account that exists but is not linked yet.** When the provider's verified email matches a local account with no SSO identity, nothing is linked and nobody is signed in: the local address was never verified, so matching it proves nothing about who owns the account. The browser arm redirects to `/auth/login?error=oidc_link_required` with a sealed, ten-minute, HttpOnly pending-link cookie; the person then signs in to that account once with its own password (and second factor) or passkey, and that sign-in makes the link. Under `OIDC_ONLY=true` the password sign-in is admitted for exactly the account the pending link names. The native arm redirects to `healthlog://oidc-callback?error=oidc_link_required`; the link is confirmed once on the web, after which the app's SSO sign-in works. Two PKCE exchanges run here and must not be confused: the server↔provider verifier the server generates for itself, and the app↔server challenge supplied above. The native flag and the app's challenge travel only inside an AES-256-GCM state cookie, so the callback branches on tamper-authenticated state rather than on anything the provider or a network attacker can flip. parameters: - in: query name: client schema: description: Send `native` to start the app leg. Anything else (including absence) is the browser leg. type: string const: native description: Send `native` to start the app leg. Anything else (including absence) is the browser leg. - in: query name: code_challenge schema: description: The app's PKCE S256 challenge (RFC 7636). REQUIRED when `client=native`; ignored otherwise. type: string minLength: 43 maxLength: 128 description: The app's PKCE S256 challenge (RFC 7636). REQUIRED when `client=native`; ignored otherwise. - in: query name: next schema: description: "Browser leg only: where to land after login. Sanitised to an in-app path; ignored entirely on the native leg." type: string description: "Browser leg only: where to land after login. Sanitised to an in-app path; ignored entirely on the native leg." - in: query name: intent schema: description: "v1.43 — `claim` continues a managed-profile claim started at POST /api/auth/claim/sso; the link and username ride the sealed `hl_claim` cookie that call set, never this URL. Without a valid cookie for a still-usable link the start redirects to `/auth/claim?error=claim_expired`. Browser leg only: with `client=native` it fails with `oidc_invalid_request`. `next` is ignored." type: string const: claim description: "v1.43 — `claim` continues a managed-profile claim started at POST /api/auth/claim/sso; the link and username ride the sealed `hl_claim` cookie that call set, never this URL. Without a valid cookie for a still-usable link the start redirects to `/auth/claim?error=claim_expired`. Browser leg only: with `client=native` it fails with `oidc_invalid_request`. `next` is ignored." - in: query name: client_proto schema: description: "Browser leg only: the scheme of the page the SSO button was pressed on. With `http` on a non-loopback `client_host` while the server issues `Secure` session cookies, the start redirects to `/auth/login?error=insecure_transport` instead of the provider, because the session cookie would be dropped on return." type: string enum: - http - https description: "Browser leg only: the scheme of the page the SSO button was pressed on. With `http` on a non-loopback `client_host` while the server issues `Secure` session cookies, the start redirects to `/auth/login?error=insecure_transport` instead of the provider, because the session cookie would be dropped on return." - in: query name: client_host schema: description: "Browser leg only: `location.host` of that page, read together with `client_proto`." type: string maxLength: 255 description: "Browser leg only: `location.host` of that page, read together with `client_proto`." responses: "302": description: Always a redirect. To the provider on success. On failure to `/auth/login?error=` (browser; `/auth/claim?error=` with `intent=claim`, where `claim_expired` is one more reason) or `healthlog://oidc-callback?error=` (native), where reason is one of `oidc_disabled`, `oidc_rate_limited`, `oidc_invalid_request`, `oidc_failed`, and on the browser leg `insecure_transport` (see `client_proto`). The provider then returns to the callback (not listed here, because the identity provider drives it rather than a client), which ends the native leg at `healthlog://oidc-callback` with `code=`, with `mfa_ticket=&methods=`, or with `error=`, where reason is one of `oidc_disabled`, `oidc_rate_limited`, `oidc_denied`, `oidc_no_email`, `oidc_email_unverified`, `oidc_identity_conflict`, `oidc_link_required`, `oidc_registration_disabled`, `oidc_failed`. `oidc_link_required` means the provider's e-mail matches an existing local account that is not linked yet; the owner links it by signing in once with that account's own password or passkey in a browser. The reasons are this exact snake_case vocabulary on both legs; a client maps a value it does not know to a generic sign-in failure. /api/auth/passkey/register-options: post: tags: - Auth summary: Begin registering a passkey (existing-factor proof required) description: >- Cookie-only — a Bearer token cannot enroll a sign-in credential on this surface at any scope. The route has TWO arms and the body decides which. **Re-proof arm.** A body of exactly `{ method: "passkey" }` or `{ method: "webauthn" }` begins an assertion ceremony WITHOUT beginning enrollment, and answers with `reauth: true` alongside the options. Use it to obtain the assertion the second arm needs. **Enrollment arm.** Any other body must be a full factor proof (the same shape POST /api/auth/step-up takes): a password, a TOTP code, or a completed passkey / security-key assertion. Only then are registration options issued. On an account that already has a second factor a password is refused before it is checked, with 422 `auth.reproof.too_weak` and `meta.methods` naming the proofs that work: a passkey added on the password alone would sign in without the second factor from then on. Which factor you prove changes the session, and the direction is not the obvious one: a strong proof stamps the session second-factor-verified, while a PASSWORD proof deliberately CLEARS that stamp. Registering with a password is authorised by the single-use, session-bound challenge alone and must not silently upgrade password-only authentication into something the rest of the app reads as a second factor. Every refusal on the proof path is the same 401 with the same prose — a missing content type, an unparseable body, a body that is not a valid proof shape, and a wrong password are indistinguishable to the caller. A `totp` proof counts only from an authenticator whose enrollment was confirmed. Each proof on the enrollment arm draws on the account's shared re-proof budget (five per fifteen minutes, the same budget as POST /api/auth/step-up), and every failed proof writes an `auth.mfa.failed` audit row. Once the budget is spent the answer is 429 until the window passes, even for a correct proof. requestBody: required: true content: application/json: schema: anyOf: - $ref: "#/components/schemas/PasskeyReauthRequest" - $ref: "#/components/schemas/StepUpMintRequest" responses: "200": description: "Options issued. Carries `reauth: true` on the re-proof arm and not on the enrollment arm." content: application/json: schema: anyOf: - $ref: "#/components/schemas/PasskeyReauthOptionsEnvelope" - $ref: "#/components/schemas/PasskeyRegisterOptionsEnvelope" "401": description: No cookie session, or no acceptable existing-factor proof. One message covers every proof failure so the response cannot be used to probe which part was wrong. content: 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: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": description: "`auth.reproof.too_weak`: a password on an account that already has a second factor. `meta.methods` names the proofs that would be accepted. Refused before the password is checked, so it spends no attempt." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 /api/auth/passkey/register-verify: post: tags: - Auth summary: Finish registering a passkey description: >- Cookie-only. Verifies the attestation against the challenge minted by register-options and stores the credential as a PRIMARY sign-in credential — a different store from the second-factor security keys under /api/auth/me/mfa/webauthn. The freshness stamp is re-read from the live session row rather than trusted from the earlier request, so an elevation downgraded or revoked between the two calls cannot be carried through the ceremony. The challenge is bound to the user AND the session that created it: presenting one from another context is refused as a missing proof rather than as a bad challenge. The transports the authenticator reports are stored as sent. Nothing about the credential is echoed back — the response is a bare confirmation, and the new row is read through GET /api/auth/passkeys. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/PasskeyRegisterVerifyRequest" responses: "200": description: Passkey registered. content: application/json: schema: $ref: "#/components/schemas/PasskeyRegisterVerifyEnvelope" "400": description: The attestation did not verify, or the registration challenge is unknown or expired. One prose message covers a malformed credential and a failed signature. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": description: No cookie session; or the session's factor proof has aged past five minutes; or the challenge belongs to another session. Restart at register-options. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "403": *a9 "422": *a4 "429": *a5 /api/auth/codex/device-start: post: tags: - Auth summary: Begin connecting a ChatGPT account by device code description: >- Starts an OAuth device-code grant against the provider and returns the short code plus the page to type it on. Takes no body. Rate-limited 5 per minute per user. The device-auth id that the poll needs is NOT in the response. It is sealed with the rest of the flow state into an encrypted, httpOnly, 15-minute cookie, which means the poll can only be completed by the browser that started the flow and a native client cannot drive this pair at all. Device-code rather than a redirect because the provider only allow-lists localhost callbacks for the public client id, so a hosted deployment has no usable redirect target. responses: "200": description: Device code issued; the state cookie is set. content: application/json: schema: $ref: "#/components/schemas/CodexDeviceStartEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/auth/codex/device-poll: post: tags: - Auth summary: Poll a device-code connection to completion description: >- Called every `intervalSeconds` until it answers `connected`. Takes no body — the flow state comes from the cookie device-start set. Rate-limited 60 per minute per user. On success the credentials are exchanged server-side and stored encrypted on the account, the state cookie is deleted, and any cached insight text is dropped so the next generation runs on the new provider. Stop polling at that point: a further call has no cookie and answers 400. responses: "200": description: Either still waiting for the person to approve, or connected. content: application/json: schema: $ref: "#/components/schemas/CodexDevicePollEnvelope" "400": description: "No device-auth attempt is in progress, or the state cookie could not be read. Both mean the same thing to a client: start again at device-start. An unreadable cookie is deleted on the way out." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 "502": description: The provider's device endpoint failed or answered unusably. The underlying message is recorded server-side and deliberately not echoed. Restart the flow. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/auth/codex/disconnect: delete: tags: - Auth summary: Disconnect the ChatGPT account description: >- Clears the stored credentials and marks the connection disconnected, and drops the cached insight text with them so nothing generated through that account is served afterwards. Idempotent: disconnecting an account that was never connected succeeds. Rate-limited 5 per minute per user. Takes no body. Local only — this revokes HealthLog's copy of the credentials. It does not tell the provider to forget the grant; that is done in the provider's own account settings. responses: "200": description: Disconnected. content: application/json: schema: $ref: "#/components/schemas/CodexDisconnectEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/sync/state: get: tags: - Sync summary: Sync handshake + window metadata description: Cheap 'should I sync?' summary. Returns the `lastSyncedAt` checkpoint, which the batch ingest endpoints advance when a client push lands; this route is a read and writes nothing. The `sync` block carries the incremental-delta window + tombstone retention so the client reads them rather than hardcoding. responses: "200": description: Sync state summary. content: application/json: schema: $ref: "#/components/schemas/SyncStateEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/sync/changes: get: tags: - Sync summary: Multi-domain delta feed description: "Incremental catch-up after the first-pair backfill (never a replacement for it). Pages over one opaque keyset cursor spanning six domains — measurements, mood, medication intakes, cycle day logs, cycles, and `measurementMirror` (the Apple Health write-back of readings whose token setting changed). Each domain carries `tombstones` (soft-deleted rows) and `upserts` (live rows); apply tombstones before upserts within a domain. Tombstone identity is `externalId` for measurements and the server `id` everywhere else. `cursorExpired: true` forces a clean re-init and the page is empty in every domain." parameters: - in: query name: cursor schema: description: Opaque multi-domain keyset cursor from the previous page. Treat as fully opaque — echo, never parse. Omit for the initial sync. type: string minLength: 1 maxLength: 2048 description: Opaque multi-domain keyset cursor from the previous page. Treat as fully opaque — echo, never parse. Omit for the initial sync. - in: query name: limit schema: description: Page size, default 200, hard cap 500. type: integer minimum: 1 maximum: 500 description: Page size, default 200, hard cap 500. responses: "200": description: Delta page. content: application/json: schema: $ref: "#/components/schemas/SyncChangesEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/measurements/series-batch: get: tags: - Measurements summary: Batched daily series (dashboard fetch coalescing) description: Returns the rollup-backed daily series for every requested MeasurementType in ONE response, so the dashboard chart row does a single round-trip instead of one self-fetch per chart. Each per-type series is byte-identical with `GET /api/measurements?aggregate=daily&source=rollup` for the same window. SLEEP_DURATION is not served here (it rides the per-night reconstruction at `/api/measurements/series?kind=sleep`). Auth via cookie or Bearer. parameters: - in: query name: types schema: description: Comma-separated list of MeasurementType values (1..16). Each is read as a daily series through the rollup tier. type: string minLength: 1 required: true description: Comma-separated list of MeasurementType values (1..16). Each is read as a daily series through the rollup tier. - in: query name: from schema: description: Window start (ISO-8601 instant). type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: true description: Window start (ISO-8601 instant). - in: query name: to schema: description: Window end (ISO-8601 instant). type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: true description: Window end (ISO-8601 instant). responses: "200": description: Per-type daily series. content: application/json: schema: $ref: "#/components/schemas/GetMeasurementsSeriesBatchResponse" "401": *a1 "403": *a15 "422": *a4 "429": *a5 /api/measurements/series: get: tags: - Measurements summary: Per-kind time series (iOS chart source) description: Maps a camelCase `kind` to the canonical MeasurementType(s) and returns an ordered point series with an explicit `unit` token and a summary `stats` block. `bloodPressure` pairs systolic + diastolic (`secondary`) within ±5 min so one fetch renders the dual-line chart; `sleep` collapses per-stage rows into one per-night point carrying time-asleep in hours (`sleepStages` holds the per-stage breakdown) and is internally capped to 365 days. `glucose` values + `unit` resolve to the user's mg/dL|mmol/L preference. Where a metric has a source-priority ladder (pulse, weight, SpO2, HRV, ...), each day shows the readings of the source the person's ladder picks for that day, so a second provider never blends into or duplicates a day; a metric without a ladder (glucose, body water, bone mass) keeps every source. Auth via cookie or Bearer. parameters: - in: query name: kind schema: type: string enum: - weight - bloodPressure - pulse - bodyFat - glucose - sleep - steps - totalBodyWater - boneMass - oxygenSaturation - restingHeartRate - heartRateVariability - vo2Max required: true - in: query name: days schema: default: 30 description: Look-back window in days (1..3650, default 30). `sleep` is internally capped to 365 regardless of the requested range. type: integer minimum: 1 maximum: 3650 description: Look-back window in days (1..3650, default 30). `sleep` is internally capped to 365 regardless of the requested range. responses: "200": description: Resolved series. content: application/json: schema: $ref: "#/components/schemas/GetMeasurementsSeriesResponse" "401": *a1 "403": *a15 "422": *a4 "429": *a5 /api/measurements: get: tags: - Measurements summary: List measurements description: >- Filter by type + date range. Response is paged via offset/limit; default limit 100. With `groupBy=day` on a cumulative type the rows are one sum per local day, and `limit` / `offset` page over those days; every returned day is complete, which is why `valueMin` / `valueMax` (a filter on single readings) are refused with 422 beside `groupBy=day`. `type=SLEEP_DURATION` returns one row per night, paged the same way over nights; every night of the page has a row, and `value` is the day's time asleep, the main night plus its naps (the same total `/api/measurements/series?kind=sleep` and the sleep score read), with the main night alone in `mainAsleepMinutes` and the naps in `napCount` / `napAsleepMinutes`; a day whose only sleep is daytime naps carries the naps' minutes and `mainAsleepMinutes: null`. Its `dayKey` is the same day `/api/sleep/night?date=` answers for. On both, `meta.total` counts the days or nights in the filtered window. A day-sum page takes `limit` as asked (up to the list's 5000); a sleep page holds at most 1000 nights, and `meta.limit` echoes the value applied. parameters: - in: query name: type schema: type: string enum: - WEIGHT - BLOOD_PRESSURE_SYS - BLOOD_PRESSURE_DIA - PULSE - BODY_FAT - SLEEP_DURATION - ACTIVITY_STEPS - BLOOD_GLUCOSE - TOTAL_BODY_WATER - BONE_MASS - OXYGEN_SATURATION - HEART_RATE_VARIABILITY - RESTING_HEART_RATE - ACTIVE_ENERGY_BURNED - FLIGHTS_CLIMBED - WALKING_RUNNING_DISTANCE - VO2_MAX - BODY_TEMPERATURE - FAT_FREE_MASS - FAT_MASS - MUSCLE_MASS - SKIN_TEMPERATURE - PULSE_WAVE_VELOCITY - VASCULAR_AGE - VISCERAL_FAT - AUDIO_EXPOSURE_ENV - AUDIO_EXPOSURE_HEADPHONE - TIME_IN_DAYLIGHT - WALKING_STEADINESS - AUDIO_EXPOSURE_EVENT - RESPIRATORY_RATE - BODY_MASS_INDEX - LEAN_BODY_MASS - WALKING_HEART_RATE_AVERAGE - WALKING_ASYMMETRY - WALKING_DOUBLE_SUPPORT - WALKING_STEP_LENGTH - WALKING_SPEED - CARDIO_RECOVERY - WRIST_TEMPERATURE - FALL_COUNT - SIX_MINUTE_WALK_DISTANCE - STAIR_ASCENT_SPEED - STAIR_DESCENT_SPEED - BREATHING_DISTURBANCES - IRREGULAR_RHYTHM_NOTIFICATION - HIGH_HEART_RATE_EVENT - LOW_HEART_RATE_EVENT - WALKING_STEADINESS_EVENT - BREATHING_DISTURBANCE_EVENT - RECOVERY_SCORE - STRESS_SCORE - STRAIN_SCORE - HRV_RMSSD - DAY_STRAIN - WORKOUT_STRAIN - SLEEP_PERFORMANCE - SLEEP_EFFICIENCY - SLEEP_CONSISTENCY - SLEEP_NEED - ENERGY_EXPENDITURE_KJ - AVERAGE_HEART_RATE - MAX_HEART_RATE - SLEEP_DISTURBANCE_COUNT - ANS_CHARGE - CARDIO_LOAD - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION - RESILIENCE - PHQ9_SCORE - GAD7_SCORE - GRIP_STRENGTH - PAIN_NRS - WAIST_CIRCUMFERENCE - WAIST_TO_HEIGHT - WHO5_SCORE - SCI_SCORE - EXERCISE_MINUTES - STAND_MINUTES - STAND_HOURS - MINDFUL_MINUTES - STATE_OF_MIND_VALENCE - AFIB_BURDEN - INDOOR_CO2 - INDOOR_TEMPERATURE - INDOOR_HUMIDITY - in: query name: from schema: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - in: query name: to schema: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - in: query name: limit schema: default: 100 type: integer minimum: 1 maximum: 5000 - in: query name: offset schema: default: 0 type: integer minimum: 0 maximum: 9007199254740991 - in: query name: sortBy schema: default: measuredAt type: string enum: - type - value - measuredAt - source - in: query name: sortDir schema: default: desc type: string enum: - asc - desc - in: query name: aggregate schema: type: string enum: - raw - daily - weekly - monthly - in: query name: source schema: type: string enum: - rollup - in: query name: sourceEq schema: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT - in: query name: appleHealthMirror schema: type: string enum: - "true" - in: query name: valueMin schema: type: number - in: query name: valueMax schema: type: number - in: query name: groupBy schema: type: string enum: - day - in: query name: dayKey schema: type: string pattern: ^\d{4}-\d{2}-\d{2}$ responses: "200": description: Measurement page. content: application/json: schema: $ref: "#/components/schemas/ListMeasurementsResponse" "401": *a1 "403": *a15 "422": *a4 "429": *a5 post: tags: - Measurements summary: Create one measurement (or a small array) description: >- Single ingest. The body may also be a bare ARRAY of the same objects — the mode the iOS client uses for a combined blood-pressure + pulse or dual-value glucose write — in which case `data` is the array of created rows in request order. Use `/api/measurements/batch` for Apple Health upload streams. Sleep stages by name (since v1.39.1): an object with `type: SLEEP_DURATION`, a `sleepStage` of `IN_BED`, `ASLEEP`, `AWAKE`, `CORE`, `DEEP` or `REM`, and the segment as `startDate` + `endDate` or as `measuredAt` (its end) + `value` (minutes). It is stored and deduplicated exactly as the batch route stores a HealthKit sleep sample, so a bridge can send stages without the HealthKit codepoints. See `NamedSleepStageEntry`. Also reachable with a narrow `measurements:write` Bearer, which is the supported way to push readings from a scale, a watch bridge or a home-automation rule. That credential is confined to its owner's own record — a request carrying the account selector is refused 403 before any sharing grant is read, however the record is actually shared — and it may not attribute a source at all: rows it writes carry `source: EXTERNAL`, resolved from the credential, so a bridged reading stays distinguishable from a hand-typed one. A body naming ANY source, `MANUAL` included, is refused 422 (`measurement.create.source_not_permitted`). The GET on this same path, and the PUT and DELETE beside it, name no scope and so refuse it — though the rows themselves stay editable by their owner, unlike a connected provider's. parameters: - *a6 requestBody: required: true content: application/json: schema: anyOf: - $ref: "#/components/schemas/CreateMeasurementRequest" - $ref: "#/components/schemas/NamedSleepStageEntry" - type: array items: $ref: "#/components/schemas/CreateMeasurementRequest" responses: "200": description: "Named sleep-stage body only: the segment was already stored (`status: duplicate`) or a stored one was corrected in place (`status: updated`). Every other body answers 201 or an error, as before." content: application/json: schema: $ref: "#/components/schemas/NamedSleepStageDuplicateResponse" "201": description: "Created. A single resource for the object body; an array of resources, in request order, for the array body; for a named sleep-stage body, the stored row with `status: inserted`." content: application/json: schema: $ref: "#/components/schemas/CreateMeasurementResponse" "401": *a1 "403": *a15 "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. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": description: Validation failed, or (`measurement.create.source_not_permitted`) a narrow `measurements:write` credential named a source — any source. Refused rather than relabelled, so the caller can fix the payload instead of discovering rows it did not ask for. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a16 /api/measurements/{id}: get: tags: - Measurements summary: Measurement detail description: Single stored row, scoped to the caller. A row owned by another user surfaces as 404 (existence channel sealed). parameters: - in: path name: id schema: type: string required: true responses: "200": description: Measurement. content: application/json: schema: $ref: "#/components/schemas/GetMeasurementResponse" "401": *a1 "403": *a15 "404": description: Measurement not found (or owned by another user). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 put: tags: - Measurements summary: Update a measurement description: Edits a manually entered row. Rows owned by a connected source are refused with 409 `measurement.update.server_owned_source`; a timestamp collision with an existing row is refused with 409 `measurement.duplicate_timestamp`. parameters: - in: path name: id schema: type: string required: true requestBody: required: true content: application/json: schema: type: object properties: value: type: number measuredAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ notes: anyOf: - type: string maxLength: 200 - type: "null" responses: "200": description: Updated measurement. content: application/json: schema: $ref: "#/components/schemas/UpdateMeasurementResponse" "401": *a1 "403": *a15 "404": description: Measurement not found (or owned by another user). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "409": description: "Refused: the row comes from a connected source (`meta.errorCode` = `measurement.update.server_owned_source`), or another row already carries this timestamp (`measurement.duplicate_timestamp`)." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 delete: tags: - Measurements summary: Delete a measurement description: Soft-deletes the row (tombstone) so paired clients can reconcile the deletion. parameters: - in: path name: id schema: type: string required: true responses: "200": description: Deleted. content: application/json: schema: $ref: "#/components/schemas/DeleteMeasurementResponse" "401": *a1 "403": *a15 "404": description: Measurement not found (or owned by another user). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/measurements/batch: post: tags: - Measurements summary: Apple Health batch ingest description: >- Up to 500 HealthKit entries per call. Idempotent via the `Idempotency-Key` header (replay window 24h). Per-entry status lets the iOS client advance its sync cursor accurately. v1.4.25 W8c adds an optional `deviceType` per entry — feed it from `HKDevice.model` so the cross-source canonical picker can break Apple-Watch-vs-iPhone ties; null/absent stays backward-compatible with v1.4.23 clients. Also reachable with a narrow `measurements:write` Bearer, for a bridge that is not the phone. Such a credential writes only on its owner's own record; its entries are attributed `EXTERNAL` and it may not name a source itself, so an entry carrying one — `MANUAL` and `APPLE_HEALTH` alike — is refused 422 (`measurement.batch.source_not_permitted`). `EXTERNAL` also gives those writes their own `(userId, type, source, externalId)` namespace, so a bridge's ids can never collide with the phone's. The call does not advance the native sync checkpoint — `lastSyncedAt` means the CLIENT delivered what it was holding, and a third-party push is not that. parameters: - *a6 requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/AppleHealthBatchRequest" responses: "200": description: Batch processed. content: application/json: 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 (`measurement.batch.invalid`, with every issue under `details.issues`), or (`measurement.batch.source_not_permitted`) carried an entry naming a source, any source, under a narrow `measurements:write` credential. Nothing was written in any of the three cases, and all three are permanent for the batch as sent: resending it unchanged is refused the same way. Which issues belong to an entry: every issue caused by one entry has a `path` that starts `entries.` (the entry's index in the request, then the field, e.g. `entries.3.startDate`, or just `entries.4` when the entry is not an object). Those are the only per-entry issues, so a client may drop the named entries and resend the rest. An issue whose `path` is `\"\"` (the body is not an object), `entries` (missing, not an array, or empty) or `syncTrigger` is about the batch itself, and no split recovers it." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 /api/measurements/by-external-ids: delete: tags: - Measurements summary: Delete measurements by external ID (iOS deletion-sync) description: "Removes the user's HealthKit-ingested measurement rows (source `APPLE_HEALTH`) whose externalId is in the request list. Only app-minted rows are eligible: an externalId that maps to an integration- or manually-sourced row (a colliding externalUUID) is a no-op, never a delete. Rows owned by another user are silently skipped (cross-user 404 guard). Up to 500 externalIds per call." requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/MeasurementsDeleteByExternalIdsRequest" responses: "200": description: Delete batch processed. content: application/json: schema: $ref: "#/components/schemas/MeasurementsDeleteByExternalIdsResponse" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/measurements/bulk-delete: post: tags: - Measurements summary: Bulk soft-delete measurements description: Soft-deletes (tombstones) up to 200 of the caller's measurement rows in one call, mirroring the single-DELETE contract. Idempotent via the `Idempotency-Key` header; rate-limited `measurements:bulk-delete:`. Forged / foreign / already-deleted ids are silently skipped — `deleted` is the count of rows actually tombstoned. The deletions surface in `/api/sync/changes` as tombstones. parameters: - *a6 requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/MeasurementsBulkDeleteRequest" responses: "200": description: Bulk delete processed. content: application/json: schema: $ref: "#/components/schemas/MeasurementsBulkDeleteResponse" "401": *a1 "403": *a15 "409": *a7 "422": *a4 "429": *a5 /api/measurements/restore: post: tags: - Measurements summary: Restore soft-deleted measurements description: Un-tombstones up to 200 of the caller's soft-deleted measurement rows in one call — the delete-Undo counterpart to `/api/measurements/bulk-delete`. Idempotent via the `Idempotency-Key` header; rate-limited `measurements:restore:`. Forged / foreign / not-deleted ids are silently skipped — `restored` is the count of rows actually un-tombstoned. The restored rows re-surface in `/api/sync/changes` as upserts. parameters: - *a6 requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/MeasurementsRestoreRequest" responses: "200": description: Restore processed. content: application/json: schema: $ref: "#/components/schemas/MeasurementsRestoreResponse" "401": *a1 "403": *a15 "409": *a7 "422": *a4 "429": *a5 /api/sleep/night: get: tags: - Measurements summary: Reconstructed sleep night (hypnogram source, v1.11.5) description: "Returns one night's reconstructed sleep session for the phase-progression (hypnogram) view: the canonical source's stage segments (each with an absolute start/end), the per-stage breakdown, asleep/in-bed/awake totals, the mid-sleep awakenings count, and same-wake-day naps surfaced separately. Session-clustered, keyed by the local wake day, collapsed to one source via the sleep priority ladder so two sources never overlay. `date` omitted returns the most recent night with a main sleep, however old; later days of naps alone are stepped over and stay reachable by `date`. An account that has only ever napped gets its latest day of naps. Any past date is answered, with no window cut. A read-only view over existing per-stage SLEEP_DURATION rows — no schema, no new measurement type." parameters: - in: query name: date schema: description: Wake-day key (YYYY-MM-DD). Omit for the most recent night with a main sleep. type: string pattern: ^\d{4}-\d{2}-\d{2}$ description: Wake-day key (YYYY-MM-DD). Omit for the most recent night with a main sleep. responses: "200": description: Reconstructed night. content: application/json: schema: $ref: "#/components/schemas/SleepNightResponse" "401": *a1 "403": &a17 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. 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: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/sleep/rhythm: get: tags: - Measurements summary: Sleep rhythm — sleep-debt + chronotype (v1.17.0) description: "Returns the two server-authoritative sleep-timing signals the Sleep page + iOS render off the same canonical night reconstruction the Sleep Score uses: the outstanding `sleepDebt` balance over the rolling window (calm `partial` state under the night threshold) and MCTQ `chronotype` (MSF/MSFsc band + social jetlag, `learning` state until enough free-day nights exist). Free vs work nights default to weekend = free in the user's timezone (no work calendar). A read-only view over existing per-stage SLEEP_DURATION rows — no schema, no new measurement type." responses: "200": description: Sleep-debt + chronotype DTO. content: application/json: schema: $ref: "#/components/schemas/SleepRhythmResponse" "401": *a1 "403": *a17 "422": *a4 "429": *a5 /api/workouts: get: tags: - Measurements summary: List workouts (v1.4.32) description: "Paginated workout list with cross-source canonical-row dedup. The picker reads the per-user source-priority ladder and clusters workouts of one sport whose starts lie less than five minutes after the cluster's first start — twin workouts (Apple Watch + Withings ScanWatch) collapse to a single canonical row per cluster. A twin just outside `since` / `until` still decides which row inside the range is canonical, so the list agrees with `canonicalId` on the detail. Field names mirror the iOS handoff contract: `distanceM`, `activeEnergyKcal`, `avgHr`, `maxHr`." parameters: - in: query name: limit schema: type: integer minimum: 1 maximum: 200 - in: query name: offset schema: type: integer minimum: 0 maximum: 9007199254740991 - in: query name: since schema: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - in: query name: until schema: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - in: query name: sportType schema: type: string responses: "200": description: Canonical workout page. content: application/json: schema: $ref: "#/components/schemas/ListWorkoutsResponse" "401": *a1 "403": *a17 "422": *a4 "429": *a5 /api/workouts/{id}: get: tags: - Measurements summary: Workout detail (v1.4.32) description: Single-workout envelope. Owns the optional `WorkoutRoute` GeoJSON geometry + `canonicalId` pointer that resolves to the cluster winner so deep-links into non-canonical twin rows can redirect cleanly. Additive enrichment fields (`hrSeries`, `zones`, `splits`, `sportContext`) are computed server-side. `aiInsight` is a pure read of a per-workout paragraph written by a background job when the workout landed; it is null for every workout that predates the feature or was re-synced, and while the `workoutInsights` AI capability is unavailable (`ai` says why), and reading this endpoint never generates one. `compact=1` (sent by the web client) drops the raw `samples.samples` HR blob and `route.sampleTimestamps` array from the response; without it the payload is byte-identical to the v1.4.32 contract. Cross-user rows surface as 404 (existence channel sealed). parameters: - in: path name: id schema: type: string required: true - in: query name: compact schema: type: string enum: - "1" responses: "200": description: Workout detail. content: application/json: schema: $ref: "#/components/schemas/GetWorkoutResponse" "401": *a1 "403": *a11 "404": description: Workout not found (or owned by another user). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 delete: tags: - Measurements summary: Delete a workout entered by hand description: "Removes a `MANUAL` workout outright, with its child rows, and any personal record that workout set; a silent detection pass then re-derives the previous best. A workout from any other source answers 409 (`workout.delete.synced_source`), because the next sync would write it back. Own record only: a cookie session or a wildcard token. Another user's row answers 404." parameters: - in: path name: id schema: type: string required: true responses: "200": description: Deleted. content: application/json: schema: $ref: "#/components/schemas/DeleteWorkoutResponse" "401": *a1 "403": *a9 "404": description: Workout not found (or owned by another user). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "409": description: The workout was synced from a device or service, not entered by hand. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/workouts/batch: post: tags: - Measurements summary: Typed workout batch ingest (v1.4.25 W16b) description: >- Server-side ingest endpoint for HKWorkout records (iOS) and Withings activity rows (server-to-server). Up to 100 workouts per call; nested route geometry (GeoJSON LineString) is capped at 20 000 points and stored in a 1:1 `WorkoutRoute` row keyed by `workoutId`. Idempotent via the `Idempotency-Key` header (replay window 24h). Per-entry status (`inserted | duplicate | enriched | updated | skipped`) lets the iOS sync cursor checkpoint accurately. An entry matching a stored workout is first-write-wins for the workout row, except a hand-entered one (`source: MANUAL` with a `manual:` externalId), which a resend with other values overwrites and reports `updated`; when it carries a `samples` array and the stored workout has no heart-rate series, the series is attached and the entry reports `enriched` (repeatable: a workout that already has a series is a no-op). An entry whose `samples` array fails validation is refused on its own with `reason: "invalid_samples"` — the rest of the batch still lands. The request body ceiling is 5 MB enforced at the HTTP layer — clients above the ceiling receive a 413 with `workout.batch.payload_too_large`. **Narrow token.** Also reachable with a `workouts:write` Bearer (minted at `POST /api/tokens/workouts`), the supported way to push workouts from a bridge that is not the phone. This route is the scope's only one. Such a credential writes only on its owner's own record; its workouts are attributed `EXTERNAL`, resolved from the credential, and it may not name a source itself, so a batch in which any entry carries one, `MANUAL` and `APPLE_HEALTH` alike, is refused 422 (`workout.batch.source_not_permitted`) and nothing is written. `EXTERNAL` gives those writes their own `(userId, source, externalId)` namespace, so a bridge's ids can never collide with the phone's. Every other narrow token, `measurements:write` included, is refused 403. parameters: - *a6 requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CreateBatchWorkoutRequest" responses: "200": description: Batch processed. content: application/json: schema: $ref: "#/components/schemas/BatchWorkoutsResponse" "400": description: Batch validation failed (over-cap, schema reject, or oversized route). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 "403": *a9 "409": *a7 "413": description: Request body exceeds the 5 MB ceiling. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": description: A narrow `workouts:write` credential sent a batch in which an entry names a source, any source (`workout.batch.source_not_permitted`). Nothing was written; resending the batch unchanged is refused the same way. Omit `source` and the server attributes the rows `EXTERNAL`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 /api/activity/calendar: get: tags: - Measurements summary: Activity calendar against the person's own normal description: "A week (Monday to Sunday) or a month of activity, computed server-side without AI: per local day the step total (the canonical-source sum the steps chart draws), active minutes (the larger of exercise minutes and workout minutes), workouts by sport after the cross-source collapse, and a training-load score when one exists. Each day is placed against the person's own spread over the 84 days before the period; the period total against the median of the twelve weeks or six months before it, pro-rated while the period runs. Never a population norm. Weekly goals are reported per calendar row, the monthly step goal for a month. Owned by the Workouts module." parameters: - in: query name: period schema: default: month description: "Calendar span: one week (Monday to Sunday) or one month." type: string enum: - week - month description: "Calendar span: one week (Monday to Sunday) or one month." - in: query name: date schema: description: Any day inside the period to show (`YYYY-MM-DD`, the person's local day). Defaults to today. A day after today, or more than ten years back, is refused. type: string format: date pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ description: Any day inside the period to show (`YYYY-MM-DD`, the person's local day). Defaults to today. A day after today, or more than ten years back, is refused. responses: "200": description: The calendar. content: application/json: schema: $ref: "#/components/schemas/ActivityCalendarEnvelope" "401": *a1 "403": *a17 "422": *a4 "429": *a5 /api/activity/goals: get: tags: - Measurements summary: Activity goals description: "The person's own activity goals: workouts, active minutes and steps per week, steps per month. `null` = not set; there is no default. Owner only; a delegate reads the goals inside the calendar." responses: "200": description: The goals. content: application/json: schema: $ref: "#/components/schemas/ActivityGoalsEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 patch: tags: - Measurements summary: Set activity goals description: "Sets the goals the body names and keeps the rest; `null` clears a goal. Owner only: a shared or delegated session cannot change another person's goals." requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/ActivityGoalsPatch" responses: "200": description: The goals after the change. content: application/json: schema: $ref: "#/components/schemas/ActivityGoalsPatchEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a16 /api/measurements/provenance: get: tags: - Measurements summary: Which source a metric's day value comes from description: "For one metric and one UTC day: every source with readings, its day value (the total for a cumulative metric, sleep counting asleep stages only, the mean otherwise), its rank on the person's source-priority ladder, and which source the canonical readers use, with the reason. Computed by the same rank SQL the charts, rollups and summaries pick with, so the answer is what they show. A metric without a ladder reports `ranked: false` and no pick: every source counts there. Read-only." parameters: - in: query name: type schema: type: string enum: - WEIGHT - BLOOD_PRESSURE_SYS - BLOOD_PRESSURE_DIA - PULSE - BODY_FAT - SLEEP_DURATION - ACTIVITY_STEPS - BLOOD_GLUCOSE - TOTAL_BODY_WATER - BONE_MASS - OXYGEN_SATURATION - HEART_RATE_VARIABILITY - RESTING_HEART_RATE - ACTIVE_ENERGY_BURNED - FLIGHTS_CLIMBED - WALKING_RUNNING_DISTANCE - VO2_MAX - BODY_TEMPERATURE - FAT_FREE_MASS - FAT_MASS - MUSCLE_MASS - SKIN_TEMPERATURE - PULSE_WAVE_VELOCITY - VASCULAR_AGE - VISCERAL_FAT - AUDIO_EXPOSURE_ENV - AUDIO_EXPOSURE_HEADPHONE - TIME_IN_DAYLIGHT - WALKING_STEADINESS - AUDIO_EXPOSURE_EVENT - RESPIRATORY_RATE - BODY_MASS_INDEX - LEAN_BODY_MASS - WALKING_HEART_RATE_AVERAGE - WALKING_ASYMMETRY - WALKING_DOUBLE_SUPPORT - WALKING_STEP_LENGTH - WALKING_SPEED - CARDIO_RECOVERY - WRIST_TEMPERATURE - FALL_COUNT - SIX_MINUTE_WALK_DISTANCE - STAIR_ASCENT_SPEED - STAIR_DESCENT_SPEED - BREATHING_DISTURBANCES - IRREGULAR_RHYTHM_NOTIFICATION - HIGH_HEART_RATE_EVENT - LOW_HEART_RATE_EVENT - WALKING_STEADINESS_EVENT - BREATHING_DISTURBANCE_EVENT - RECOVERY_SCORE - STRESS_SCORE - STRAIN_SCORE - HRV_RMSSD - DAY_STRAIN - WORKOUT_STRAIN - SLEEP_PERFORMANCE - SLEEP_EFFICIENCY - SLEEP_CONSISTENCY - SLEEP_NEED - ENERGY_EXPENDITURE_KJ - AVERAGE_HEART_RATE - MAX_HEART_RATE - SLEEP_DISTURBANCE_COUNT - ANS_CHARGE - CARDIO_LOAD - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION - RESILIENCE - PHQ9_SCORE - GAD7_SCORE - GRIP_STRENGTH - PAIN_NRS - WAIST_CIRCUMFERENCE - WAIST_TO_HEIGHT - WHO5_SCORE - SCI_SCORE - EXERCISE_MINUTES - STAND_MINUTES - STAND_HOURS - MINDFUL_MINUTES - STATE_OF_MIND_VALENCE - AFIB_BURDEN - INDOOR_CO2 - INDOOR_TEMPERATURE - INDOOR_HUMIDITY description: The metric. required: true description: The metric. - in: query name: date schema: type: string format: date pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ description: The UTC day (`YYYY-MM-DD`). The source ladder picks one source per UTC day, so this is the day the pick is explained for. required: true description: The UTC day (`YYYY-MM-DD`). The source ladder picks one source per UTC day, so this is the day the pick is explained for. responses: "200": description: The day's sources and the pick. content: application/json: schema: $ref: "#/components/schemas/DayProvenanceEnvelope" "401": *a1 "403": *a15 "422": *a4 "429": *a5 /api/measurements/provenance/compare: get: tags: - Measurements summary: Two sources of one metric side by side description: "Per UTC day over up to 366 days: the day value from source `a` and from source `b`, and which source the ladder picked that day among all of the day's sources. The summary averages only the days both sources cover. Without `a` / `b` the two sources covering the most days are compared. Read-only." parameters: - in: query name: type schema: type: string enum: - WEIGHT - BLOOD_PRESSURE_SYS - BLOOD_PRESSURE_DIA - PULSE - BODY_FAT - SLEEP_DURATION - ACTIVITY_STEPS - BLOOD_GLUCOSE - TOTAL_BODY_WATER - BONE_MASS - OXYGEN_SATURATION - HEART_RATE_VARIABILITY - RESTING_HEART_RATE - ACTIVE_ENERGY_BURNED - FLIGHTS_CLIMBED - WALKING_RUNNING_DISTANCE - VO2_MAX - BODY_TEMPERATURE - FAT_FREE_MASS - FAT_MASS - MUSCLE_MASS - SKIN_TEMPERATURE - PULSE_WAVE_VELOCITY - VASCULAR_AGE - VISCERAL_FAT - AUDIO_EXPOSURE_ENV - AUDIO_EXPOSURE_HEADPHONE - TIME_IN_DAYLIGHT - WALKING_STEADINESS - AUDIO_EXPOSURE_EVENT - RESPIRATORY_RATE - BODY_MASS_INDEX - LEAN_BODY_MASS - WALKING_HEART_RATE_AVERAGE - WALKING_ASYMMETRY - WALKING_DOUBLE_SUPPORT - WALKING_STEP_LENGTH - WALKING_SPEED - CARDIO_RECOVERY - WRIST_TEMPERATURE - FALL_COUNT - SIX_MINUTE_WALK_DISTANCE - STAIR_ASCENT_SPEED - STAIR_DESCENT_SPEED - BREATHING_DISTURBANCES - IRREGULAR_RHYTHM_NOTIFICATION - HIGH_HEART_RATE_EVENT - LOW_HEART_RATE_EVENT - WALKING_STEADINESS_EVENT - BREATHING_DISTURBANCE_EVENT - RECOVERY_SCORE - STRESS_SCORE - STRAIN_SCORE - HRV_RMSSD - DAY_STRAIN - WORKOUT_STRAIN - SLEEP_PERFORMANCE - SLEEP_EFFICIENCY - SLEEP_CONSISTENCY - SLEEP_NEED - ENERGY_EXPENDITURE_KJ - AVERAGE_HEART_RATE - MAX_HEART_RATE - SLEEP_DISTURBANCE_COUNT - ANS_CHARGE - CARDIO_LOAD - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION - RESILIENCE - PHQ9_SCORE - GAD7_SCORE - GRIP_STRENGTH - PAIN_NRS - WAIST_CIRCUMFERENCE - WAIST_TO_HEIGHT - WHO5_SCORE - SCI_SCORE - EXERCISE_MINUTES - STAND_MINUTES - STAND_HOURS - MINDFUL_MINUTES - STATE_OF_MIND_VALENCE - AFIB_BURDEN - INDOOR_CO2 - INDOOR_TEMPERATURE - INDOOR_HUMIDITY description: The metric. required: true description: The metric. - in: query name: from schema: type: string format: date pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ description: First UTC day, inclusive. required: true description: First UTC day, inclusive. - in: query name: to schema: type: string format: date pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ description: Last UTC day, inclusive. required: true description: Last UTC day, inclusive. - in: query name: a schema: description: First source. Defaults to the source covering the most days. type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT description: First source. Defaults to the source covering the most days. - in: query name: b schema: description: Second source. Defaults to the next one. type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT description: Second source. Defaults to the next one. responses: "200": description: The comparison. content: application/json: schema: $ref: "#/components/schemas/SourceComparisonEnvelope" "401": *a1 "403": *a15 "422": *a4 "429": *a5 /api/devices: post: tags: - Devices summary: Register native device + APNs token description: Idempotent upsert by `token`. APNs token + environment are paired — supplying one without the other returns 422. Cross-user re-registration of either identifier returns 409. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/DeviceRegisterRequest" responses: "201": description: Device registered or refreshed. content: application/json: schema: $ref: "#/components/schemas/DeviceRegisterResponse" "401": *a1 "403": *a9 "409": description: Device or APNs token already registered to another user. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/devices/{id}: delete: tags: - Devices summary: Revoke a device and everything bound to it description: "Deletes the device and, in one transaction, revokes every refresh and access token bound to it — the cleanup a native client runs when it rotates its own registration. The counts come back so the caller can log what the cascade actually killed. Scoped to the caller's own devices: an id belonging to somebody else is indistinguishable from one that does not exist. This is a second URL for `DELETE /api/auth/me/devices/{id}`; the two share one transactional helper and behave identically, and the duplication exists so the native rotation loop has a path that is not the settings-surface one. Auth via cookie or Bearer." parameters: - name: id in: path required: true schema: type: string description: Device id. responses: "200": description: The device and its tokens were revoked. content: application/json: 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. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/medications/intake: get: tags: - Medications summary: Today's doses, or per-day compliance description: "Two reads behind one path, chosen by `scope`. `today` lists every dose slot for the current local day across all medications, ordered by slot. `compliance` returns per-day scheduled-versus-taken buckets over the trailing `days`. The `today` scope WRITES before it reads: it projects the pending intake rows for every active schedule whose window opens today and idempotently backfills the missing ones, because a daily schedule has no row to read until the reminder worker reaches the end of its dose window — so a plain read would return an empty morning. The `compliance` scope is cached per (record, days, timezone). Delegable at READ level over the `medications` section. Not module-gated. Cookie or Bearer auth." parameters: - in: query name: scope schema: type: string enum: - today - compliance description: "`today` lists the account's intake events for the current local day across every medication; `compliance` returns per-day scheduled-versus-taken buckets." required: true description: "`today` lists the account's intake events for the current local day across every medication; `compliance` returns per-day scheduled-versus-taken buckets." - in: query name: days schema: default: 30 description: Trailing window for the `compliance` scope, 1..365, default 30. Ignored by the `today` scope. type: integer minimum: 1 maximum: 365 description: Trailing window for the `compliance` scope, 1..365, default 30. Ignored by the `today` scope. responses: "200": description: "`TodayIntakeEntry[]` for `scope=today`, `MedicationComplianceDayBucket[]` for `scope=compliance`. The array is the whole payload — there is no wrapper object inside `data`." content: application/json: schema: $ref: "#/components/schemas/MedicationIntakeAggregateEnvelope" "401": *a1 "403": *a15 "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 the issue messages stripped of echoed input. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 post: tags: - Medications summary: Mark a dose taken, skipped or snoozed description: "Updates one intake event by id and returns the full row. A `taken` toggle re-runs slot attribution, so an off-window or edited take re-binds to the right slot instead of leaving `scheduledFor` stale — and when it re-binds to a DIFFERENT slot the source row is tombstoned and the dose converges onto the target slot, which means the returned row's `id` can differ from the `intakeId` that was sent. Inventory is consumed exactly once on a genuine transition into taken and refunded on the way out. A `snoozed` toggle writes the deferral onto the MEDICATION, deferring every pending dose of it. Two transitions are refused for a DELEGATE and stay open to the owner: snoozing, because the field is unbounded and every surface that exists to make delegation visible would describe years of silenced reminders as `marked a dose`; and flipping an already-resolved event, because marking an open dose is a contribution while changing a decision the owner already recorded is an edit. Delegable at WRITE level over the `medications` section, and the owner is notified when somebody else marks their dose. Cookie or Bearer auth." requestBody: required: true content: application/json: schema: type: object properties: intakeId: type: string minLength: 1 description: The intake event to act on. status: type: string enum: - taken - skipped - snoozed description: "`snoozed` writes the deferral onto the MEDICATION row rather than the event, so it defers every pending dose of that medication and not just this one." takenAt: description: When the dose was taken. Defaults to now. Ignored unless the status is `taken`. type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ snoozedUntil: description: How long to defer. Defaults to 30 minutes from now. Deliberately UNBOUNDED — nothing here caps how far out it may sit. Ignored unless the status is `snoozed`. type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ injectionSite: description: Site recorded alongside a `taken` toggle, validated server-side against the medication's effective allowed set. Dropped for a non-injection medication, for one with site tracking off, and for a skip or snooze. type: string enum: - ABDOMEN_LEFT - ABDOMEN_RIGHT - ABDOMEN_UPPER_LEFT - ABDOMEN_UPPER_RIGHT - THIGH_LEFT - THIGH_RIGHT - UPPER_ARM_LEFT - UPPER_ARM_RIGHT forceSlotInstant: description: Pins a late take onto a named real slot of the medication instead of letting dose-window membership decide. Must BE one of that medication's slots — anything else is refused rather than approximated. Ignored for a skip or snooze. type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - intakeId - status responses: "200": description: The updated intake event. On a slot move this is the CONVERGED row, whose id may differ from the requested one. content: application/json: schema: $ref: "#/components/schemas/MedicationIntakeStatusEnvelope" "401": *a1 "403": 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: 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`). 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: $ref: "#/components/schemas/ErrorEnvelope" "404": description: No live intake event with that id for this record — an unknown id, a tombstoned one, and one belonging to another record are indistinguishable. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": description: "Body validation failed (multi-issue envelope, breadcrumbed as `medications.intake.update.validation-failed`), or `meta.errorCode` = `medications.intake.injection_site.disallowed` when the site is outside the medication's effective allowed set, or `medications.intake.force_slot.invalid` when `forceSlotInstant` is not a real slot of the medication, or (v1.42.0) `medication.intake.notTracked` when the event is still open and its medication is kept as a record only (`trackIntake: false`): resolving or snoozing it would record a new dose that is counted nowhere. Correcting an event that is already taken or skipped stays open. The coded refusals carry no issue list — branch on the code." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a16 /api/medications/{id}/glp1: get: tags: - Medications summary: GLP-1 detail — titration, recent injections, supply description: "Returns the extras the GLP-1 card variant, the dashboard tile and the doctor-report section read: the full dose-change history, the last twelve injections with their sites, and the running supply math carrying the same low-stock verdict the daily notification uses. Delegable at READ level over the `medications` section, the level `/inventory`, `/side-effects` and `/cadence` ask for the same medication's data — and strictly narrower than the MANAGE-level write the POST on this path already permits. Cookie or Bearer auth." parameters: - in: path name: id schema: type: string required: true responses: "200": description: The GLP-1 detail block. content: application/json: schema: $ref: "#/components/schemas/Glp1DetailResponseEnvelope" "401": *a1 "403": *a15 "404": description: Medication not found (or owned by another account). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 post: tags: - Medications summary: Record a titration step or a legacy stock correction description: "The body carries exactly one of `doseChange` or `inventory`; carrying both or neither is refused. `doseChange` writes a titration step with its note encrypted at rest and evicts the medication caches so the card's runway reflects the new daily-dose estimate. `inventory` is DEPRECATED: it appends to the legacy running-sum pen ledger, which reads back only while the medication has no per-container inventory items — new callers register containers through the inventory endpoints instead. Rate-limited 30/min keyed on the ACTOR, so a delegate burns their own allowance and cannot collect a fresh one by switching records. Audits both branches as `medication.glp1.update`. Delegable at MANAGE level over the `medications` section: a titration step is record data and the dose is what the server then acts on." parameters: - in: path name: id schema: type: string required: true - *a6 requestBody: required: true content: application/json: schema: type: object properties: doseChange: type: object properties: effectiveFrom: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ doseValue: type: number minimum: 0 maximum: 100 doseUnit: type: string minLength: 1 maxLength: 10 note: anyOf: - type: string maxLength: 500 - type: "null" required: - effectiveFrom - doseValue - doseUnit inventory: type: object properties: delta: type: integer minimum: -100 maximum: 100 description: "Deprecated since v1.16.10: pen-count delta on the legacy running-sum ledger. Register containers via the inventory endpoints instead; reads use the ledger only while the medication has no inventory items." reason: type: string minLength: 1 maxLength: 200 required: - delta - reason responses: "201": description: Created. The body carries `doseChange` OR `inventory` — whichever branch the request took — and never both. content: application/json: schema: $ref: "#/components/schemas/Glp1PostResponseEnvelope" "401": *a1 "403": *a15 "404": description: Medication not found (or owned by another account). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "409": *a7 "422": *a4 "429": *a5 /api/medications/{id}/api-endpoint: get: tags: - Medications summary: Is the per-medication ingest endpoint enabled? description: "Presence only: whether any live token carries this medication's ingest scope, and how many. No token, hash or prefix is on this wire — tokens are stored as an HMAC and there is no path back to the plaintext. Refused with 403 when the operator has switched the API off globally, which is checked BEFORE ownership, so that refusal is visible even for a medication the caller does not hold. NOT delegable: the caller is always resolved as themselves, which is right for a credential surface. Cookie or Bearer auth." parameters: - in: path name: id schema: type: string required: true responses: "200": description: Whether the endpoint is on. content: application/json: schema: $ref: "#/components/schemas/MedicationApiEndpointStateEnvelope" "401": *a1 "403": 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: $ref: "#/components/schemas/ErrorEnvelope" "404": description: Medication not found (or owned by another account). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 put: tags: - Medications summary: Enable or disable the per-medication ingest endpoint description: "Enabling mints an API token scoped to `medication::ingest` and returns the RAW token — the one and only time it is ever returned, because the stored value is an HMAC and cannot be reversed. Read `created` rather than inferring from the token: enabling an already-enabled endpoint answers 200 with `token: null` and `created: false` and mints nothing, so a lost token is recovered by disabling and enabling again, not by re-reading. Disabling revokes every live token carrying the scope and answers a DIFFERENT shape — `revokedTokenCount` instead of `activeTokenCount` — so one decoder for both outcomes will find the key missing. The body is read by hand rather than parsed: `enabled` is true only for the literal boolean, so any other value disables. Minted tokens carry no expiry. Both branches audit. Refused with 403 when the operator has switched the API off globally. NOT delegable. Cookie or Bearer auth." parameters: - in: path name: id schema: type: string required: true requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/MedicationApiEndpointPutRequest" responses: "200": description: "Either the endpoint was disabled (`MedicationApiEndpointDisabled`), or it was already enabled and nothing was minted (`MedicationApiEndpointEnabled` with `token: null`)." content: application/json: schema: $ref: "#/components/schemas/MedicationApiEndpointPutEnvelope" "201": description: A token was minted. `token` carries the raw `hlk_` value — store it now, it is never shown again. content: application/json: schema: $ref: "#/components/schemas/MedicationApiEndpointCreatedEnvelope" "401": *a1 "403": 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: $ref: "#/components/schemas/ErrorEnvelope" "404": description: Medication not found (or owned by another account). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "413": description: Request body exceeds 64 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": description: The body was not valid JSON. A single-message envelope with no issue list. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 /api/medications/{id}/phase-config: get: tags: - Medications summary: Read a medication's reminder phase configuration description: How long each reminder phase runs for this medication. When no row is stored the DEFAULTS are returned rather than a 404 or a null — and the way to tell the two apart is that the defaults carry no `id` and no `medicationId`. Delegable at READ level over the `medications` section. Cookie or Bearer auth. parameters: - in: path name: id schema: type: string required: true responses: "200": description: The stored configuration, or the defaults. content: application/json: schema: $ref: "#/components/schemas/ReminderPhaseConfigEnvelope" "401": *a1 "403": *a15 "404": description: Medication not found (or owned by another account). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 put: tags: - Medications summary: Replace a medication's reminder phase configuration description: "Upserts the whole configuration — every field is required, so this replaces rather than merges. The stored row is built field by field from the parsed body rather than spread, so a future schema field cannot land here unnoticed. NOT delegable, unlike the GET beside it: the write resolves the caller as themselves, so a manager can read a shared record's phase configuration and cannot change it. Body capped at 64 KiB. Cookie or Bearer auth." parameters: - in: path name: id schema: type: string required: true requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/ReminderPhaseConfigPutRequest" responses: "200": description: The stored configuration, with its row id. content: application/json: schema: $ref: "#/components/schemas/ReminderPhaseConfigSavedEnvelope" "401": *a1 "403": *a9 "404": description: Medication not found (or owned by another account). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 delete: tags: - Medications summary: Reset a medication's reminder phases to the defaults description: Drops the stored row so the GET falls back to the defaults. Idempotent — resetting a medication that has no row succeeds and reports the same thing. NOT delegable, matching the PUT. Cookie or Bearer auth. parameters: - in: path name: id schema: type: string required: true responses: "200": description: Reset. The response confirms the write and does not echo the defaults. content: application/json: schema: $ref: "#/components/schemas/ReminderPhaseConfigResetEnvelope" "401": *a1 "403": *a9 "404": description: Medication not found (or owned by another account). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/medications/{id}/side-effects: get: tags: - Medications summary: List a medication's side-effect log description: The record's side-effect entries for this medication, newest first, optionally bounded by a window. `from` is inclusive and `to` is EXCLUSIVE. Notes are decrypted on read and the ciphertext column never leaves the server. Delegable at READ level over the `medications` section. Cookie or Bearer auth. parameters: - in: path name: id schema: type: string required: true - in: query name: from schema: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - in: query name: to schema: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - in: query name: limit schema: default: 50 type: integer minimum: 1 maximum: 200 responses: "200": description: The entries, plus a `meta.total` that counts THIS PAGE rather than the whole log — it is the returned array's length, so it can never exceed `limit`. content: application/json: schema: $ref: "#/components/schemas/ListMedicationSideEffectsEnvelope" "401": *a1 "403": *a15 "404": description: Medication not found (or owned by another account). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 post: tags: - Medications summary: Log a side effect description: "Records one entry against the medication. The category is derived server-side from `entry`, so a client cannot stamp a row with one that contradicts it. The note is encrypted at rest. Rate-limited 30/min keyed on the ACTOR, which is the condition this verb was admitted for delegation on: a delegate burns their own allowance rather than locking the owner out of their own log, and cannot collect a fresh one by switching records. Delegable at WRITE level over the `medications` section. Body capped at 64 KiB. Cookie or Bearer auth." parameters: - in: path name: id schema: type: string required: true - *a6 requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CreateMedicationSideEffectRequest" responses: "201": description: The created entry, with its note decrypted. content: application/json: schema: $ref: "#/components/schemas/CreateMedicationSideEffectEnvelope" "401": *a1 "403": *a15 "404": description: Medication not found (or owned by another account). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "409": *a7 "422": *a4 "429": description: More than 30 side-effect writes in the trailing minute for this ACTOR. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/medications/{id}/side-effects/{logId}: delete: tags: - Medications summary: Remove one side-effect entry description: "A HARD delete — the row is removed, not tombstoned, and nothing on the sync feed carries the removal. It is allowed at any time rather than inside a retraction window, deliberately: a side-effect entry is not a clinical record, the account owns it, and a stale mis-entry should never be undeletable. What survives is the audit row, which carries the entry, the severity and the date it happened, so the deletion is reconstructable from the trail even though the row is gone. Delegable at MANAGE level over the `medications` section. Cookie or Bearer auth." parameters: - in: path name: id schema: type: string required: true - in: path name: logId schema: type: string required: true responses: "200": description: The entry was deleted. content: application/json: schema: $ref: "#/components/schemas/DeleteMedicationSideEffectEnvelope" "401": *a1 "403": *a15 "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. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/medications/{id}/intake/{eventId}: put: tags: - Medications summary: Correct one dose description: "Edits a single intake event. Whenever `takenAt` or `skipped` changes, slot attribution is re-run and `scheduledFor` snaps to the matched window — or to the take's own time when it falls in none. `forceSlotInstant` overrides that with a named real slot of this medication; an explicit `null` UNPINS and re-attributes by band, resetting the binding provenance to automatic. A correction that lands on a DIFFERENT slot is not an in-place edit: the original row is tombstoned and the dose is re-created on the corrected slot, so the row id in the response can differ from the `eventId` in the path, and the tombstone rides the sync feed. A `takenAt` before the medication's start date is refused. Tombstoned events 404 rather than resurrect. Delegable at MANAGE level over the `medications` section. Body capped at 64 KiB. Cookie or Bearer auth." parameters: - in: path name: id schema: type: string required: true - in: path name: eventId schema: type: string required: true requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpdateMedicationIntakeEventRequest" responses: "200": description: The corrected event. On a slot move this is the RE-CREATED row, whose id differs from the one in the path. content: application/json: schema: $ref: "#/components/schemas/UpdateMedicationIntakeEventEnvelope" "401": *a1 "403": *a15 "404": description: No live event with that id under this medication and record. An unknown id and an already-tombstoned one are indistinguishable. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": description: "Body validation failed (multi-issue), or one of three coded refusals: `medications.intake.taken_at.before_start` when the edited take predates the medication's start day in the record's timezone, `medications.intake.force_slot.invalid` when the pinned instant is not a real slot, and `medications.intake.force_slot.occupied` when it is a real slot that another live dose already holds." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 delete: tags: - Medications summary: Undo one dose description: "A SOFT delete: the row stays, `deletedAt` is stamped and the sync counter bumps, so a client that was offline when it happened receives a tombstone keyed on the server id rather than silently keeping the dose forever. Every today, compliance and list read filters tombstones out, so it is invisible from here on. The inventory stamp is REFUNDED — undoing a dose puts the units back in the container — and the day's compliance rollup is recomputed, dropping the row entirely when the day now holds nothing. Undoing the single live dose of a one-shot medication reactivates that medication so the lists and the worker pick it back up. Re-deleting is harmless. Delegable at MANAGE level over the `medications` section. Cookie or Bearer auth." parameters: - in: path name: id schema: type: string required: true - in: path name: eventId schema: type: string required: true responses: "200": description: The dose was tombstoned and its inventory refunded. content: application/json: schema: $ref: "#/components/schemas/DeleteMedicationIntakeEventEnvelope" "401": *a1 "403": *a15 "404": description: No event with that id under this medication and record. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/medications/{id}/intake/bulk-delete: post: tags: - Medications summary: Undo several doses at once description: Tombstones up to 500 named intake events in one call, so a multi-select in the history preview does not fire N concurrent deletes. Ids are matched against THIS medication and THIS record; anything else is silently dropped rather than refused, so a partial match succeeds with a lower `deleted` count than the number of ids sent, and an id leaked from another medication's history deletes nothing. Every stamped row's inventory consumption is refunded before the sweep, and the compliance rollup is recomputed once per affected day rather than once per event. A POST rather than a DELETE because the ids ride a body. Re-posting already-tombstoned ids counts zero. Rate-limited 30/min keyed on the ACTOR. Delegable at MANAGE level over the `medications` section. Body capped at 256 KiB. Cookie or Bearer auth. parameters: - in: path name: id schema: type: string required: true requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/BulkDeleteMedicationIntakeRequest" responses: "200": description: "`deleted` is the REAL tombstone count, which can be lower than the ids sent — and is 0 when none matched, which is a success rather than a 404." content: application/json: schema: $ref: "#/components/schemas/BulkDeleteMedicationIntakeEnvelope" "401": *a1 "403": *a15 "404": description: Medication not found (or owned by another account). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": description: More than 30 bulk deletes in the trailing minute for this ACTOR. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/medications/{id}/intake/purge: delete: tags: - Medications summary: Clear a medication's whole intake history description: "Tombstones EVERY live intake event for this medication in one call, and deletes its compliance rollup rows outright. The rollups are the only hard deletion and they are recomputable, so nothing that cannot be rebuilt is destroyed: the intake rows stay with `deletedAt` set, which is what lets the sync feed emit a tombstone per row to a client that was offline. That was not always true — this was the one destructive verb in the family with nothing to reconstruct from, and a purge fired by mistake was final and invisible to sync. Deliberately does NOT refund inventory, unlike the single and bulk deletes: those mean `I did not take that dose` and put the units back, while a purge wipes a history as bookkeeping, and refunding every dose ever taken would inflate the current container count instead. A second purge counts zero. There is no confirmation step on the wire. Delegable at MANAGE level over the `medications` section. Cookie or Bearer auth." parameters: - in: path name: id schema: type: string required: true responses: "200": description: "`count` is how many live events were tombstoned." content: application/json: schema: $ref: "#/components/schemas/PurgeMedicationIntakeEnvelope" "401": *a1 "403": *a15 "404": description: Medication not found (or owned by another account). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/medications/{id}/intake/import: post: tags: - Medications summary: Import a dose history for one medication description: "Accepts 1..1000 `{ datum, uhrzeit }` rows and enqueues a background import, answering 202 with a job id and the URL to poll. The body may be the bare array or an object carrying one — the first array-valued property is taken. Identity comes from the INSTANT, which is also the grain the database enforces, so a re-import of the same file dedups. It did not always: an optional third field decided the replay key while presenting itself as a quantity, and a file that repeated one value down that column collapsed a month of history onto a single key. The field is gone from the contract rather than accepted-and-ignored, and a payload still carrying it imports unchanged. Timestamps are bounded on the FUTURE side only — importing years of history is the point, so the past side is open back to 1900. Only one import may run per medication at a time. Rate-limited 60/min keyed on the ACTOR. Delegable at MANAGE level over the `medications` section: the import is additive with duplicates skipped, every written row tombstones, and the `IMPORT` provenance it stamps stays true under delegation because it describes where the data came from and not who typed it. Body capped at 1 MiB. Cookie or Bearer auth." parameters: - in: path name: id schema: type: string required: true requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/MedicationIntakeImportRequest" responses: "202": description: "Accepted and enqueued. Nothing is imported yet: poll `statusUrl` for progress and the per-row skip reasons." content: application/json: schema: $ref: "#/components/schemas/MedicationIntakeImportAcceptedEnvelope" "401": *a1 "403": *a15 "404": description: Medication not found (or owned by another account). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "409": description: An import for this medication is already running. Wait for it rather than retrying. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": description: The payload did not parse. Multi-issue envelope with `meta.errorCode` = `medication.intake.import.invalid_format`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": description: More than 60 import submissions in the trailing minute for this ACTOR. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "503": description: The background worker is not available, so nothing was enqueued. Retry later. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/medications/intake/bulk: post: tags: - Medications summary: Bulk medication-intake backfill (iOS SyncMode) description: Up to 500 intake entries per call, mirroring the mood-entries bulk envelope so the iOS sync engine reuses one retry/cursor path. Idempotent via the `Idempotency-Key` header plus per-entry `idempotencyKey` / `externalId`. Per-entry status (`inserted` / `updated` / `duplicate` / `skipped`) lets the client advance its cursor. Rate-limited 60/min/user. An `APPLE_HEALTH` entry must carry `externalId` and target a medication mirrored from Apple Health, else the whole batch 422s (`meta.errorCode = medications.intake.bulk.apple_health_not_mirrored`); an over-size batch 422s (`medications.intake.bulk.too_large`); a malformed body 422s (`medications.intake.bulk.invalid`). parameters: - *a6 requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/BulkMedicationIntakeRequest" responses: "200": description: Batch processed (always 200 on a well-formed body). content: application/json: schema: $ref: "#/components/schemas/BulkMedicationIntakeResponseEnvelope" "401": *a1 "403": *a9 "409": *a7 "422": *a4 "429": *a5 /api/medications: get: tags: - Medications summary: List medications for the calling user description: Returns every medication owned by the caller (active + paused), ordered by `createdAt DESC`. Each row carries its nested `schedules`, the joined clinical `category`, the latest non-skipped `lastTakenAt`, and the count of today's actioned intake events (`todayEventCount`). The response is cached server-side for 60 s per user; writes flush the cache. responses: "200": description: Medication list. content: application/json: schema: $ref: "#/components/schemas/ListMedicationsResponse" "401": *a1 "403": *a15 "422": *a4 "429": *a5 post: tags: - Medications summary: Create a medication with at least one schedule description: Validates the body against `CreateMedicationRequest`, applies the v1.5 cross-field invariants (one-shot consistency, recurring default `FREQ=DAILY`, `timesOfDay` dual-write), and creates the medication + its schedules in a single Prisma write. Audits as `medication.create`. v1.28 — a mirror create (`externalSource` + `externalId`) re-posting a pair the caller already holds returns the existing medication with 200 instead of a duplicate. v1.32.25 — a NEW mirror create is refused with 422 (`meta.errorCode = medications.mirror.limit_exceeded`) once the caller already holds 30 medications mirrored from the same external source; an idempotent re-post of a medication already mirrored is unaffected, and native creates are never gated. v1.40 — `category` may be the key of one of the caller's own categories; any other `custom:` key is 422 `medications.category.unknown`. parameters: - *a6 requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CreateMedicationRequest" responses: "200": description: "A mirror create re-posting an `(externalSource, externalId)` pair the caller already holds: the existing medication, nothing written." content: application/json: schema: $ref: "#/components/schemas/CreateMedicationResponse" "201": description: Created medication with its schedules. content: application/json: schema: $ref: "#/components/schemas/CreateMedicationResponse" "401": *a1 "403": *a15 "409": *a7 "422": *a4 "429": *a5 /api/medications/categories: get: tags: - Medications summary: List the record's own medication categories description: v1.40 (#1041) — every custom category of the record, hidden ones included, in picker order (`sortOrder`, then creation), each with the number of medications filed under it. The built-in categories are not listed; they are the fixed values of `MedicationCategory`. Delegable at READ level over the `medications` section. responses: "200": description: The custom categories. content: application/json: schema: $ref: "#/components/schemas/ListMedicationCategoriesResponse" "401": *a1 "403": *a15 "422": *a4 "429": *a5 post: tags: - Medications summary: Create a custom medication category description: "v1.40 (#1041) — mints a `custom:` key and stores the label encrypted. At most 20 per account, hidden ones included: one more is 422 `medications.category.limitReached`; an invalid body is 422 `medications.category.invalid`. Rate-limited 30/min keyed on the actor. Delegable at MANAGE level over the `medications` section. The label never reaches an audit row." parameters: - *a6 requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CreateMedicationCategoryRequest" responses: "201": description: The new category. content: application/json: schema: $ref: "#/components/schemas/CreateMedicationCategoryResponse" "401": *a1 "403": *a15 "409": *a7 "422": *a4 "429": *a5 /api/medications/categories/{key}: patch: tags: - Medications summary: Rename, reorder or hide a custom medication category description: "v1.40 (#1041) — field-by-field update of one of the record's own categories. Another account's key, or a built-in value, is 404. Owner-only: not delegable, refused under a record switch. Rate-limited 30/min." parameters: - in: path name: key schema: type: string description: "`custom:`, the category's key." required: true description: "`custom:`, the category's key." requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpdateMedicationCategoryRequest" responses: "200": description: The updated category. content: application/json: schema: $ref: "#/components/schemas/UpdateMedicationCategoryResponse" "401": *a1 "403": *a9 "404": description: Not one of the record's own categories. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 delete: tags: - Medications summary: Delete a custom medication category description: "v1.40 (#1041) — deletes the category and, in the same transaction, moves every medication filed under it to OTHER; `movedCount` says how many moved. No medication is left pointing at a key that no longer exists. Another account's key, or a built-in value, is 404. Owner-only: not delegable, refused under a record switch." parameters: - in: path name: key schema: type: string description: "`custom:`, the category's key." required: true description: "`custom:`, the category's key." responses: "200": description: Deleted. content: application/json: schema: $ref: "#/components/schemas/DeleteMedicationCategoryResponse" "401": *a1 "403": *a9 "404": description: Not one of the record's own categories. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/medications/{id}/courses: get: tags: - Medications summary: Read a medication's courses description: v1.40 (#1024) — the same resolved course fields the list and detail reads carry. Owner-only. parameters: - in: path name: id schema: type: string required: true responses: "200": description: The courses. content: application/json: schema: $ref: "#/components/schemas/MedicationCoursesResponse" "401": *a1 "403": *a9 "404": description: Medication not found (or another user's). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 post: tags: - Medications summary: Start a course description: "v1.40 (#1024) — adds a course and, in the same transaction, rewrites the medication's `startsOn`/`endsOn` to the latest course, so reminders and `intakeActionable` follow with no further write. Refusals: 422 `medications.course.overlap`, `medications.course.currentOpen`, `medications.course.oneShot`, `medications.course.invalidRange`, `medications.course.invalid` (body). Owner-only." parameters: - in: path name: id schema: type: string required: true - *a6 requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CreateMedicationCourseRequest" responses: "201": description: The new course. content: application/json: schema: $ref: "#/components/schemas/CreateMedicationCourseResponse" "401": *a1 "403": *a9 "404": description: Medication not found (or another user's). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "409": *a7 "422": *a4 "429": *a5 /api/medications/{id}/courses/end: post: tags: - Medications summary: End the running course now description: "#1280 — \"End course\". No body. The running course's last day becomes today on the person's clock and the moment is stored as its `endedAt`: a dose slot later today is not due, not reminded and not expected by compliance, a dose taken earlier today still counts, and `courseStatus` reads ENDED at once. Pending placeholders for slots at or after the moment are removed (tombstoned for sync). A medication with no dates gets the course its window describes, from creation to today. An end date set through `PUT /api/medications/{id}` or the course editor keeps meaning the whole day; only this action ends a course at an instant. Refused with 409 `medications.course.notRunning` while no course is running — the rule the reads publish as `canEndCourse`. The record's owner, or a delegate whose grant manages the record's medications (the level of the medication's PUT); any other delegate is refused with 403." parameters: - in: path name: id schema: type: string required: true - *a6 responses: "200": description: The medication's window after ending. content: application/json: schema: $ref: "#/components/schemas/EndMedicationCourseResponse" "401": *a1 "403": *a15 "404": description: Medication not found (`medications.course.notFound`), or another user's. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "409": description: "No course is running now (`medications.course.notRunning`): it has ended already or has not started." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/medications/{id}/courses/{courseId}: patch: tags: - Medications summary: Edit a course description: v1.40 (#1024) — field by field; the medication's window is re-projected in the same transaction. Same refusals as the create. Owner-only. parameters: - in: path name: id schema: type: string required: true - in: path name: courseId schema: type: string required: true requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpdateMedicationCourseRequest" responses: "200": description: The edited course. content: application/json: schema: $ref: "#/components/schemas/UpdateMedicationCourseResponse" "401": *a1 "403": *a9 "404": description: Course not found (`medications.course.notFound`), or another user's. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 delete: tags: - Medications summary: Delete a course description: v1.40 (#1024) — deletes the course and re-projects the medication's window. Deleting the only course makes the medication continuous again (both window columns cleared). Intakes are not touched. Owner-only. parameters: - in: path name: id schema: type: string required: true - in: path name: courseId schema: type: string required: true responses: "200": description: Deleted. content: application/json: schema: $ref: "#/components/schemas/DeleteMedicationCourseResponse" "401": *a1 "403": *a9 "404": description: Course not found (`medications.course.notFound`), or another user's. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/medications/layout: get: tags: - Medications summary: Read the calling user's medications list presentation description: Returns the per-user /medications presentation (card/table view + manual order) plus the optimistic-concurrency `updatedAt` token. Falls back to the defaults (cards, empty order) when the user has not customised it. Mirrors the insights-layout contract. responses: "200": description: The resolved presentation (custom or default) plus its token. content: application/json: schema: $ref: "#/components/schemas/MedicationListLayoutResponse" "401": *a1 "403": *a15 "422": *a4 "429": *a5 put: tags: - Medications summary: Update the calling user's medications list presentation description: "Field-scoped update: `view` and `order` are each optional, and whichever the body omits is preserved from the stored blob — a view toggle can never wipe the manual order and vice versa. The normalised presentation plus the advanced `updatedAt` token is returned. Optimistic concurrency (v1.32.21): send `baseUpdatedAt` (the token from a prior read) and the write 409s if the stored blob changed since; omit it for the legacy unconditional write. Invalid bodies return the multi-issue 422 envelope." requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/MedicationListLayoutPutBody" responses: "200": description: Presentation saved; the normalised blob plus the advanced token is echoed back. content: application/json: 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. `meta.errorCode` = `medication_layout_conflict`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": &a19 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. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 delete: tags: - Medications summary: Reset the calling user's medications list presentation description: Clears the persisted presentation and returns the defaults (cards, empty order). Idempotent. responses: "200": description: Presentation reset; the defaults are returned. content: application/json: schema: $ref: "#/components/schemas/MedicationListLayoutReset" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/medications/{id}: get: tags: - Medications summary: Fetch a single medication description: Returns the medication + its schedules + the joined `category`. Cross-user rows surface as 404 (existence channel sealed). parameters: - in: path name: id schema: type: string required: true responses: "200": description: Medication detail. content: application/json: schema: $ref: "#/components/schemas/GetMedicationResponse" "401": *a1 "403": *a15 "404": description: Medication not found (or owned by another user). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 put: tags: - Medications summary: Replace a medication (partial fields) description: Every field on the body is optional; omitted fields are left untouched. Supplying `schedules` REPLACES the medication's full schedule list (the route deletes existing rows before re-creating). Flipping `active` to false stamps `pausedAt`; flipping back to true clears it. v1.5 invariants on the `schedules` array match `POST /api/medications`. Audits as `medication.update`. v1.40 — an absent `category` leaves the stored one untouched (send it only when the person changed it); a `custom:` key that is not the caller's is 422 `medications.category.unknown`. v1.40 (#1024) — `startsOn`/`endsOn` now edit the latest course (created when there is none); the same course refusals apply (422 `medications.course.*`), and clearing both on a medication with several courses is 422 `medications.course.windowRequired`. parameters: - in: path name: id schema: type: string required: true requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpdateMedicationRequest" responses: "200": description: Updated medication. content: application/json: schema: $ref: "#/components/schemas/UpdateMedicationResponse" "401": *a1 "403": *a15 "404": description: Medication not found (or owned by another user). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 delete: tags: - Medications summary: Delete a medication description: Cascades to the medication's schedules, intake events, dose changes, inventory rows, and side-effect logs. Revokes every API token scoped to `medication::ingest`. Audits as `medication.delete`. parameters: - in: path name: id schema: type: string required: true responses: "200": description: Deletion succeeded. content: application/json: schema: $ref: "#/components/schemas/DeleteMedicationResponse" "401": *a1 "403": *a9 "404": description: Medication not found (or owned by another user). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/medications/{id}/intake: post: tags: - Medications summary: Log an intake event for a medication description: Records a taken or skipped dose. Idempotent via the `Idempotency-Key` header AND the optional `idempotencyKey` body field (the route walks both paths); a re-post inside the 60 s server-side dedup window returns the original event. Non-skipped intakes auto-decrement pen inventory (best-effort), refresh the per-day compliance rollup, and — for `oneShot:true` medications — flip `active` to false. parameters: - in: path name: id schema: type: string required: true - *a6 requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/MedicationIntakeRequest" responses: "200": description: Idempotent replay — the original event is returned without creating a new row. content: application/json: schema: $ref: "#/components/schemas/ReplayMedicationIntakeResponse" "201": description: Intake event created. content: application/json: schema: $ref: "#/components/schemas/CreateMedicationIntakeResponse" "401": *a1 "403": *a15 "404": description: Medication not found (or owned by another user). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "409": *a7 "422": description: "Body validation failed (multi-issue envelope), or `meta.errorCode` names the refusal: `medications.intake.injection_site.disallowed`, `medications.intake.force_slot.invalid`, or (v1.42.0) `medication.intake.notTracked` when the medication is kept as a record only (`trackIntake: false`). Its stored history stays; a new dose is refused rather than stored and counted nowhere. The check runs after ownership, so a foreign medication is still a 404." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a16 get: tags: - Medications summary: List a medication's intake events description: "Paged intake history for one medication (tombstoned rows excluded). `status` filters by action state: `all` (default, byte-stable pre-v1.4.37 contract), `taken`, `skipped`, or `completed` (taken OR skipped — hides the ambiguous never-confirmed rows). Sorting by `takenAt` pins NULLs last so skipped/planned rows do not float above real timestamps." parameters: - in: path name: id schema: type: string required: true - in: query name: limit schema: default: 25 type: integer minimum: 1 maximum: 500 - in: query name: offset schema: default: 0 type: integer minimum: 0 maximum: 9007199254740991 - in: query name: sortBy schema: default: scheduledFor type: string enum: - scheduledFor - takenAt - source - createdAt - in: query name: sortDir schema: default: desc type: string enum: - asc - desc - in: query name: status schema: default: all type: string enum: - all - taken - skipped - completed responses: "200": description: Intake event page. content: application/json: schema: $ref: "#/components/schemas/ListMedicationIntakeEventsResponse" "401": *a1 "403": *a15 "404": description: Medication not found (or owned by another user). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/medications/{id}/inventory: get: tags: - Medications summary: List a medication's supply containers description: Returns every inventory item (all states) for the medication, ordered by state, then `expiresAt`, then `createdAt`. Items count UNITS; divide by the medication's `unitsPerDose` for dose-level figures. v1.19.0 (iOS#25) — also returns a server-computed `summary` (the canonical {`unitsRemaining`, `unitsTotal`, `dosesRemaining`, `dosesTotal`, `expiredUnits`}); clients render it directly rather than re-deriving the Bestand headline, so web and iOS agree. parameters: - in: path name: id schema: type: string required: true responses: "200": description: Inventory item list. content: application/json: schema: $ref: "#/components/schemas/ListMedicationInventoryResponse" "401": *a1 "403": *a15 "404": description: Medication not found (or owned by another user). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 post: tags: - Medications summary: Register a new supply container description: Creates an ACTIVE inventory item with `unitsRemaining = unitsTotal`. The request's `unitsTotal` field carries UNITS (1–1000). Rate-limited 30/min/user. Audits as `medication.inventory.create`. parameters: - in: path name: id schema: type: string required: true - *a6 requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CreateMedicationInventoryItemRequest" responses: "201": description: Created inventory item. content: application/json: schema: $ref: "#/components/schemas/CreateMedicationInventoryItemResponse" "401": *a1 "403": *a15 "404": description: Medication not found (or owned by another user). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "409": *a7 "422": *a4 "429": *a5 /api/medications/{id}/inventory/{itemId}: patch: tags: - Medications summary: Mutate a supply container description: "Per-item operations: manual first-use (`markAsFirstUseAt`), used-up override (`markAsUsedUp`), printed-expiry correction, absolute remaining-unit correction (`unitsRemaining`, clamped to the item's capacity), notes. The canonical state machine re-derives the state after every mutation. Audits as `medication.inventory.update`." parameters: - in: path name: id schema: type: string required: true - in: path name: itemId schema: type: string required: true requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpdateMedicationInventoryItemRequest" responses: "200": description: Updated inventory item. content: application/json: schema: $ref: "#/components/schemas/UpdateMedicationInventoryItemResponse" "401": *a1 "403": *a15 "404": description: Inventory item not found (or owned by another user / medication). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 delete: tags: - Medications summary: Delete a supply container description: Hard-deletes the inventory item. The audit log captures the before-state (`medication.inventory.delete`) so a row can be reconstructed if needed. Consumption stamps on intake events that reference the item stay in place; a later restore skips the missing container. parameters: - in: path name: id schema: type: string required: true - in: path name: itemId schema: type: string required: true responses: "200": description: Deletion succeeded. content: application/json: schema: $ref: "#/components/schemas/DeleteMedicationInventoryItemResponse" "401": *a1 "403": *a15 "404": description: Inventory item not found (or owned by another user / medication). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/medications/extract: post: tags: - Medications summary: Extract scheduling fields from a free-text medication description description: Runs the user's free-text description through the Coach provider chain and returns a citation-guarded partial payload the wizard merges onto whatever the user already typed. `name` and `dose` are dropped when not substring-matched in the original text so the wizard cannot land a hallucinated brand or dose. `cadenceKind` / `doseUnit` / `weekdays` are closed enums; numeric fields are clamped. Answers under the `medicationExtract` capability (the operator's reading-documents switch; free-text medication input is health data, so any provider in the chain that leaves the machine needs an active `ai_extraction` or `ai_full` consent receipt). Rate-limited 10 requests / 5 minutes / user, gated against the daily Coach token budget (429 `coach.budget.exceeded`). requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/MedicationExtractRequest" responses: "200": description: Citation-guarded partial extraction. content: application/json: schema: $ref: "#/components/schemas/MedicationExtractResponse" "401": *a1 "403": 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: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 "502": description: Upstream provider returned an empty, unparseable, or off-schema reply. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "503": description: 'No answer could be produced. `meta.errorCode = "ai.provider.none"` (with `meta.capability` and `meta.reason = "no_provider"`): no AI provider is configured for the calling user or the operator — the status predates the capability envelope and is kept for clients that branch on it. `ai.unavailable`: the capability could not be resolved and failed closed. No code: every provider in the chain failed.' content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/medications/{id}/cadence: get: tags: - Medications summary: Cadence + compliance read for a medication description: Returns the expected-vs-actual dose timeline for the requested window plus the four compliance chip values that drive the detail-page section. Pure computation — no writes. Day boundaries are resolved in the user's IANA timezone so a Tokyo user and a Berlin user see the same chips for the same medication. The `days` query parameter caps at 180. parameters: - in: path name: id schema: type: string required: true - in: query name: days schema: description: Window size in days (default 30, max 180). type: integer minimum: 1 maximum: 180 description: Window size in days (default 30, max 180). responses: "200": description: Cadence response. content: application/json: schema: $ref: "#/components/schemas/GetMedicationCadenceResponse" "401": *a1 "403": *a15 "404": description: Medication not found (or owned by another user). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/medications/compliance: get: tags: - Medications summary: Batched adherence read for every medication of the caller description: >- Returns one compact row per non-PRN medication. A scheduled medication with no local schedule remains in the array with applicable=false and reason NO_LOCAL_SCHEDULE. compliance7/compliance30 stay non-null as all-zero compatibility placeholders for released clients; aware clients must ignore those percentages when applicable=false. complianceDisplay is null. v1.42.0, additive: `meta.aggregate` carries one figure for the whole account (`MedicationComplianceAggregate`, weighted by expected doses across the applicable medications; `null` when none applies), so a client never averages the rows itself. `days` (30, 60, 90, 180 or 365) adds a `complianceN` block in the same shape to every row and to the aggregate, for a report that covers its own window; `meta.days` echoes it. Any other `days` is a 422 rather than a rounded window. parameters: - in: query name: days schema: description: Report window in days. 30 is already `compliance30`; the others add `compliance60` / `compliance90` / `compliance180` / `compliance365`. type: string enum: - "30" - "60" - "90" - "180" - "365" description: Report window in days. 30 is already `compliance30`; the others add `compliance60` / `compliance90` / `compliance180` / `compliance365`. responses: "200": description: One adherence row per medication, and the account aggregate in `meta`. content: application/json: schema: $ref: "#/components/schemas/ListMedicationComplianceResponse" "401": *a1 "403": *a15 "422": *a4 "429": *a5 /api/medications/{id}/compliance: get: tags: - Medications summary: Adherence read for a medication description: Returns the cadence-aware adherence payload. A scheduled medication with no local schedule returns applicable=false, reason NO_LOCAL_SCHEDULE, non-null all-zero compliance7/compliance30 compatibility placeholders, a null display block, and an empty daily grid. Aware clients must not render the placeholder percentages. Medications with a local schedule preserve the existing arithmetic, including windows in which no dose is due. parameters: - in: path name: id schema: type: string required: true responses: "200": description: Compliance response. content: application/json: schema: $ref: "#/components/schemas/GetMedicationComplianceResponse" "401": *a1 "403": *a9 "404": description: Medication not found (or owned by another user). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/medications/{id}/efficacy: get: tags: - Medications summary: Efficacy view for a medication ("Wirkung") description: "Returns the resolved, strictly-descriptive efficacy DTO: the outcome metric(s)/lab(s) the medication's class targets, the target series with start/dose-change/pause markers, a before/after-start comparison, the cadence-aware adherence lane, an optional conservative level-shift note, the data-floor state, and the retarget options. Association-only — there is no verdict / score field. `eligible:false` (with `reason`) marks a one-shot or no-target medication whose tab is hidden." parameters: - in: path name: id schema: type: string required: true responses: "200": description: Efficacy view. content: application/json: schema: $ref: "#/components/schemas/GetMedicationEfficacyResponse" "401": *a1 "403": *a9 "404": description: Medication not found (or owned by another user). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/medications/{id}/efficacy/target: put: tags: - Medications summary: Set or clear the efficacy-target override description: Persists the user's explicit efficacy target for a medication (the only thing the view stores; everything else is derived each read). Pin exactly one of `measurementType` / `biomarkerId`, or pass `clear:true` to revert to the derived target. parameters: - in: path name: id schema: type: string required: true requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/SetMedicationEfficacyTargetRequest" responses: "200": description: Override set or cleared. content: application/json: schema: $ref: "#/components/schemas/SetMedicationEfficacyTargetResponse" "401": *a1 "403": *a9 "404": description: Medication or biomarker not found. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/medications/{id}/dose-history: get: tags: - Medications summary: Per-slot dose-history ledger (Verlauf tab) description: "Returns the full per-slot ledger for the medication over [from, to]: every expected slot with a status (taken on-time / taken late / skipped / missed / upcoming) plus every off-schedule intake tagged ad-hoc. Built from the SAME band minter + `reconstructDoseHistory` the compliance % consumes, so the history view and the rate can never disagree. v1.32.8 (iOS #64) surfaces `intake.source` so a client can label how each dose was recorded. Ownership-scoped; rate-limited 60/min/user; reads only non-deleted rows. Auth via cookie or Bearer." parameters: - in: path name: id schema: type: string required: true - in: query name: from schema: description: Window start (inclusive). Defaults to 90 days before `to`; clamped to a 366-day span floor. Recorded doses are returned from this instant. A slot from before the medication's `createdAt` appears only when a recorded row (a take, a skip, or a miss carried in with restored history) claims it; the server's own placeholder for such a slot is not returned. type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ description: Window start (inclusive). Defaults to 90 days before `to`; clamped to a 366-day span floor. Recorded doses are returned from this instant. A slot from before the medication's `createdAt` appears only when a recorded row (a take, a skip, or a miss carried in with restored history) claims it; the server's own placeholder for such a slot is not returned. - in: query name: to schema: description: Window end (inclusive). Defaults to now. Must be ≥ `from`. A dose recorded ahead of its slot (the slot's time within a day after `to`) is still returned. type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ description: Window end (inclusive). Defaults to now. Must be ≥ `from`. A dose recorded ahead of its slot (the slot's time within a day after `to`) is still returned. responses: "200": description: Dose-history ledger. content: application/json: schema: $ref: "#/components/schemas/GetDoseHistoryResponse" "401": *a1 "403": *a9 "404": description: Medication not found (or owned by another user). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/medications/{id}/schedule-revisions: get: tags: - Medications summary: List a medication's archived schedule eras description: Returns every archived schedule era (newest first) plus `currentSince`, the instant the live plan took over. The dose-history ledger and compliance tallies already mint past days against these eras; this read powers the Zeitplan-tab history timeline. parameters: - in: path name: id schema: type: string required: true responses: "200": description: Era list. content: application/json: schema: $ref: "#/components/schemas/ListScheduleRevisionsResponse" "401": *a1 "403": *a15 "404": description: Medication not found (or owned by another user). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 post: tags: - Medications summary: Append a manual schedule era (pre-tracking history) description: Records that the medication dosed at the given daily times during `[validFrom, validUntil)` — history from before the schedule was edited in the app. The era must end at or before the start of the live plan and must not overlap an existing era; violations return 422. The snapshot is shaped exactly like a write-path archive (`FREQ=DAILY`, window pulled to the min/max of the times), so every historical surface reads it transparently. Audits as `medication.schedule_revision.created`. parameters: - in: path name: id schema: type: string required: true requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CreateScheduleRevisionRequest" responses: "201": description: Manual era created. content: application/json: schema: $ref: "#/components/schemas/CreateScheduleRevisionResponse" "401": *a1 "403": *a15 "404": description: Medication not found (or owned by another user). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/medications/{id}/schedule-revisions/{revisionId}: patch: tags: - Medications summary: Correct a recorded schedule era description: "Replaces an era's bounds and daily times. A `MANUAL` era updates in place; an `ARCHIVED` era stays as the immutable audit record and the correction is minted as a superseding `MANUAL` revision that takes its place in every historical surface (the response carries the correction's id). Validation mirrors the sibling POST: the era must end at or before the start of the live plan and must not overlap another active era; violations return 422. An era that has already been corrected refuses with 409. Audits as `medication.schedule_revision.updated`." parameters: - in: path name: id schema: type: string required: true - in: path name: revisionId schema: type: string required: true requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpdateScheduleRevisionRequest" responses: "200": description: Era corrected. content: application/json: schema: $ref: "#/components/schemas/UpdateScheduleRevisionResponse" "401": *a1 "403": *a15 "404": description: Medication or revision not found (or owned by another user). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "409": description: The revision has already been superseded by a correction. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 delete: tags: - Medications summary: Delete a manually added schedule era description: "Removes a `MANUAL` era — one appended through the sibling POST, or a correction minted by PATCH (deleting a correction restores the archived original it superseded). Write-path archives (`source: ARCHIVED`) are immutable history and refuse with 409. Audits as `medication.schedule_revision.deleted`." parameters: - in: path name: id schema: type: string required: true - in: path name: revisionId schema: type: string required: true responses: "200": description: Deletion succeeded. content: application/json: schema: $ref: "#/components/schemas/DeleteScheduleRevisionResponse" "401": *a1 "403": *a15 "404": description: Medication or revision not found (or owned by another user). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "409": description: The revision is a write-path archive (`ARCHIVED`) and cannot be deleted. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/medications/intake/dose-history-import: post: tags: - Medications summary: Import a dose history exported from another medication tracker description: "Reads a ten-column CSV export covering a whole regimen (multiple medications in one file), matching each row against the caller's existing medications by name — a medication is NEVER created from an import file. `text/csv` carries the actual dose history (`Date`/`Scheduled Date` in separate columns, mapped onto `takenAt`/`scheduledFor`). `application/json` is accepted at the content-type check but the documented JSON export shape always refuses at 422 (`json_carries_no_intake_time`): it has no field for when a dose was actually taken, and inferring one from the scheduled time would manufacture an on-time history the file never claimed. Body capped at 16 MB and 30 000 importable rows. `?dryRun=1` parses, matches, and returns the file verdict WITHOUT writing. A real submission queues the write onto the same background job the per-medication import uses (chunked, heartbeated, resumable) and returns 202 with a `statusUrl` to poll. Shares the 5/hour `import:` rate bucket with every other bulk import surface. Auth via cookie or Bearer." parameters: - name: dryRun in: query required: false schema: type: string enum: - "1" - "true" description: When `1` / `true`, parse + match + return the file verdict without queuing a write. requestBody: required: true content: text/csv: schema: type: string application/json: schema: type: string responses: "200": description: Dry-run verdict (nothing written). content: application/json: schema: $ref: "#/components/schemas/MedicationDoseHistoryImportDryRunEnvelope" "202": description: Import queued. content: application/json: schema: $ref: "#/components/schemas/MedicationDoseHistoryImportQueuedEnvelope" "401": *a1 "403": *a15 "409": description: A dose-history import is already in progress. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "413": description: File exceeds the 16 MB limit. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "415": description: Content-Type must be text/csv or application/json. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": description: "The file could not be read (see `MedicationDoseHistoryImportFatalReason` for the closed `errorCode` set), or it holds more than 30 000 importable rows (`errorCode: too_many_rows`)." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 "503": description: Background worker is not available. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/medications/{id}/intake/import/{jobId}/status: get: tags: - Medications summary: Poll a per-medication intake-import job description: Returns the queued/running/finished state of a per-medication intake-import job, scoped to the caller's own medication. A job created by the account-wide dose-history import (which carries no `medicationId`) never resolves through this path — poll its own `statusUrl` instead. v1.33.0 widened `result.skipReasons[].reason` / `progress.skipDetails[].reason` to the full 16-value `MedicationImportSkipReason` set. A foreign or unknown job/medication id is sealed as 404. Auth via cookie or Bearer. parameters: - in: path name: id schema: type: string required: true - in: path name: jobId schema: type: string required: true responses: "200": description: Current job status. content: application/json: schema: $ref: "#/components/schemas/MedicationIntakeImportJobStatusEnvelope" "401": *a1 "403": *a15 "404": description: No such job for this medication (or owned by another user). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/medications/intake/dose-history-import/{jobId}/status: get: tags: - Medications summary: Poll an account-wide dose-history-import job description: "Returns the queued/running/finished state of a dose-history-import job created by `POST /api/medications/intake/dose-history-import` — the `statusUrl` that create response carries. Identical response projection to the per-medication `/api/medications/{id}/intake/import/{jobId}/status` (both read through the same `readMedicationIntakeImportJob` helper); this path narrows to the account-wide job (`medicationId: null`), so a per-medication job id 404s here and vice versa. A foreign or unknown job id is sealed as 404. Auth via cookie or Bearer." parameters: - in: path name: jobId schema: type: string required: true responses: "200": description: Current job status. content: application/json: schema: $ref: "#/components/schemas/MedicationIntakeImportJobStatusEnvelope" "401": *a1 "403": *a15 "404": description: No such job for this account (or owned by another user). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/profile/summary: get: tags: - Records summary: Read the bounded shared-record profile summary description: >- The short profile a record viewer needs, and only that: allergies, family history, and the current lifestyle facts. It carries no settings, no AI configuration and no encrypted free text, which is why it can be served on a `profile` grant while the full profile cannot. Read-only, and delegable: under a switched session or an account selector the payload is the RECORD's, not the caller's. responses: "200": description: The record's profile summary. content: application/json: schema: $ref: "#/components/schemas/ProfileSummaryEnvelope" "401": *a1 "403": *a15 "422": *a4 "429": *a5 /api/ai/test: post: tags: - Auth summary: Probe the AI provider connection description: >- Sends a tiny fixed prompt to the resolved provider and reports whether it answered. The body is OPTIONAL: with no body the saved configuration is tested; with one, the unsaved selection is tested without touching the user row, so the settings surface can verify a credential before persisting it. Plaintext keys sent this way are never stored. **This route never returns 5xx, and that is deliberate.** A 5xx from the origin is rewritten to an HTML error page by a reverse proxy or CDN, and the client's `res.json()` then dies on `- Partial update — an omitted key is left untouched, an explicit `null` (or an empty string, on the string fields) clears the column. Key material is write-only: it is encrypted at rest on the way in and the read never gives it back, only presence plus a four-character preview. One implicit write is worth knowing about: switching `provider` to anything other than LOCAL, without naming `baseUrl` in the same request, CLEARS the stored base URL. The column is shared across providers, so leaving it would send a cloud key to a host that was configured for a self-hosted backend. A base URL — for LOCAL or for the OpenAI-compatible gateway — runs the SSRF floor: a private or internal host is refused unless the operator allowlisted that exact hostname on this instance. The response is a bare `{ updated: true }` acknowledgement. It does not echo the resolved configuration, so a client that needs the new state re-reads the GET. A wrongly-typed value is refused, not skipped. Until this release the route inspected the body field by field with `typeof` guards, so a numeric `model` or a boolean `baseUrl` was dropped in silence and the write went ahead without it; a body of nothing but such keys came back as 'No valid fields' naming none of them. It runs the standard Zod parse now and answers the multi-issue 422 with the offending path. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/AiProviderPatchRequest" responses: "200": description: Saved. The body is the fixed acknowledgement, not the resolved configuration. content: application/json: schema: $ref: "#/components/schemas/PatchAiProviderResponse" "401": *a1 "403": *a9 "413": description: Body exceeds 64 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "415": description: "`Content-Type` is not `application/json`." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": description: "Nothing was written. A body that failed validation carries the multi-issue list under `details.issues` with `meta.errorCode` = `ai_provider.invalid` — a wrongly-typed field, a `provider` outside the five, or a `responseTimeoutSeconds` that is not an integer in 10–600 all land there and name their path. Two single-message cases stay outside that list because they are not shape failures: no recognised field was present at all (`meta.errorCode` = `ai_provider.no_fields`), and a base URL pointing at an internal or private host the operator has not allowlisted." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 /api/auth/me/coach-prefs: get: tags: - Auth summary: Read per-user Coach prompt-tuning preferences description: Returns the persisted preferences with defaults filled in. Null persisted state resolves to the legacy v1.4.22 defaults. responses: "200": description: Resolved preferences plus the `updatedAt` token. content: application/json: schema: $ref: "#/components/schemas/GetCoachPrefsResponse" "401": *a1 "403": *a9 "422": *a4 "429": *a5 put: tags: - Auth summary: Replace per-user Coach prompt-tuning preferences description: "Persists the supplied prefs. Body is validated against `CoachPrefs`; missing keys fall back to the documented defaults. Supports optimistic concurrency: echo the `updatedAt` from the last read as `baseUpdatedAt` and the write is refused with 409 if the row changed since; omit it for the legacy unconditional write." requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/PutCoachPrefsRequest" responses: "200": description: Saved preferences echoed back with the fresh token. content: application/json: schema: $ref: "#/components/schemas/PutCoachPrefsResponse" "400": *a18 "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. `meta.errorCode` = `coach_prefs_conflict`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a19 "429": *a5 /api/auth/me/disable-coach: get: tags: - Auth summary: Read per-user Coach opt-out flag description: Returns the user's per-account Coach opt-out flag. Default `false` (Coach visible). Powers the Settings → Insights "Hide Coach" Switch and the layout FAB short-circuit. responses: "200": description: Resolved flag. content: application/json: schema: $ref: "#/components/schemas/GetDisableCoachResponse" "401": *a1 "403": *a9 "422": *a4 "429": *a5 patch: tags: - Auth summary: Toggle per-user Coach opt-out flag description: Flips the per-account Coach opt-out flag. Idempotent — the DB write fires even when the value matches so the audit-log row mirrors the API call. Rate-limit 60/min per user. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/DisableCoachFlag" responses: "200": description: Resolved next-state echoed back for optimistic-update consumers. content: application/json: schema: $ref: "#/components/schemas/PatchDisableCoachResponse" "400": *a18 "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/auth/me/diabetes: get: tags: - Auth summary: Read per-user diabetes opt-in flag description: Returns the user's per-account diabetes opt-in flag. Default `false`. When `true`, the glucose target resolver applies the tighter ADA glycemic GOAL bands (fasting 80–130, postprandial < 180) instead of the general non-diabetic bands. Powers the Settings "I have diabetes / clinician glucose targets" toggle. Never inferred from a reading; asserts no diagnosis. responses: "200": description: Resolved flag. content: application/json: schema: $ref: "#/components/schemas/GetDiabetesResponse" "401": *a1 "403": *a9 "422": *a4 "429": *a5 patch: tags: - Auth summary: Toggle per-user diabetes opt-in flag description: Sets the per-account diabetes opt-in flag. Idempotent — the DB write fires even when the value matches so the audit-log row mirrors the API call. Always returns the resolved next-state. `userId` is never accepted from the body. Rate-limit 60/min per user. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/DiabetesFlag" responses: "200": description: Resolved next-state echoed back for optimistic-update consumers. content: application/json: schema: $ref: "#/components/schemas/PatchDiabetesResponse" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/auth/me/health-score-config: get: tags: - Auth summary: Read which pillars count toward the Health Score description: "v1.35.0 — the resolved composition: which pillars count, which the person took out, whether they have chosen at all, and the recipe version. An account that never chose reads every pillar with `hasSelection: false` and `version: 0`, which is exactly what its number was already built from — there is no backfill because there is nothing to back-fill." responses: "200": description: The resolved composition. `updatedAt` is absent until a selection has been stored. content: application/json: schema: $ref: "#/components/schemas/HealthScoreConfigEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 patch: tags: - Auth summary: Choose which pillars count toward the Health Score description: "Replaces the recipe with the given positive selection and bumps the per-user recipe version by one. Since v1.38 the empty selection is the only one refused: three distinct areas of health are what the score RECOMMENDS, not what it requires, so a one-area or two-area recipe saves and produces a score that labels its own breadth (`scoreBasis`). Blood pressure, glucose and cholesterol are still three pillars in one area — that now decides what the score says about itself rather than whether it exists. Accepting the write also evicts the account's score caches, so the next read reflects the new composition rather than the previous hour's number. Optimistic concurrency: send `baseUpdatedAt` from a prior read and the write 409s if the recipe moved since; omit it for the unconditional write. Rate-limited to 60 writes per minute per user." requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/PatchHealthScoreConfigRequest" responses: "200": description: The stored composition, echoed back with the incremented `version` and the advanced `updatedAt` token. content: application/json: 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. `meta.errorCode` = `health_score_config_conflict`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": description: "The write was refused and nothing was stored. `meta.errorCode` distinguishes the cases: `health_score_config.too_narrow` — the selection cannot produce a score (`meta.reason` is `no_pillars_selected`, and the `error` string is the sentence to show the person). Since v1.38 the empty selection is the ONLY one refused: two areas of health, or one, now save and produce a score that labels its own breadth, so a client must not pre-empt the write with a three-area rule of its own. `health_score_config.invalid` — the body failed validation, with the per-issue list under `details.issues`; `invalid_base_updated_at` — `baseUpdatedAt` was present but unparseable, `null` included, which is rejected rather than falling back to the unconditional write. A body that is not JSON at all returns the bare message `health-score-config.body.invalid_json` with the status default `meta.errorCode: http.invalid`." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 /api/auth/me/modules: get: tags: - Auth summary: Read the resolved per-user module enable/disable map description: "Returns the fully-resolved `{ : boolean }` map for every toggleable module. `cycle` reflects the cycle gate, `coach` reflects the resolved per-user opt-out + operator master flag, and the rest read the per-user disabled-allowlist (`modulePreferencesJson`). Default-on: a fresh account reads every module enabled. iOS/web read this to hide a whole module surface end-to-end." responses: "200": description: Resolved module map. content: application/json: schema: $ref: "#/components/schemas/GetModulesResponse" "401": *a1 "403": *a9 "422": *a4 "429": *a5 patch: tags: - Auth summary: Update per-user module enable/disable preferences description: Merges the supplied keys into the persisted disabled-allowlist (`modulePreferencesJson`) field-by-field — a PATCH touching only one module leaves the siblings intact. Refuses to disable a core module (`weight`, `bloodPressure`, `pulse`, `medications`) or any unknown key with a 422. `userId` is never accepted from the body. Always returns the fully-resolved next-state map so clients can hard-set their optimistic update. Rate-limit 60/min per user. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/ModulePrefsPatchRequest" responses: "200": description: Resolved next-state module map echoed back with the fresh `updatedAt` token. content: application/json: schema: $ref: "#/components/schemas/PatchModulesResponse" "400": *a18 "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. `meta.errorCode` = `modules_conflict`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a19 "429": *a5 /api/auth/me/source-priority: get: tags: - Auth summary: Read per-user source priority description: Returns the per-metric-class source priority used by the analytics aggregator. Missing keys fall back to `DEFAULT_SOURCE_PRIORITY`. Null persisted state resolves to the documented defaults. responses: "200": description: Resolved priority. content: application/json: schema: $ref: "#/components/schemas/GetSourcePriorityResponse" "401": *a1 "403": *a9 "422": *a4 "429": *a5 put: tags: - Auth summary: Replace per-user source priority description: Persists the supplied priority. Body is validated against `SourcePriority`; missing keys read as `DEFAULT_SOURCE_PRIORITY` at the call site. requestBody: required: true content: application/json: schema: type: object properties: steps: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT activeEnergy: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT walkingRunningDistance: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT flightsClimbed: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT sleep: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT weight: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT bloodPressure: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT pulse: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT bodyFat: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT bodyTemperature: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT spo2: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT hrv: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT restingHeartRate: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT vo2Max: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT skinTemperature: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT respiratoryRate: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT recovery: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT stress: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT sleepDebt: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT hrvRmssd: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT metricPriority: type: object properties: steps: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT activeEnergy: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT walkingRunningDistance: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT flightsClimbed: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT sleep: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT weight: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT bloodPressure: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT pulse: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT bodyFat: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT bodyTemperature: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT spo2: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT hrv: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT restingHeartRate: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT vo2Max: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT skinTemperature: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT respiratoryRate: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT recovery: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT stress: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT sleepDebt: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT hrvRmssd: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT deviceTypePriority: type: object properties: default: maxItems: 8 type: array items: type: string enum: - watch - band - ring - phone - scale - other - unknown additionalProperties: maxItems: 8 type: array items: type: string enum: - watch - band - ring - phone - scale - other - unknown responses: "200": description: Saved priority (defaulted) echoed back. content: application/json: schema: $ref: "#/components/schemas/PutSourcePriorityResponse" "400": *a18 "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/user/avatar: post: tags: - Auth summary: Upload the calling user's avatar description: "Multipart upload (field `file`). Accepts image/jpeg, image/png, image/webp; rejects anything else at the magic-byte sniff (the multipart Content-Type header is informational only). Hard caps: 2 MiB body, 2048×2048 dimensions. Replaces the v1.4.22 Gravatar leak by storing the bytes on the User row + serving them from same-origin." requestBody: required: true content: multipart/form-data: schema: type: object properties: file: type: string description: Binary image payload (JPEG / PNG / WebP). Hard-capped at 2 MiB and 2048×2048. required: - file responses: "201": description: Avatar saved. `avatarUrl` is the same value the /me payload returns; the `?v=` suffix busts client caches on re-upload. content: application/json: 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: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "415": description: Image format is not one of JPEG / PNG / WebP (magic-byte sniff). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 delete: tags: - Auth summary: Clear the calling user's avatar description: Resets the avatar columns to null. Idempotent — a delete on an already-empty row returns 204 with no audit-log row. responses: "204": description: Avatar cleared (or already empty). "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/user/avatar/{id}: get: tags: - Auth summary: Serve a user's avatar bytes description: Owner-scoped. The `{id}` path segment must match the calling user's id — cross-user reads return 403. Response body is the raw image bytes with the persisted `Content-Type`; the /me payload appends `?v={updatedAtMs}` so clients can cache aggressively. parameters: - in: path name: id schema: type: string description: User id (must match the calling user). required: true description: User id (must match the calling user). responses: "200": description: Avatar bytes. content: image/jpeg: schema: type: string description: Raw JPEG bytes. image/png: schema: type: string description: Raw PNG bytes. image/webp: schema: type: string description: Raw WebP bytes. "401": *a1 "403": 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: $ref: "#/components/schemas/ErrorEnvelope" "404": description: The user has no uploaded avatar. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/user/profile: get: tags: - Auth summary: Read the calling user's profile description: Flattened profile fields for the native client. Aliased over the same data exposed by /api/auth/me. The KVNR is decrypted server-side; the IKNR (v1.8.6) is returned plaintext. responses: "200": description: The calling user's profile. content: application/json: schema: $ref: "#/components/schemas/GetProfileResponse" "401": *a1 "403": *a9 "422": *a4 "429": *a5 patch: tags: - Auth summary: Update the calling user's profile description: Partial profile update — every field is optional. An explicit null (or empty string) clears a field. A non-empty `insurerIkNumber` must be exactly 9 digits (422 otherwise); a non-empty `insuranceNumber` (KVNR) must pass the mod-10 check. `userId` is never accepted from the body. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/ProfileUpdateRequest" responses: "200": description: Saved profile echoed back. content: application/json: 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. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a20 /api/auth/me/notification-prefs: get: tags: - Notifications summary: Read per-user notification preferences description: Returns the fully-resolved preferences with the documented defaults filled in. A null persisted row resolves to the defaults (server-managed reminders, low-stock threshold 7 days, mood reminder 22:00, Coach nudges on). responses: "200": description: Resolved preferences plus the `updatedAt` token. content: application/json: schema: $ref: "#/components/schemas/GetNotificationPrefsResponse" "401": *a1 "403": *a9 "422": *a4 "429": *a5 patch: tags: - Notifications summary: Update per-user notification preferences description: 'Deep-merges the supplied partial shape over the persisted row — a PATCH touching only one category leaves the siblings intact. Always returns the fully-resolved next state so clients can hard-set their optimistic update. `medication.clientManaged: true` (or `deliveryDefault: "client"`) keeps the MEDICATION_REMINDER that announces a dose off APNs and leaves the follow-ups for a dose still open on it (v1.42.0); `medication.lowStockRunwayDays` (1–60, nullable, default 7) tunes the low-stock alert — null switches it off. Rate-limit 60/min per user.' requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/NotificationPrefsPatchRequest" responses: "200": description: Resolved next-state preferences echoed back with the fresh `updatedAt` token. content: application/json: schema: $ref: "#/components/schemas/PatchNotificationPrefsResponse" "400": *a18 "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. `meta.errorCode` = `notification_prefs_conflict`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a19 "429": *a5 /api/auth/me/report-selection: get: tags: - Auth summary: Read the owner's saved doctor-report selection description: "Returns the saved report-scope profile, or `profile: null` when the account has never saved one (or the stored blob no longer parses against the current leaf catalogue). Replaces the retired `GET /api/auth/me/doctor-report-prefs`. Auth via cookie or Bearer." responses: "200": description: The saved profile, or null. content: application/json: schema: $ref: "#/components/schemas/GetReportSelectionResponseEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 put: tags: - Auth summary: Replace the owner's saved doctor-report selection description: Replace, not merge — the body states the whole scope or it is not a scope; a leaf omitted from the body is a leaf removed from the saved profile. An unknown leaf id is refused with a 422 carrying `meta.errorCode` `report-selection.leaves.unknown` and `meta.unknownLeaves`, the ids this build does not know. The saved selection is also what the FHIR REST face and the MCP doctor-visit surfaces replay for a caller that cannot ask a human, so every write is audit-logged. Auth via cookie or Bearer. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/SavedReportProfile" responses: "200": description: The saved profile, echoed back. content: application/json: schema: $ref: "#/components/schemas/PutReportSelectionResponseEnvelope" "400": *a18 "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/auth/me/unit-preference: get: tags: - Auth summary: Read the metric/imperial display preference description: Which branch of the display-time transform registry the client should render (km/h vs mph, km vs mi). Canonical storage stays SI on every measurement row — this changes presentation only, and never what was written. Any stored value other than `imperial` resolves to `metric`, including a null on an account that predates the column. responses: "200": description: The resolved preference. content: application/json: schema: $ref: "#/components/schemas/GetUnitPreferenceEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 patch: tags: - Auth summary: Set the metric/imperial display preference description: >- Hard-set, idempotent, audit-logged. Rate-limited 60 / min per user. The body is capped at 1 KB and must carry `Content-Type: application/json`. Anything larger is 413, a wrong content type is 415, and a body that is not JSON at all is 400 — the same three the other scalars in this group answer. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UnitPreferencePatchRequest" responses: "200": description: The resolved next preference. content: application/json: schema: $ref: "#/components/schemas/PatchUnitPreferenceEnvelope" "400": &a21 description: The request body is not parseable JSON. Nothing was written. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 "403": *a9 "413": &a22 description: Request body exceeds 1024 bytes. Nothing was written. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "415": &a23 description: "The request did not carry `Content-Type: application/json`. Nothing was read and nothing was written." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/auth/me/glucose-unit: get: tags: - Auth summary: Read the blood-glucose display unit description: Which unit blood-glucose readings are rendered in — `mg/dL` or `mmol/L`. A separate choice from the metric/imperial preference, because metric countries are split on which one they read glucose in. Canonical storage is mg/dL on every reading and every ingest path; this changes presentation only. Any stored value other than `mmol/L` resolves to `mg/dL`, including a null on an account that never set it. responses: "200": description: The resolved unit. content: application/json: schema: $ref: "#/components/schemas/GetGlucoseUnitEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 patch: tags: - Auth summary: Set the blood-glucose display unit description: >- Hard-set, idempotent, audit-logged. Rate-limited 60 / min per user. Nothing already stored is converted or reinterpreted: readings stay mg/dL, and a value typed into the entry form in mmol/L is inverted to mg/dL before it is written. The body is capped at 1 KB and must carry `Content-Type: application/json`. Anything larger is 413, a wrong content type is 415, and a body that is not JSON at all is 400 — the same three the other scalars in this group answer. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/GlucoseUnitPatchRequest" responses: "200": description: The resolved next unit. content: application/json: schema: $ref: "#/components/schemas/PatchGlucoseUnitEnvelope" "400": *a21 "401": *a1 "403": *a9 "413": *a22 "415": *a23 "422": *a4 "429": *a5 /api/auth/me/documents-auto-ai-read: get: tags: - Auth summary: Read the automatic document-reading opt-in description: "Whether the auto-index-on-upload job may read a freshly uploaded document through the account's configured external provider with no per-document tap. OFF by default and off for any account that has never set it: the vault is local-first and every egress otherwise needs an explicit action plus an active consent receipt." responses: "200": description: The current flag. content: application/json: schema: $ref: "#/components/schemas/GetDocumentsAutoAiReadEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 patch: tags: - Auth summary: Set the automatic document-reading opt-in description: >- Hard-set, idempotent, audit-logged. Rate-limited 60 / min per user. Turning it ON does two further things a caller should expect. It is itself the standing consent act, so the write appends an `ai_extraction` `ConsentReceipt` unless one that covers document reads (`ai_extraction` or `ai_full`) is already active. The receipt covers reading documents, lab reports and medication text and nothing else: the Coach and the AI analysis keep asking for their own consent (earlier releases minted `ai_full` here, and those receipts stay valid). DELETE /api/consent/ai/latest?kind=ai_extraction revokes it without touching this flag. And a genuine OFF→ON flip schedules a bounded catch-up over the documents already in the vault, because the summary job is enqueued at upload time and would otherwise only ever apply to future uploads; it is skipped while the `documentAi` capability is closed for the record. The catch-up is fire-and-forget and re-runs every consent and budget gate per document. The body cap here is 1 KB, far tighter than the 64 KB its siblings allow — a payload above it is refused with 413 rather than parsed. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/DocumentsAutoAiReadPatchRequest" responses: "200": description: The resolved next flag. content: application/json: schema: $ref: "#/components/schemas/PatchDocumentsAutoAiReadEnvelope" "400": *a21 "401": *a1 "403": *a9 "413": *a22 "415": *a23 "422": *a4 "429": *a5 /api/auth/me/injection-site-prefs: get: tags: - Auth summary: Read the user-level injection-site deny-list description: "Sites the account never wants offered. The list is a DENY, and deny wins everywhere: a site here is dropped from every medication's injection-site picker and refused at intake with 422, even when that medication names it among its preferred sites." responses: "200": description: The current deny-list. content: application/json: schema: $ref: "#/components/schemas/GetInjectionSitePrefsEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 patch: tags: - Auth summary: Replace the user-level injection-site deny-list description: >- Replace, not merge — the body states the whole list, and an empty array clears the exclusion. Hard-set, idempotent, audit-logged. Rate-limited 60 / min per user. The stored list is deduplicated and re-ordered into the canonical enum order before it is written, so the response can differ from the body that produced it while describing the same set. Take the response as the state. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/InjectionSitePrefsPatchRequest" responses: "200": description: The resolved next deny-list, deduplicated and in canonical order. content: application/json: schema: $ref: "#/components/schemas/PatchInjectionSitePrefsEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/user/thresholds: get: tags: - Analytics summary: Read the effective metric ranges and the caller's overrides description: >- Every threshold metric with its resolved traffic-light band, whether that band came from an override, the unmodified computed default beside it, and the metric's physiological bounds — so a settings surface can show current-versus-default and validate an edit without a second call. `range` and `default` are nullable: a metric whose default is derived from profile data the account has not supplied (height for weight, date of birth and gender for body fat) has no band to resolve until that data exists. responses: "200": description: Effective ranges plus the stored overrides. content: application/json: 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. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 put: tags: - Analytics summary: Set threshold overrides for one or more metrics description: >- MERGE, not replace, and it is the one write on this surface that behaves that way: the body is a partial map and only the metrics it names are touched. A metric omitted keeps whatever override it already had. To remove one, use DELETE with `?metric=` — sending it back as an absent key does nothing. Each range must sit inside that metric's physiological bounds with `min` strictly below `max`, and an unknown metric key is refused rather than ignored (the schema is strict). Rate-limited 30 / 5 min per user; audit-logged with the before and after maps. The response carries the merged override map, not the recomputed effective ranges — re-read the GET for those. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/ThresholdsUpdateRequest" responses: "200": description: The merged override map. content: application/json: schema: $ref: "#/components/schemas/PutThresholdsEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 delete: tags: - Analytics summary: Reset one threshold override, or all of them description: >- With `?metric=` it drops that metric's override; WITHOUT the parameter it drops EVERY override on the account. The wide form is not reachable by omitting the parameter alone — it requires `{ "confirm": "RESET_THRESHOLDS" }` in the body, the same typed-confirmation shape account deletion and the data reset use. The `?metric=` form takes no body and is unchanged. Audit-logged with the before and after maps. Rate-limited 30 / 5 min per user, matching the PUT. parameters: - in: query name: metric schema: description: The single metric to reset. Omit to reset every override on the account — which then requires the confirmation body. $ref: "#/components/schemas/ThresholdMetric" description: The single metric to reset. Omit to reset every override on the account — which then requires the confirmation body. requestBody: required: false content: application/json: schema: $ref: "#/components/schemas/ThresholdsResetAllRequest" responses: "200": description: The remaining override map. content: application/json: schema: $ref: "#/components/schemas/DeleteThresholdsEnvelope" "400": description: "`metric` was present but is not a known threshold metric. Nothing was reset." content: application/json: 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: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 /api/auth/me/devices: get: tags: - Auth summary: List the account's registered devices description: >- The devices this account has registered for push, newest activity first. Identifying metadata only: no push tokens, no APNs identifiers, no IP addresses — nothing here can be replayed to send a notification. `isCurrent` is not taken on trust. A caller may send `X-Device-Id`, but the badge is only set when that device also holds an unrevoked refresh token for this account, so a forged header cannot move it. A cookie caller has no such anchor and correctly sees `false` on every row. responses: "200": description: Registered devices, most recently seen first. content: application/json: schema: $ref: "#/components/schemas/DeviceListEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/auth/me/devices/{id}: patch: tags: - Auth summary: Set a device's medication-reminder delivery override description: "Chooses where this one device's medication reminders come from: `server` for server-side dispatch, `client` to leave it to the app's own local reminders, or `null` to clear the override and inherit the account default. Scoped to the caller — a foreign id is 404, never another account's row." parameters: - in: path name: id schema: type: string required: true requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/DevicePatchRequest" responses: "200": description: The device's resolved override. content: application/json: schema: $ref: "#/components/schemas/DevicePatchEnvelope" "401": *a1 "403": *a9 "404": description: No such device for this account. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 delete: tags: - Auth summary: Revoke a device and every credential bound to it description: >- Deletes the device row and, in the same transaction, revokes every unrevoked refresh token bound to it plus the access tokens those refresh rows pair with. The counts come back so a client can say what was actually killed. One transaction on purpose: a partial cascade would leave a live device row whose tokens are all dead. This does NOT touch browser sessions. A session lives on its own row, not on a device, so signing a browser out is /api/auth/logout or the session list — the two surfaces cover different blast radii. Re-pairing later creates a fresh device row rather than reviving this one. Scoped to the caller: a foreign id answers 404 rather than confirming the id exists. parameters: - in: path name: id schema: type: string required: true responses: "200": description: Device removed and its credentials revoked. content: application/json: schema: $ref: "#/components/schemas/AccountDeviceRevokeEnvelope" "401": *a1 "403": *a9 "404": description: No such device for this account. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/auth/me/labs-local-ocr: get: tags: - Auth summary: Read the browser-side OCR opt-in description: Whether a lab-report scan should be transcribed on the device before anything is sent. It exists for the provider chains that cannot read an image at all — a text-only model, or the ChatGPT-OAuth path — so a person on one of those can still scan a paper report. The raw image never leaves the device in this mode; only the extracted text does. Less accurate than native vision, which is why the review screen stays mandatory either way. responses: "200": description: The current flag. content: application/json: schema: $ref: "#/components/schemas/GetLabsLocalOcrEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 patch: tags: - Auth summary: Set the browser-side OCR opt-in description: Hard-set, idempotent, audit-logged. Rate-limited 60 / min per user; body capped at 1 KB. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/LabsLocalOcrPatchRequest" responses: "200": description: The resolved next flag. content: application/json: schema: $ref: "#/components/schemas/PatchLabsLocalOcrEnvelope" "400": *a21 "401": *a1 "403": *a9 "413": *a22 "415": *a23 "422": *a4 "429": *a5 /api/auth/me/workout-routes: get: tags: - Auth summary: Read the workout-route opt-in description: Whether the GPS track of a workout is kept. Off by default. While it is off, `POST /api/workouts/batch` and the Apple Health export import keep every workout but drop its `route`; the per-entry status is the same as with the route stored. No AI path ever reads a stored track. responses: "200": description: The current flag. content: application/json: schema: $ref: "#/components/schemas/GetWorkoutRoutesEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 patch: tags: - Auth summary: Set the workout-route opt-in description: Hard-set, idempotent, audit-logged. Rate-limited 60 / min per user; body capped at 1 KB. Turning it off stops new tracks; tracks already stored stay until their workout is deleted. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/WorkoutRoutesPatchRequest" responses: "200": description: The resolved next flag. content: application/json: schema: $ref: "#/components/schemas/PatchWorkoutRoutesEnvelope" "400": *a21 "401": *a1 "403": *a9 "413": *a22 "415": *a23 "422": *a4 "429": *a5 /api/auth/me/use-central-codex: get: tags: - Auth summary: Read the shared-Codex opt-in description: Whether this account's provider chain gains a trailing entry for the operator's own ChatGPT subscription. OFF by default. responses: "200": description: The current flag. content: application/json: schema: $ref: "#/components/schemas/GetUseCentralCodexEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 patch: tags: - Auth summary: Set the shared-Codex opt-in description: >- Hard-set, idempotent, audit-logged. Rate-limited 60 / min per user; body capped at 1 KB. Worth being plain about what turning it on does and does not do. The shared account is external, shared with every other user who opts in, bound by the operator's rate limits, and trains on its input by default. But the entry is server-managed, so the AI consent gate still applies: flipping this ON egresses nothing by itself, and without an active consent receipt nothing will leave for it. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UseCentralCodexPatchRequest" responses: "200": description: The resolved next flag. content: application/json: schema: $ref: "#/components/schemas/PatchUseCentralCodexEnvelope" "400": *a21 "401": *a1 "403": *a9 "413": *a22 "415": *a23 "422": *a4 "429": *a5 /api/auth/me/locale: put: tags: - Auth summary: Set the interface language description: >- Writes the account's UI language and mirrors it into the locale cookie via `Set-Cookie`. Both halves matter: the column is what server-side work reads when there is no request to take a cookie from — a scheduled nudge, a Telegram message — and without it those went out in English however the person reads the app. The server-set cookie outlives the client-written one, which some browsers cap at seven days. An ACTOR surface, and the clearest case for that mode existing: the interface language belongs to the person reading the screen, never to the record on it. Under a record scope this would write a delegate's choice onto the owner's row and then mail the owner in a language they may not read. A request that attaches the per-request account selector (the `AccountSelector` header parameter) is refused with 403 `sharing.not_permitted`. It keeps answering while a switch is on rather than merely refusing safely, because the language switcher fires a mount-time backfill on every page load. Idempotent: when the column already matches, the write is skipped and only the cookie is refreshed. Body capped at 4 KB. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/LocalePutRequest" responses: "200": description: The accepted locale. content: application/json: schema: $ref: "#/components/schemas/LocalePutEnvelope" "401": *a1 "403": 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: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/auth/me/timezone: put: tags: - Auth summary: Set the account timezone description: >- Lifted out of the profile patch so the picker can send one field without round-tripping the rest. Accepts any zone the running engine's `Intl` knows; anything else is 422 rather than stored, so the resolver never has to defend against a poisoned column. Audit-logged with the old and new zone. A change is not only a display preference. The supplement nutrient rows key on the LOCAL day a dose fell on, minted under whatever zone was in force at write time, so moving the zone re-buckets them. The route therefore enqueues a recompute of the account's supplement nutrients whenever the zone actually changes. Best-effort and de-duplicated, so rapid re-picks collapse into one. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/TimezonePutRequest" responses: "200": description: The accepted timezone. content: application/json: schema: $ref: "#/components/schemas/TimezonePutEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/auth/me/passkey-nudge/dismiss: post: tags: - Auth summary: Dismiss the add-a-passkey nudge description: Permanently hides the prompt that suggests adding a passkey. Cookie-only, takes no body, idempotent — dismissing twice is a success. The flag is also readable as `passkeyNudgeDismissed` on GET /api/auth/me/mfa. responses: "200": description: Nudge dismissed. content: application/json: schema: $ref: "#/components/schemas/PasskeyNudgeDismissEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/insights/chat: get: tags: - Insights summary: List the caller's Coach conversations description: "v1.18.0 — cursor-paginated list of the caller's Coach conversations for the history rail, most-recent activity first. Metadata only (id, title, timestamps, message count); message bodies are not decrypted here. `limit` defaults to 20, capped at 50; pass the returned `nextCursor` back as `cursor` for the next page (null at the end). v1.30.2 — optional `q` narrows the page to conversations whose TITLE contains the text (case-insensitive substring, capped at 200 chars); message bodies are encrypted at rest and are not searched. Never AI-gated: stored conversations are the person's data and stay listable, readable and deletable while the Coach is unavailable for any reason. Auth via cookie or Bearer; the owner is narrowed from the session." parameters: - name: cursor in: query required: false schema: type: string description: Id of the last conversation on the previous page. Omit for the first page. - name: limit in: query required: false schema: type: integer minimum: 1 maximum: 50 default: 20 description: Page size. Defaults to 20, capped at 50. - name: q in: query required: false schema: type: string maxLength: 200 description: v1.30.2 — case-insensitive substring filter over the conversation TITLE only (message bodies are encrypted and not searched). Omit for the unfiltered list. responses: "200": description: A page of the caller's conversations. content: application/json: schema: $ref: "#/components/schemas/CoachConversationsPageResponse" "401": *a1 "403": *a9 "422": *a4 "429": *a5 post: tags: - Insights summary: Send a Coach turn (streaming reply) description: 'v1.18.0 — sends a user turn and streams the assistant reply as Server-Sent Events. The response is `text/event-stream`, not JSON: one `data: \n\n` frame per event, each a `CoachStreamEvent` dispatched on `type`. Frames, in the order they arrive: `step` (v1.39.4, live progress while the Coach reads the record: `{ type, step }`, upserted by `step.id` from `running` to `done` / `empty` / `failed`; catalog labels, domains, windows and counts only), `token` (a chunk of reply text: `{ type, token }`), `provenance` (the evidence envelope: `{ type, metricSource }`), `result` (v1.39.4, a table of the values the turn read: `{ type, result }`, zero or more, sent only to the account that owns the conversation), `suggestion` (a cadence-suggestion card: `{ type, suggestion }`), `suggestedAction` (v1.22, a confirm-to-apply action card: `{ type, suggestedAction }`; nothing is created until the person confirms it through `POST /api/coach/suggested-actions`), `clarification` (v1.39.4, the choices when the reply is a clarifying question: `{ type, clarification }`; answer with `clarification` on the next request), `followUps` (v1.39.4, up to three chips under the reply: `{ type, followUps }`; send one back with `followUp` on the next request), `reasoning` (v1.18.9, optional reasoning-summary text: `{ type, text }` — emitted only by reasoning-capable providers; absent otherwise), and `done` (`{ type, conversationId, messageId, usage? }` — v1.18.9 adds the optional `usage` envelope `{ totalTokens, promptTokens?, completionTokens?, model? }`, server-authoritative; clients display it, never recompute), or a single `error` (`{ type, code, message, reason? }`). No frame added after v1.18.9 carries a top-level key an older frame uses, so a client decoding every frame into one flat struct is safe. The HTTP status is 200 even for a provider/refusal outcome — clients dispatch on the `error` frame, not the status. Clients ignore unknown frame types (additive evolution). Omitting `conversationId` starts a new conversation. Requires the `coach` AI capability, checked right after auth: an unavailable Coach is refused with the capability envelope before the stream opens, except a missing provider, which keeps its `coach.provider.none` error frame and adds `reason: "no_provider"`. Budget- and rate-limited. Auth via cookie or Bearer.' requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CoachChatRequest" responses: "200": description: "Server-Sent Events stream: each `data: \\n\\n` frame is one `CoachStreamEvent`. Keepalive comment lines (`: ka`) carry no data and are ignored." content: text/event-stream: schema: $ref: "#/components/schemas/CoachStreamEvent" "401": *a1 "403": 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: $ref: "#/components/schemas/ErrorEnvelope" "413": description: Request body exceeds the 64 KB cap. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 "503": &a26 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: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/insights/chat/{id}/messages/{messageId}/results: get: tags: - Insights summary: Read the tables one Coach message read description: "v1.39.4 — the tables of values one assistant message read, decrypted server-side for the account that owns the conversation. One entry per table the message's `metricSource.results` lists, in that order: the full `CoachResultTable`, or `{ ref, withheld }` when it is not served — `module_disabled` when the table's domain is switched off for the record now, `unavailable` when the stored tables cannot be read. A message without tables answers an empty list. Fetch lazily, per message, when a table scrolls into view. Never AI-gated: the tables are stored data and stay readable while the Coach is unavailable. A foreign or unknown conversation or message id maps to 404 (never 403). Auth via cookie or Bearer." parameters: - name: id in: path required: true schema: type: string description: Conversation id. - name: messageId in: path required: true schema: type: string description: Assistant message id within the conversation. responses: "200": description: The message's tables, each served whole or withheld. content: application/json: schema: $ref: "#/components/schemas/CoachMessageResultsResponse" "401": *a1 "403": *a9 "404": description: Conversation or message not found, or not owned by the caller. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/insights/chat/{id}/messages/{messageId}/trail: get: tags: - Insights summary: Read the trail text of one Coach message description: "v1.41 — the text of one assistant message's trail: the screened reasoning titles and summaries by trail entry (model-written), the fact texts the turn recalled, and a pending fact proposal (the person's own data). The trail's structure (phases, statuses, counts) is plaintext on the message's `metricSource.activity`; this route serves only the text, decrypted for the account that owns the conversation. Fetch lazily when the person opens the trail. A mixed read: while the Coach's text may not be shown for the record, `entries` is empty and the `ai` state rides beside it, but `recalled` and `proposal` are still served, since they are the person's data. While the medications module is off, recalled facts and a proposal that concern a medication are left out. `trail` is null when the message has no trail or nothing in it may be served. A foreign or unknown conversation or message id maps to 404 (never 403). Auth via cookie or a full-access Bearer token." parameters: - name: id in: path required: true schema: type: string description: Conversation id. - name: messageId in: path required: true schema: type: string description: Assistant message id within the conversation. responses: "200": description: The trail text, or null when there is none or nothing in it may be served. content: application/json: schema: $ref: "#/components/schemas/CoachMessageTrailResponse" "401": *a1 "403": *a9 "404": description: Conversation or message not found, or not owned by the caller. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/insights/chat/{id}: get: tags: - Insights summary: Read one Coach conversation with all messages description: "v1.18.0 — returns one conversation with every message decrypted server-side and ordered oldest-first, plus the rolling `summary` when one is on file. The client renders the bodies directly; no decryption key is involved. A foreign / unknown id maps to 404 (never 403) so the existence channel does not leak across accounts. Never AI-gated: a stored conversation stays readable while the Coach is unavailable. Auth via cookie or Bearer." parameters: - name: id in: path required: true schema: type: string description: Conversation id. responses: "200": description: The conversation with all decrypted messages. content: application/json: schema: $ref: "#/components/schemas/CoachConversationDetailResponse" "401": *a1 "403": *a9 "404": description: Conversation not found or not owned by the caller. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 patch: tags: - Insights summary: Rename one Coach conversation description: Sets the conversation's title. The only field this verb writes — the body is `.strict()`, so an unrecognised key is REFUSED rather than ignored, unlike most partial updates on this surface. The title is trimmed and must be 1..80 characters after trimming. A foreign or unknown id maps to 404 (never 403), so the existence channel does not leak across accounts. Never AI-gated. Auth via cookie or Bearer; the caller is always resolved as themselves, so a conversation in a shared record is not reachable here. parameters: - name: id in: path required: true schema: type: string description: Conversation id. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/RenameCoachConversationRequest" responses: "200": description: The new id and title. Deliberately NOT the full conversation — nothing else changed, and re-sending the messages on a rename would be a large body for no new information. content: application/json: schema: $ref: "#/components/schemas/CoachConversationRenamed" "401": *a1 "403": *a9 "404": description: Conversation not found or not owned by the caller. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": description: The body failed validation — an empty or over-long title, or an unrecognised key. Multi-issue envelope with `meta.errorCode` = `coach.conversation.invalidTitle`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 delete: tags: - Insights summary: Delete one Coach conversation description: "v1.18.0 — hard-deletes a conversation and every message under it. A foreign / unknown id maps to 404 (never 403). Never AI-gated: erasure works while the Coach is unavailable. Auth via cookie or Bearer." parameters: - name: id in: path required: true schema: type: string description: Conversation id. responses: "200": description: The conversation was deleted. content: application/json: schema: $ref: "#/components/schemas/CoachConversationDeleted" "401": *a1 "403": *a9 "404": description: Conversation not found or not owned by the caller. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/insights/chat/fenced: post: tags: - Insights summary: Send a FENCED multi-document coach turn (streaming reply) description: "v1.29.x (S7) — sends a user turn and streams a grounded prose reply about the documents attached to a coach conversation, as Server-Sent Events (`text/event-stream`: one `data: \\n\\n` frame per event; `token` / `done` / `error`, HTTP 200 even for a provider/refusal outcome). FENCED by construction: NO tools, NO health snapshot; every attached document is fenced as untrusted DATA (per-document header fields marker-scrubbed); answers under the `coach` and `documentAi` capabilities, both re-checked for the single picked provider immediately before anything is sent (403 with the capability envelope when either is closed; `consent.ai.required` when that provider leaves the machine without an `ai_extraction` / `ai_full` receipt; no provider is the `documents.chat.provider.none` error frame); the reply is numerically grounded against the LIVE attachments' figures only. `conversationId` continues an existing fenced thread; `attachmentIds` (first-turn ONLY, min 1, max 5) creates a fresh fenced thread — supplying BOTH is a 422. The body is `.strict()`: `scope` / `guidedQuestion` / `prefill` / `userId` are rejected. A plain tool conversation 404s here. Module-gated on `inboundDocuments`; rate-limited (shared `document-chat` bucket). Renders as plain text (no markdown). Auth via cookie or Bearer." requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/FencedCoachChatRequest" responses: "200": description: Server-Sent Events stream of `token` / `done` / `error` frames. content: text/event-stream: schema: type: string description: "SSE frames: `data: \\n\\n`. See the operation description for the per-`type` frame shapes." "401": *a1 "403": 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: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 "503": description: "`ai.unavailable` — the inputs behind the AI capability could not be read, and the answer failed closed. Nothing was sent. Retry later." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/insights/chat/{id}/attachments: post: tags: - Insights summary: Attach a document to a coach conversation description: "v1.29.x (S7) — attaches one already-stored, content-indexed document to an existing conversation and sets its sticky `documentScoped` flag TRUE (the one legal, privilege-reducing tool→fenced transition; audit-logged when a flip occurs). Validates the document is owned + live + indexed + within the 5-document cap. Idempotent: attaching an already-attached document is a 200. A foreign / unknown conversation or document maps to 404. Module-gated on `inboundDocuments`; answers under the `coach` and `documentAi` capabilities (nothing is sent to a provider here, so a missing provider or consent receipt is left to the next turn); rate-limited. Detaching (DELETE) is never refused for AI reasons: it removes the person's own data. Auth via cookie or Bearer." parameters: - name: id in: path required: true schema: type: string description: Conversation id. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CoachAttachmentCreateRequest" responses: "200": description: The refreshed live attachment set + the fenced flag. content: application/json: schema: $ref: "#/components/schemas/CoachAttachmentsResponse" "401": *a1 "403": 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: $ref: "#/components/schemas/ErrorEnvelope" "404": description: Conversation or document not found / not owned. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 "503": description: "`ai.unavailable` — the inputs behind the AI capability could not be read, and the answer failed closed. Nothing was sent. Retry later." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/insights/chat/{id}/attachments/{documentId}: delete: tags: - Insights summary: Detach a document from a coach conversation description: v1.29.x (S7) — removes the document from the conversation. The conversation stays FENCED (the sticky flag is never cleared — a thread whose history may contain document-derived text must never regain the tool loop). Detaching the last document leaves a fenced conversation with zero attachments. A missing row / foreign conversation maps to 404. Module-gated on `inboundDocuments`; rate-limited. Auth via cookie or Bearer. parameters: - name: id in: path required: true schema: type: string description: Conversation id. - name: documentId in: path required: true schema: type: string description: The attached document's id. responses: "200": description: The refreshed live attachment set + the fenced flag. content: application/json: schema: $ref: "#/components/schemas/CoachAttachmentsResponseDetach" "401": *a1 "403": *a9 "404": description: Attachment or conversation not found / not owned. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/insights/layout: get: tags: - Insights summary: Read the calling user's Insights tile layout description: Returns the per-user Insights tile layout (visibility + order) plus the optimistic-concurrency `updatedAt` token. Falls back to the default layout when the user has not customised it. Mirrors the dashboard-widgets contract. responses: "200": description: The resolved layout (custom or default) plus its token. content: application/json: schema: $ref: "#/components/schemas/InsightsLayout" "401": *a1 "403": *a9 "422": *a4 "429": *a5 put: tags: - Insights summary: Replace the calling user's Insights tile layout description: "Persists the full tile layout. The normalised layout plus the advanced `updatedAt` token is returned. Optimistic concurrency (v1.32.21): send `baseUpdatedAt` (the token from a prior read) and the write 409s if the stored row changed since; omit it for the legacy unconditional write. Invalid bodies return the multi-issue 422 envelope, matching the dashboard-widgets route." requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/InsightsLayoutPutBody" responses: "200": description: Layout saved; the normalised layout plus the advanced token is echoed back. content: application/json: 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. `meta.errorCode` = `insights_layout_conflict`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a19 "429": *a5 delete: tags: - Insights summary: Reset the calling user's Insights tile layout description: Clears the persisted layout and returns the default layout. Idempotent. responses: "200": description: Layout reset; the default layout is returned. content: application/json: schema: $ref: "#/components/schemas/InsightsLayoutReset" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/insights/coach/facts: get: tags: - Insights summary: List the caller's durable Coach facts description: "v1.11.1 — returns the active facts the Coach keeps about the caller (highest-confidence then newest first), each decrypted on the fly. The GDPR 'what do you know about me' surface. v1.41 — each fact carries its source, the message it came from and when it was last used in a turn; a health fact still waiting for the caller's confirmation is not listed. Never AI-gated: the facts stay readable and erasable while the Coach is unavailable for any reason. Auth via cookie or Bearer; the owner is always narrowed from the session, never the body. Undecryptable rows are omitted rather than failing the read." responses: "200": description: The caller's active facts. content: application/json: schema: $ref: "#/components/schemas/CoachFactsList" "401": *a1 "403": *a9 "422": *a4 "429": *a5 post: tags: - Insights summary: Remember one of the caller's own Coach messages description: "v1.41 — the remember button. Saves the named user message as a fact with `source: \"user\"`: the text is the message the server stored, filed as `medication` or `condition` when it names one, `context` otherwise. The tap is the caller's confirmation, so a health message is saved too. A message the caller does not own, or an assistant message, is a 404. Saving the same fact again returns the existing one with `created: false`. Gated on the Coach module, never on AI. Auth via cookie or Bearer." requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CoachFactCreateRequest" responses: "200": description: The caller's memory already held this fact. content: application/json: schema: $ref: "#/components/schemas/CoachFactAlreadyKnown" "201": description: The fact was saved. content: application/json: schema: $ref: "#/components/schemas/CoachFactCreated" "401": *a1 "403": 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: $ref: "#/components/schemas/ErrorEnvelope" "404": description: Message not found or not one of the caller's own. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 delete: tags: - Insights summary: Forget all of the caller's Coach facts description: "v1.11.1 — bulk 'forget what you know about me': soft-deletes every active fact for the caller and returns the count cleared. Idempotent (a second call clears 0). Never AI-gated. Auth via cookie or Bearer." responses: "200": description: All active facts cleared; the count is returned. content: application/json: schema: $ref: "#/components/schemas/CoachFactsCleared" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/insights/coach/facts/{id}: patch: tags: - Insights summary: Edit one Coach fact's wording description: "v1.41 — rewrites the text of a fact the caller owns (re-encrypted at rest) and files it by its new wording: text that names a medication moves to `medication`, so the medications module keeps filtering it; other health wording moves a non-health category to `condition`; otherwise the category stays. A health fact the caller edited counts as confirmed (`source: \"user\"`). A proposal still waiting for the caller's confirmation, an unknown, cross-user or deleted id are all a 404. Never AI-gated. Auth via cookie or Bearer." parameters: - name: id in: path required: true schema: type: string description: Fact id. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CoachFactPatchRequest" responses: "200": description: The fact after the edit. content: application/json: schema: $ref: "#/components/schemas/CoachFactUpdated" "401": *a1 "403": *a9 "404": description: Fact not found or not owned by the caller. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 delete: tags: - Insights summary: Forget one Coach fact description: "v1.11.1 — soft-deletes a single fact owned by the caller. An unknown / cross-user / already-deleted id is an idempotent no-op returning `{ deleted: false }`, never revealing whether the id exists under another account. Never AI-gated. Auth via cookie or Bearer." responses: "200": description: "The fact was soft-deleted (`deleted: true`) or the id matched nothing the caller owns (`deleted: false`)." content: application/json: schema: $ref: "#/components/schemas/CoachFactDeleted" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/insights/coach/nudge-status: get: tags: - Insights summary: Whether an unopened Coach message is waiting description: "v1.18.6 (CCH-03) — server-authoritative unread signal for the Coach FAB. `unread` is true when the caller's newest Coach ASSISTANT message (a proactive nudge or any reply) is newer than `User.coachLastSeenAt`; a user who has never opened the Coach reads an existing nudge as unread exactly once. `nudgedAt` carries that newest assistant-message timestamp (null when none exists) so the client can key a local seen-mirror on a stable value. Never refused for an AI reason: while the `coach` capability is unavailable the answer is the quiet `{ unread: false, nudgedAt: null, conversationId: null }` with `ai` saying why. Auth via cookie or Bearer; the owner is narrowed from the session." responses: "200": description: The current unread state. content: application/json: schema: $ref: "#/components/schemas/CoachNudgeStatus" "401": *a1 "403": *a15 "422": *a4 "429": *a5 /api/insights/coach/seen: post: tags: - Insights summary: Mark the Coach as opened (clear the unread dot) description: "v1.18.6 (CCH-03) — opening the Coach (drawer or full page) stamps `User.coachLastSeenAt = now()`, so `GET /api/insights/coach/nudge-status` then reports no assistant message newer than the stamp and the FAB drops the unread dot. Server-authoritative, so the cleared state follows the caller across web + iOS rather than just the opening device. No request body — the timestamp is server-minted, so a client can never backdate the stamp to suppress a future nudge. Never AI-gated: a timestamp on the caller's own row. Auth via cookie or Bearer." responses: "200": description: The Coach was marked opened; the stamp is echoed back. content: application/json: schema: $ref: "#/components/schemas/CoachSeenResponse" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/coach/about-me: get: tags: - Insights summary: Read the caller's self-context description: "v1.16.0 — returns the structured self-context (free text plus chronic conditions, allergies, coach focus) the Coach system prompt and the daily briefing inject as a delimited, user-provided context block, alongside any pending clarifying questions. Every field is stored encrypted at rest; an undecryptable payload reads as null (fail closed). The questions are the one AI part: stored questions cannot be told apart by origin, so while the `aboutMeQuestions` capability is unavailable an outstanding set is replaced by the deterministic completion hints for the same profile, and `ai` says why. Auth via cookie or Bearer; the owner is always narrowed from the session." responses: "200": description: The stored fields (null when never written / cleared) plus pending clarifying questions. content: application/json: schema: $ref: "#/components/schemas/GetCoachAboutMeResponse" "401": *a1 "403": *a9 "422": *a4 "429": *a5 put: tags: - Insights summary: Write (or clear) the caller's self-context description: "v1.16.0 — persists the free text (4 000-char cap) and the three structured fields (500-char cap each) encrypted at rest; caps are enforced before encryption. Structured fields are optional: omitted leaves the stored value untouched, an empty string clears it. After a non-empty save the server derives up to 3 clarifying questions (AI when the `aboutMeQuestions` capability is available and the daily Coach token budget allows, deterministic completion hints otherwise, with no provider call) and returns them as `pendingQuestions`. Rate-limited per user. Optimistic concurrency (v1.32.21): send `baseUpdatedAt` (the `updatedAt` from a prior read) and the write 409s if the stored self-context changed since; omit it for the legacy unconditional write. The token is per-surface (`UserHealthProfile.updatedAt`), so it is not perturbed by unrelated account writes." requestBody: required: true content: application/json: schema: type: object properties: aboutMe: type: string maxLength: 4000 conditions: type: string maxLength: 500 allergies: type: string maxLength: 500 coachFocus: type: string maxLength: 500 aiIncludedSections: maxItems: 8 type: array items: type: string enum: - ABOUT_ME - CONDITIONS - ALLERGIES - COACH_FOCUS - FAMILY_HISTORY - SMOKING_STATUS - ALCOHOL_PATTERN - SHIFT_SCHEDULE baseUpdatedAt: description: "Optimistic-concurrency base token: the `updatedAt` the client last read for this resource. Omit it for the legacy unconditional write (older clients are unaffected). When present, the write is guarded on the stored row still carrying this exact value — a stale token fails with 409 and changes nothing. A present-but-unparseable value fails with 422 and `meta.errorCode` = `invalid_base_updated_at` — note that this is NOT the unconditional write: sending `null` or a malformed string is rejected rather than treated as an omitted token. Treat as opaque: only ever echo a server-returned value, never parse or synthesise it." type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ responses: "200": description: The effective (trimmed) state echoed back plus the freshly derived pending questions and the advanced `updatedAt` token. content: application/json: 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. `meta.errorCode` = `about_me_conflict`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a19 "429": *a5 /api/anamnesis/facts: get: tags: - Insights summary: Read effective-dated anamnesis facts description: "Returns the current value and immutable revision history for the caller's smoking status, alcohol pattern and shift schedule, and only those three kinds: the life-circumstance kinds are read through `GET /api/anamnesis/life-circumstances`. Values are decrypted server-side; unreadable ciphertext returns `value: null` with `unreadable: true`." responses: "200": description: Current facts and their revision history. content: application/json: schema: $ref: "#/components/schemas/GetAnamnesisFactsResponse" "401": *a1 "403": *a15 "422": *a4 "429": *a5 post: tags: - Insights summary: Create an anamnesis fact description: "Creates the first current revision for one fact kind. The answer is encrypted before persistence. The three closed kinds take their string value and answer the closed revision shape; the six life-circumstance kinds (v1.43, send only when `features.lifeCircumstances` is true) take an object with `schema: 1` and answer a `LifeFactRevision`. Optional `validFrom` (a calendar day) dates the answer back. A second current value for the same kind returns 409; corrections use the revision endpoint." parameters: - *a6 requestBody: required: true content: application/json: schema: oneOf: - type: object properties: kind: type: string const: SMOKING_STATUS value: type: string enum: - NEVER - FORMER - CURRENT validFrom: description: The calendar day the answer holds from, stored at noon UTC like every date-only value. Omitted, it holds from now. Never later than today; an earlier day than the end of the previous answer of the same kind starts at that end instead. type: string format: date pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ required: - kind - value - type: object properties: kind: type: string const: ALCOHOL_PATTERN value: type: string enum: - NONE - OCCASIONAL - WEEKLY - MOST_DAYS validFrom: description: The calendar day the answer holds from, stored at noon UTC like every date-only value. Omitted, it holds from now. Never later than today; an earlier day than the end of the previous answer of the same kind starts at that end instead. type: string format: date pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ required: - kind - value - type: object properties: kind: type: string const: SHIFT_SCHEDULE value: type: string enum: - NONE - FIXED_SHIFT - ROTATING validFrom: description: The calendar day the answer holds from, stored at noon UTC like every date-only value. Omitted, it holds from now. Never later than today; an earlier day than the end of the previous answer of the same kind starts at that end instead. type: string format: date pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ required: - kind - value - type: object properties: kind: type: string const: TOBACCO_DETAIL value: type: object properties: schema: type: number const: 1 products: maxItems: 5 type: array items: type: string enum: - cigarettes - cigars_pipe - e_cigarette - heated_tobacco - snus_pouches cigarettesPerDay: anyOf: - type: integer minimum: 1 maximum: 100 - type: "null" startYear: anyOf: - type: integer minimum: 1900 maximum: 9007199254740991 - type: "null" quitMonth: anyOf: - type: string pattern: ^\d{4}-(0[1-9]|1[0-2])$ - type: "null" secondhandSmoke: type: - boolean - "null" required: - schema - products - cigarettesPerDay - startYear - quitMonth - secondhandSmoke additionalProperties: false validFrom: description: The calendar day the answer holds from, stored at noon UTC like every date-only value. Omitted, it holds from now. Never later than today; an earlier day than the end of the previous answer of the same kind starts at that end instead. type: string format: date pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ required: - kind - value - type: object properties: kind: type: string const: ACTIVITY_HABIT value: type: object properties: schema: type: number const: 1 moderateDaysPerWeek: anyOf: - type: integer minimum: 0 maximum: 7 - type: "null" moderateMinutesPerDay: anyOf: - type: integer minimum: 0 maximum: 600 - type: "null" sittingHoursWorkday: anyOf: - type: number minimum: 0 maximum: 24 multipleOf: 0.5 - type: "null" required: - schema - moderateDaysPerWeek - moderateMinutesPerDay - sittingHoursWorkday additionalProperties: false validFrom: description: The calendar day the answer holds from, stored at noon UTC like every date-only value. Omitted, it holds from now. Never later than today; an earlier day than the end of the previous answer of the same kind starts at that end instead. type: string format: date pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ required: - kind - value - type: object properties: kind: type: string const: WORK_CONTEXT value: type: object properties: schema: type: number const: 1 situation: anyOf: - type: string enum: - none - indoor - outdoor - mixed - type: "null" physicalNature: anyOf: - type: string enum: - mostly_sitting - standing_walking - heavy_physical - type: "null" nightWork: type: - boolean - "null" exposures: maxItems: 5 type: array items: type: string enum: - heat - cold - dust - fumes_solvents - noise required: - schema - situation - physicalNature - nightWork - exposures additionalProperties: false validFrom: description: The calendar day the answer holds from, stored at noon UTC like every date-only value. Omitted, it holds from now. Never later than today; an earlier day than the end of the previous answer of the same kind starts at that end instead. type: string format: date pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ required: - kind - value - type: object properties: kind: type: string const: FREE_WEEKDAYS value: type: object properties: schema: type: number const: 1 days: maxItems: 7 type: array items: type: integer minimum: 1 maximum: 7 required: - schema - days additionalProperties: false validFrom: description: The calendar day the answer holds from, stored at noon UTC like every date-only value. Omitted, it holds from now. Never later than today; an earlier day than the end of the previous answer of the same kind starts at that end instead. type: string format: date pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ required: - kind - value - type: object properties: kind: type: string const: HABITS value: type: object properties: schema: type: number const: 1 napRegularly: type: - boolean - "null" snoringReported: type: - boolean - "null" dietPattern: anyOf: - type: string enum: - mixed - vegetarian - vegan - pescetarian - other - type: "null" dietSpecifics: maxItems: 3 type: array items: type: string enum: - intermittent_fasting - low_carb - prescribed_diet lateMealsPerWeek: anyOf: - type: integer minimum: 0 maximum: 7 - type: "null" fruitVegPortionsPerDay: anyOf: - type: integer minimum: 0 maximum: 15 - type: "null" caffeineDrinksPerDay: anyOf: - type: integer minimum: 0 maximum: 20 - type: "null" latestCaffeine: anyOf: - type: string enum: - before_12 - 12_15 - 15_18 - after_18 - type: "null" required: - schema - napRegularly - snoringReported - dietPattern - dietSpecifics - lateMealsPerWeek - fruitVegPortionsPerDay - caffeineDrinksPerDay - latestCaffeine additionalProperties: false validFrom: description: The calendar day the answer holds from, stored at noon UTC like every date-only value. Omitted, it holds from now. Never later than today; an earlier day than the end of the previous answer of the same kind starts at that end instead. type: string format: date pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ required: - kind - value - type: object properties: kind: type: string const: HEALTH_GOALS value: type: object properties: schema: type: number const: 1 goals: maxItems: 3 type: array items: type: string enum: - weight - blood_pressure - blood_sugar - sleep - fitness - stress_recovery - medication_routine - quit_smoking - less_alcohol required: - schema - goals additionalProperties: false validFrom: description: The calendar day the answer holds from, stored at noon UTC like every date-only value. Omitted, it holds from now. Never later than today; an earlier day than the end of the previous answer of the same kind starts at that end instead. type: string format: date pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ required: - kind - value type: object responses: "201": description: The newly created current revision. content: application/json: 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: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/anamnesis/facts/{id}: patch: tags: - Insights summary: Correct a current anamnesis fact description: "Closes the current revision at the correction instant (or at the optional `validFrom` day, never earlier than the revision it replaces) and creates a successor. The value takes the stored kind's form: a string for the closed kinds, an object with `schema: 1` for the life-circumstance kinds; any other is 422 `anamnesis.fact.invalidValue`. Prior ciphertext and its half-open validity interval remain in history." parameters: - name: id in: path required: true schema: type: string requestBody: required: true content: application/json: schema: type: object properties: value: anyOf: - type: string minLength: 1 - type: object propertyNames: type: string additionalProperties: {} validFrom: description: The calendar day the answer holds from, stored at noon UTC like every date-only value. Omitted, it holds from now. Never later than today; an earlier day than the end of the previous answer of the same kind starts at that end instead. type: string format: date pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ required: - value responses: "200": description: The successor revision. content: application/json: schema: $ref: "#/components/schemas/CorrectAnamnesisFactResponse" "401": *a1 "403": *a9 "404": &a24 description: No current revision with this id exists for the authenticated account. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "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. `meta.errorCode` = `anamnesis.fact.conflict`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 delete: tags: - Insights summary: Remove a current anamnesis fact description: Closes the caller-owned current revision without deleting its encrypted history. The fact kind then resolves as not recorded until a new value is created. The path revision id is the optimistic concurrency token. parameters: - name: id in: path required: true schema: type: string responses: "200": description: Metadata for the revision whose interval was closed. content: application/json: schema: $ref: "#/components/schemas/RemoveAnamnesisFactResponse" "401": *a1 "403": *a9 "404": *a24 "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. `meta.errorCode` = `anamnesis.fact.conflict`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/anamnesis/emergency: get: tags: - Insights summary: Read the emergency profile description: "Returns the caller's emergency (Notfalldaten) profile: blood type, organ-donor and advance-directive declarations, and the decrypted emergency contacts, implants/devices and ICE note. Free text decrypts fail-soft." responses: "200": description: The emergency profile (fields null when unset). content: application/json: schema: $ref: "#/components/schemas/GetEmergencyProfileResponse" "401": *a1 "403": *a9 "422": *a4 "429": *a5 patch: tags: - Insights summary: Update the emergency profile description: Partial edit of the emergency profile on the caller's own record. Free-text fields are AES-256-GCM encrypted before persistence. Audits as `anamnesis.emergency.update`. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpdateEmergencyProfileRequest" responses: "200": description: The updated emergency profile. content: application/json: schema: $ref: "#/components/schemas/UpdateEmergencyProfileResponse" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/coach/about-me/questions: get: tags: - Insights summary: Read the pending clarifying questions description: v1.16.0 — the up-to-3 clarifying questions derived after the last self-context save. The Coach composer renders them as tappable suggestion chips. Stored encrypted; an undecryptable payload reads as an empty list (fail closed). responses: "200": description: The pending questions (possibly empty). content: application/json: schema: $ref: "#/components/schemas/GetCoachAboutMeQuestionsResponse" "401": *a1 "403": *a9 "422": *a4 "429": *a5 delete: tags: - Insights summary: Dismiss pending clarifying questions description: v1.16.0 — dismisses one question (body `{ question }`, exact match) or all of them (empty body). Tapping a chip in the Coach composer inserts the question into the chat input and dismisses it here. requestBody: required: false content: application/json: schema: type: object properties: question: description: Exact question text to dismiss. Omitted = dismiss all. type: string responses: "200": description: The remaining questions after the dismissal. content: application/json: schema: $ref: "#/components/schemas/DeleteCoachAboutMeQuestionsResponse" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/coach/about-me/adopt: post: tags: - Insights summary: Fold an answer back into the stored self-context description: >- v1.16.4 — closes the clarifying-question loop. The Coach composer's chips insert a question the person answers in their own words; this endpoint moves that answer out of the chat transcript and into the matching structured self-context field. **Which field it lands on is inferred from the wording, not chosen by the caller.** The server keyword-matches allergy and condition stems across the six UI locales; anything else lands on `coachFocus`, the generic "worth knowing" slot. `question` is optional — the remember action on a chat message sends only the message text, and the field is then matched from that text. The chosen field is reported back so the client can say where it went. **Append, never replace.** Existing text stays and the answer joins on its own line, encrypted at rest. A structured field at its 500-character cap overflows into the free-text `aboutMe` instead of failing; only when THAT is full too does the write refuse. **A duplicate is a success, not an error.** An answer already contained in the target field or in `aboutMe` returns 200 with `adopted: false` and `reason: "duplicate"`, so tapping the offer twice cannot stack the same prose. Containment is checked case-insensitively on whitespace-collapsed text, so a near-identical rephrasing IS adopted as a second line. The read-modify-write runs under a row lock, so two concurrent adoptions cannot lose an append or miss each other's dedupe. Audit rows carry the field name and the answer's LENGTH — never the text, which is free-form health prose. Coach-module-gated; rate-limited to 30 per minute per user; body cap 16 KiB. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CoachAboutMeAdoptRequest" responses: "200": description: 'The adoption resolved. `adopted: true` means the text was appended; `adopted: false` with `reason: "duplicate"` means it was already there and nothing was written.' content: application/json: schema: $ref: "#/components/schemas/CoachAboutMeAdoptResponse" "401": *a1 "403": 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: $ref: "#/components/schemas/ErrorEnvelope" "413": description: Body exceeds 16 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "415": description: "`Content-Type` is not `application/json`." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": description: Either the body failed validation (every issue is listed), or the self-context is FULL — the matched field and the 4000-character `aboutMe` are both at their caps, so there is nowhere to put the answer. The two are told apart by the presence of the issue list. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 /api/coach/plans: get: tags: - Insights summary: List the caller's Coach goal / if-then plans description: 'v1.21.3 — returns the durable plans the Coach has proposed for the caller, newest first, each decrypted on the fly. A plan is an "if-then" implementation intention tied to one metric, with an optional target. The Coach extractor writes a plan as `proposed`; only `PATCH /api/coach/plans/{id}` activates it. Pass `?status=` to filter to one lifecycle status, or `?scope=` for a named group (open = proposed + active + review_due, past = met + abandoned + reviewed, all = every non-deleted plan) — mutually exclusive. Both omitted returns the non-terminal set (proposed + active). Not gated on the Coach: the plans belong to the caller and stay readable while the Coach is unavailable (v1.39). Auth via cookie or Bearer; the owner is always narrowed from the session, never the body. Undecryptable rows are omitted rather than failing the read.' parameters: - name: status in: query required: false schema: type: string enum: - proposed - active - met - abandoned - review_due - reviewed description: Filter to a single lifecycle status. Mutually exclusive with `scope`. Omit both for the non-terminal set (proposed + active). - name: scope in: query required: false schema: type: string enum: - open - past - all description: "Named status group: open (proposed + active + review_due), past (met + abandoned + reviewed), or all. Mutually exclusive with `status`." responses: "200": description: The caller's plans. content: application/json: schema: $ref: "#/components/schemas/CoachPlansList" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/coach/plans/{id}: patch: tags: - Insights summary: Confirm or update a Coach plan's lifecycle description: v1.21.3 — confirm a proposed plan (status proposed → active) or mark it met / abandoned, and optionally set / clear a review date. The body carries ONLY lifecycle fields — never the metric or the encrypted free text — so a client can change a plan's status but never inject or overwrite its prose. A foreign / unknown / already-deleted id maps to 404 (never 403) so the existence channel does not leak across accounts. Coach-gated. Auth via cookie or Bearer; the owner is narrowed from the session. parameters: - name: id in: path required: true schema: type: string description: Plan id. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CoachPlanPatchRequest" responses: "200": description: The plan after the lifecycle update. content: application/json: schema: $ref: "#/components/schemas/CoachPlanUpdated" "401": *a1 "403": 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: $ref: "#/components/schemas/ErrorEnvelope" "404": description: Plan not found or not owned by the caller. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 delete: tags: - Insights summary: Soft-delete one Coach plan description: "v1.21.3 — soft-deletes a single plan owned by the caller. An unknown / cross-user / already-deleted id is an idempotent no-op returning `{ deleted: false }`, never revealing whether the id exists under another account. Not gated on the Coach: erasing one's own plan works while the Coach is unavailable (v1.39). Auth via cookie or Bearer." parameters: - name: id in: path required: true schema: type: string description: Plan id. responses: "200": description: "The plan was soft-deleted (`deleted: true`) or the id matched nothing the caller owns (`deleted: false`)." content: application/json: schema: $ref: "#/components/schemas/CoachPlanDeleted" "401": *a1 "403": 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: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/coach/reminder-suggestions: post: tags: - Insights summary: Act on a Coach cadence suggestion description: 'v1.18.1 — accept / dismiss / stop a Coach-proposed measurement cadence. Coach-gated (`requireModuleEnabled("coach")`); a disabled surface 403s. `accept` resolves the cadence from the closed server-side catalog and creates a `MeasurementReminder` with `origin: COACH` (201), or returns 200 with `duplicate: true` when a live COACH reminder for that metric already exists — idempotent against a re-tapped or stale card, with a partial unique index as the structural backstop. Per-user rate-limited (429 on excess). Auth via cookie or Bearer; the owner is narrowed from the session.' requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CoachReminderSuggestionAction" responses: "200": description: "A dismiss/stop, or an accept that matched an existing reminder (`duplicate: true`)." content: application/json: schema: $ref: "#/components/schemas/CoachReminderSuggestionResultOk" "201": description: An accept that created a new reminder. content: application/json: schema: $ref: "#/components/schemas/CoachReminderSuggestionResultCreated" "401": *a1 "403": 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: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/coach/reminders: get: tags: - Insights summary: List the caller's Coach reminders description: 'v1.22 (B2/B6) — the durable "remind me about X" memory the Coach captured inline, decrypted on the fly, soonest-due first. Pass `?status=` (one status or a comma set like `due,surfaced` for the in-app tile); omitted returns the non-terminal set (proposed + active + due + surfaced). Not gated on the Coach: the reminders belong to the caller and stay readable while the Coach is unavailable (v1.39). Auth via cookie or Bearer; the owner is narrowed from the session. Undecryptable rows are omitted.' parameters: - name: status in: query required: false schema: type: string description: Filter to one status or a comma-separated set (proposed | active | due | surfaced | done | dismissed). Omit for the non-terminal set. responses: "200": description: The caller's reminders. content: application/json: schema: $ref: "#/components/schemas/CoachRemindersList" "401": *a1 "403": *a15 "422": *a4 "429": *a5 post: tags: - Insights summary: Create a Coach reminder manually description: v1.22 — create a reminder from the ledger. `note` is the only writable content; `when` is the closed grammar (ISO date | +Nd / +Nw | a context cue) resolved into the trigger server-side; `metric` optional. The per-user cap returns 409 when the non-terminal set is full. Coach-gated. Auth via cookie or Bearer; the owner is narrowed from the session. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CoachReminderCreate" responses: "201": description: The created reminder. content: application/json: schema: $ref: "#/components/schemas/CoachReminderCreated" "401": *a1 "403": 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: $ref: "#/components/schemas/ErrorEnvelope" "409": description: The per-user reminder cap is full. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/coach/reminders/{id}: patch: tags: - Insights summary: Confirm or update a Coach reminder's lifecycle description: v1.22 — confirm a proposed reminder (→ active), mark it done / dismissed, or re-schedule via the closed `when` grammar (null clears the due moment). The body never carries the note text. A foreign / unknown / already-deleted id maps to 404 (never 403) so the existence channel does not leak. Coach-gated. Auth via cookie or Bearer. parameters: - name: id in: path required: true schema: type: string description: Reminder id. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CoachReminderPatch" responses: "200": description: The reminder after the lifecycle update. content: application/json: schema: $ref: "#/components/schemas/CoachReminderUpdated" "401": *a1 "403": 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: $ref: "#/components/schemas/ErrorEnvelope" "404": description: Reminder not found or not owned by the caller. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 delete: tags: - Insights summary: Soft-delete one Coach reminder description: "v1.22 — soft-deletes a single reminder owned by the caller. An unknown / cross-user / already-deleted id is an idempotent no-op returning `{ deleted: false }`. Not gated on the Coach: erasing a stored reminder works while the Coach is unavailable (v1.39). Auth via cookie or Bearer." parameters: - name: id in: path required: true schema: type: string description: Reminder id. responses: "200": description: "The reminder was soft-deleted (`deleted: true`) or the id matched nothing the caller owns (`deleted: false`)." content: application/json: schema: $ref: "#/components/schemas/CoachReminderDeleted" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/coach/suggested-actions: post: tags: - Insights summary: Confirm a Coach action card description: v1.22 (F6) — the confirm half of the generalised propose→confirm moat. `actionType` is from the CLOSED allowlist (checkup.create | reminder.note) — NEVER a medication or clinical change. `checkup.create` builds a preventive-care Vorsorge `MeasurementReminder` (free-text label + a closed interval id resolved to an RRULE server-side); `reminder.note` builds a `CoachReminder`. Nothing is created without this explicit confirm. Coach-gated (`requireModuleEnabled("coach")`); per-user rate-limited (429). Auth via cookie or Bearer; the owner is narrowed from the session, built field-by-field — no mass assignment, no IDOR. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CoachSuggestedAction" responses: "201": description: The action was applied (entity created server-side). content: application/json: schema: $ref: "#/components/schemas/CoachSuggestedActionResultCreated" "401": *a1 "403": 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: $ref: "#/components/schemas/ErrorEnvelope" "409": description: The per-user reminder cap is full (reminder.note). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/admin/invites: get: tags: - Admin summary: List registration invites description: v1.16.0 — every invite with creator / consumer usernames, use counters, soft-revocation timestamp, and the full per-signup redemption ledger (`redemptions`; `consumer` only carries the LAST account on a multi-use invite). Metadata only — the raw token is never derivable from this endpoint (only its HMAC hash is persisted). Admin session cookie required; Bearer tokens cannot reach admin endpoints. responses: "200": description: All invites, newest first. content: application/json: schema: $ref: "#/components/schemas/AdminInviteList" "401": *a1 "403": &a25 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: tags: - Admin summary: Mint a registration invite description: v1.15.20 — creates an invite that admits a signup even while open registration is disabled. The raw `hlv_<64hex>` token and the composed registration URL appear EXACTLY ONCE in this response; only the keyed hash is persisted. Lifetime is capped at 30 days; `maxUses` makes multi-use invites possible (consumption is an atomic guarded increment). Admin session cookie required. requestBody: required: true content: application/json: schema: type: object properties: expiresInDays: default: 7 type: integer minimum: 1 maximum: 30 maxUses: default: 1 type: integer minimum: 1 maximum: 50 responses: "201": description: The minted invite, including the one-time raw token. content: application/json: schema: $ref: "#/components/schemas/AdminInviteCreated" "401": *a1 "403": *a25 "422": *a4 "429": *a5 /api/admin/invites/{id}: delete: tags: - Admin summary: Revoke a registration invite description: "v1.16.0 — soft-revokes the invite (`revokedAt`): the row keeps its redemption history visible in the admin table while the consume path refuses the link like an expired one. Idempotent: an unknown or already-revoked id returns `{ revoked: false }` instead of 404. Admin session cookie required." parameters: - in: path name: id schema: type: string required: true responses: "200": description: "The invite was revoked (`revoked: true`) or nothing matched (`revoked: false`)." content: application/json: schema: $ref: "#/components/schemas/AdminInviteRevoked" "401": *a1 "403": *a25 "422": *a4 "429": *a5 /api/insights/chat/messages/{id}/feedback: post: tags: - Insights summary: Rate a Coach assistant message description: "Persists a helpful/unhelpful rating for a single Coach reply. Reuses the v1.4.16 RecommendationFeedback table via the polymorphic `targetType` column. The aggregator buckets ratings by (promptVersion, tone, verbosity). Never AI-gated: a rating on a message that already exists calls no model." requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CoachMessageFeedbackRequest" responses: "201": description: Feedback saved. content: application/json: schema: $ref: "#/components/schemas/CoachMessageFeedbackResponse" "401": *a1 "403": *a9 "404": description: Message not found or not owned by the caller. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "409": description: Caller has already rated this message text. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/admin/maintenance/measurements: get: tags: - Admin summary: Where measurement table maintenance stands description: "The newest maintenance run and its state, whether the compaction-tombstone purge is still at work, and the table's size right now. The admin card polls it while a run is queued or running. Cookie auth only: `requireAdmin()` refuses every Bearer caller." responses: "200": description: The status. content: application/json: schema: $ref: "#/components/schemas/AdminMeasurementMaintenanceStatusResponse" "401": *a1 "403": *a25 "422": *a4 "429": *a5 post: tags: - Admin summary: Queue measurement table maintenance description: "Queues one `VACUUM (ANALYZE)` and `REINDEX INDEX CONCURRENTLY` pass over the `measurements` table, for after a large purge. Runs in the background worker, one statement at a time; pick a quiet window, because a rebuild needs free disk the size of the index it rebuilds. At most one run at a time. Cookie auth only: `requireAdmin()` refuses every Bearer caller. Runbook: `docs/ops/measurement-maintenance.md`." requestBody: required: false content: application/json: schema: $ref: "#/components/schemas/AdminMeasurementMaintenanceRequest" responses: "202": description: Queued, or already queued. content: application/json: schema: $ref: "#/components/schemas/AdminMeasurementMaintenanceResponse" "401": *a1 "403": 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: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 "503": description: No background worker is bound, so nothing would run it. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/admin/notifications/reminder-check: post: tags: - Admin summary: Run the medication-reminder sweep description: >- The admin panel's manual reminder sweep: it classifies every active medication's dose windows against the OWNING account's wall clock and dispatches a MEDICATION_REMINDER for each overdue slot that has no intake event today. Cookie auth only — `requireAdmin()` refuses every Bearer caller, wildcard scope included. Scope is the thing to get right. With no body the sweep is INSTANCE-WIDE and dispatches into every account that has an overdue dose and a configured channel; that is the operator button's behaviour and it is deliberate. A caller that means to sweep one account sends `userId` and stops being a second writer for every other one. The selector can only narrow: it feeds the Prisma `where`, never the authorisation. requestBody: required: false content: application/json: schema: type: object properties: userId: type: string minLength: 1 maxLength: 64 pattern: ^[A-Za-z0-9_-]+$ additionalProperties: false responses: "200": description: The sweep's findings and how many reminders it sent. content: application/json: schema: $ref: "#/components/schemas/AdminReminderCheckResponse" "401": *a1 "403": 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: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/admin/notifications/diagnostic: get: tags: - Admin summary: Admin notification diagnostic snapshot description: "Admin-only. Returns what the dispatcher would see when targeting the calling admin's account: registered devices (APNs tokens masked to prefix + suffix), per-channel enabled + configPresent flags, and recent push attempts (currently always `[]` pending the v1.4.48+ PushAttempt table). Cookie auth only — never Bearer." responses: "200": description: Diagnostic snapshot. content: application/json: schema: $ref: "#/components/schemas/AdminDiagnosticResponse" "401": *a1 "403": 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: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/dashboard/snapshot: get: tags: - Dashboard summary: Unified dashboard first-paint snapshot description: "Assembles every above-the-fold tile field in one round-trip from the rollup / mood / widget helpers plus a read-only lift of the pre-generated daily briefing. Two-phase: `tiles` always present, `extras` nullable on a rollup-coverage miss. No LLM is reachable from this path. The briefing is model text: it, its recall (`briefingMemory`) and its timestamp are served only while the `briefing` AI capability is available for the record, decided on every read rather than cached with the body; `briefingAi` carries the state and `briefingState` reads `no-provider` or `disabled` otherwise. Every other field is data and never depends on AI. Cookie or Bearer auth." responses: "200": description: Dashboard snapshot. content: application/json: schema: $ref: "#/components/schemas/DashboardSnapshotResponseEnvelope" "401": *a1 "403": *a15 "422": *a4 "429": *a5 /api/insights/comprehensive: get: tags: - Insights summary: The Insights overview's computed read description: "The main read of the Insights overview: summaries, classifications, correlations, medication compliance and rule alerts, all computed from the record. No provider is called and no AI switch, opt-out, provider or consent refuses it; `hasProvider` is provider presence only. The `ai` block beside the body says whether the briefing and the status notes that decorate the overview can be shown, resolved per request. Served stale-while-revalidate; `revalidating` marks a stale body. Delegable at MANAGE level over the whole record. Cookie or Bearer auth." responses: "200": description: The overview read. content: application/json: schema: $ref: "#/components/schemas/InsightsComprehensiveResponseEnvelope" "401": *a1 "403": *a15 "422": *a4 "429": *a5 /api/insights/cards: get: tags: - Insights summary: Insight cards (iOS adapter) description: 'v1.4.31 — the native-client adapter over the same alert rule engine the web comprehensive surface consumes: measurements, BP-in-target, weight trend, pulse, and cadence-aware medication compliance are fed through `generateAlerts()` and each resulting `HealthAlert` is re-shaped to the iOS Insight card model. Deterministic — no LLM call on this path, so no AI switch, opt-out or module refuses it. Every card is a rule alert: `provider` is `"rules"` and `summary` is never null. Auth via cookie or Bearer.' responses: "200": description: The list of insight cards (possibly empty). content: application/json: schema: $ref: "#/components/schemas/InsightsCardsEnvelope" "401": *a1 "403": *a15 "422": *a4 "429": *a5 /api/insights/pregenerate: post: tags: - Insights summary: Warm all AI assessments for the calling user description: "v1.8.7.1 — enqueue a full warm of every AI assessment for the authenticated user (comprehensive insight + the seven specialised status cards + every data-bearing generic metric assessment) in the active locale, so the read-only status GETs serve cached text instantly. Returns immediately; the generation runs out of band on the worker. Covers two AI capabilities, `briefing` and `statusText`, and never refuses: with neither available it answers `queued: false` and enqueues nothing (and spends no anti-spam bucket); with one available it enqueues and the worker checks each half before it builds anything. The `ai` block says which half can run. Empty metrics and provider-less accounts never trigger an LLM call. Short anti-spam bucket (`insights-warm:`, one warm per 3 minutes) → 429 on a tight loop. Auth via cookie or Bearer; `userId` is taken from the session, never the body." requestBody: required: false content: application/json: schema: $ref: "#/components/schemas/InsightsPregenerateRequest" responses: "200": description: "`queued: true`: the warm was enqueued; the work runs on the worker, so poll the read-only status routes for the text. `queued: false`: neither capability is available and nothing was enqueued; `ai` says why." content: application/json: schema: $ref: "#/components/schemas/InsightsPregenerateResponseEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/analytics: get: tags: - Analytics summary: The dashboard analytics aggregate description: >- The web dashboard's aggregate read, in two shapes chosen by `?slice=`. `?slice=summaries` returns the slim tile-strip body from two SQL passes; omitting the parameter returns the thick default body. Any OTHER value is refused with 422 (`meta.errorCode` = `analytics.invalid_query`). It used to fall through to the default instead, which made `?slice=summary` answer 200 after running the heaviest chain on the surface — the typo was invisible and expensive at the same time. Both shapes are served through a stale-while-revalidate cache. A cached body can be up to a minute old, and the default slice's cache key includes the reader's locale because the correlation `interpretation` sentences are narrated per reader. `Cache-Control: private, max-age=0, must-revalidate` on both — bfcache-friendly rather than the framework's stock `no-store`. This GET is not free of side effects, and a caller polling it should know: it records a Health Score row for the scored instant, syncs accepted correlation hypotheses into the pattern ledger (which is what mints the `patternId` the dismissal endpoint takes), and kicks a background rollup-freshness refresh. The refresh is fire-and-forget by design — awaiting it stalled the event loop for tens of seconds on large accounts — so a value can be up to about a minute behind a measurement that just landed, and the next read has it. Auth is a cookie session or a wildcard (`*`) Bearer token; a narrow-scope token is refused. NOT delegable: it resolves the caller's own record, so a switched session still reads its own analytics. parameters: - name: slice in: query required: false schema: type: string enum: - summaries description: "`summaries` selects the slim tile-strip body. Omit it for the default body. Any other value is a 422 — it is not treated as an omission." responses: "200": description: The analytics body. `AnalyticsSummariesSlice` for `?slice=summaries`, `AnalyticsDefaultResponse` otherwise. content: application/json: 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`." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 /api/insights/generate: get: tags: - Insights summary: Read the cached advisor briefing (never generates) description: 'The read-only advisor. It serves the cached payload and NEVER calls a provider — the surfaces that mount on page load used to POST here, which blocked first paint on the whole provider chain. When the cache is stale or missing AND a provider exists it enqueues an out-of-band warm and says so in `revalidating`; the next read reflects it. User-initiated regeneration is the POST. Four honesty signals ride the payload and are the whole point of it: `hasProvider: false` means no warm pass can ever refresh what is shown, so stop polling and offer to connect one; `generationFailed` means the last attempt failed and the text is HELD rather than fresh; `generationFailureClass` says which lever to point at; and `briefingOmittedReason: "ungrounded"` means a generation SUCCEEDED and the grounding gate withheld the briefing, which is a different state from a failure. A pure AI read: the payload is the model-written briefing and nothing else, so it requires the `briefing` AI capability and refuses with the capability envelope before the cache is read; stored briefing text is never served, and no warm is enqueued, while the capability is unavailable. Cookie or Bearer auth; the caller is always resolved as themselves, so this read cannot be delegated to a shared record.' responses: "200": description: The cached payload, or the empty shape when nothing is cached. Always 200 — an absent briefing is a normal answer here, not an error. content: application/json: schema: $ref: "#/components/schemas/InsightsGenerateReadEnvelope" "401": *a1 "403": 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: $ref: "#/components/schemas/ErrorEnvelope" "422": description: "No configured AI provider can serve the `briefing` capability: `meta.errorCode` = `ai.provider.none`, `meta.reason` = `no_provider`." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 "503": *a26 post: tags: - Insights summary: Generate the advisor briefing inline (costs tokens) description: "The only path in the advisor family that calls a provider, and it does so INLINE. Two gates bound it, and they bound different things. The RATE gate is `insights:`, ten generations per hour by default and configurable by the operator through `INSIGHTS_RATE_LIMIT_PER_HOUR` (a value below 1 falls back to the default); it is checked AFTER the cache short-circuit, so a cache hit never spends a token. The BUDGET gate is the day's token ceiling, refused at reservation time before any provider is contacted — so it is not a provider failure, records no failure marker, and answers 429 with `meta.errorCode` = `insights.generate.budgetExceeded`, which is how a client tells 'you asked too often' from 'the day's spend is gone'. Two more things a caller should know before integrating: a plain 200 does NOT mean a provider ran — a fresh cache inside 24 hours answers `cached: true`, and so does an exhausted quota when the fresh cache merely lacked a briefing — and a request that TIMES OUT client-side may still complete on the server, writing the briefing to the cache or a dated failure marker, which is exactly the pair the read GET reports so a client can settle an aborted regenerate by polling rather than guessing. Requires the `briefing` AI capability (the operator's briefing switch, the AI analysis opt-out, a provider, and consent when the chain egresses through the operator's server-managed key), checked before any read or provider work. Cookie or Bearer auth; not delegable." requestBody: required: false content: application/json: schema: $ref: "#/components/schemas/InsightsGeneratePostRequest" responses: "200": description: A payload — freshly generated, served from the 24-hour cache, or served from a briefingless cache after the hourly quota refused the regeneration. `cached` tells the three apart. content: application/json: schema: $ref: "#/components/schemas/InsightsGeneratePostEnvelope" "401": *a1 "403": 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: $ref: "#/components/schemas/ErrorEnvelope" "409": description: The account's privacy mode, exclude list, locale or profile changed while the generation was running, so the finished text describes a scope that no longer applies and is discarded rather than written. `meta.errorCode` = `insights.generate.scopeChanged` or `insights.generate.profileScopeChanged`. Retry. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "413": description: The request body is over 16 KB. Nothing was generated. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": description: No AI provider can serve the `briefing` capability (`meta.errorCode` = `ai.provider.none`, `meta.reason` = `no_provider`), or the provider answered something that was not valid JSON (`meta.errorCode` = `ai_response_truncated` when the answer was cut off mid-stream rather than malformed), or the finished prose was withheld by the outbound safety screen (`insights.generate.outboundScreened`). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": description: "Three different refusals share this status and are told apart by `meta.errorCode`. No code: more than the hourly limit of generations for this account. `insights.generate.budgetExceeded`: the DAY's token ceiling refused the reservation before any provider was contacted — retry tomorrow, not in an hour. No code, with provider wording: the upstream provider rate-limited every hop of the chain." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "502": description: The generated briefing did not pass the outbound safety screen and was discarded (`meta.errorCode` = `insights.generate.outboundScreened`). Regenerating may succeed. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "503": description: "Every provider in the chain failed for a reason that is not a rate limit — auth, timeout or an upstream 5xx. A dated failure marker is written, so the read GET reports the briefing as held rather than fresh. Also: the `briefing` capability could not be resolved (`meta.errorCode` = `ai.unavailable`, `meta.reason` = `check_failed`); nothing was generated." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/insights/provider-chain: get: tags: - Insights summary: Read the account's AI-provider chain description: "The chain as the settings surface renders it: the resolved head, the last provider that actually worked, and the PERSISTED list with disabled entries included. That last point is the one that matters — the resolved list drops disabled entries, and returning it here once made a disabled provider vanish from the settings list along with the toggle that would have brought it back. No key, token or account id is ever on this wire; the response carries provider TYPES and boolean state only. Cookie or Bearer auth; not delegable." responses: "200": description: The chain summary. content: application/json: schema: $ref: "#/components/schemas/ProviderChainEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 put: tags: - Insights summary: Replace the account's AI-provider chain description: Persists the chain wholesale in the order the array gives. Any `priority` a client sends is accepted and IGNORED — the server recomputes it from insertion order, so a stale tab cannot store a chain whose displayed order disagrees with its stored one. A repeated provider type is refused rather than deduplicated on read, because drift between the two representations is the kind of thing that costs an evening. No credential is written or read here. Body capped at 64 KiB. Cookie or Bearer auth; not delegable. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/ProviderChainPutRequest" responses: "200": description: Saved. The response confirms the write and does not echo the chain — re-read it if the resolved state is needed. content: application/json: 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. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 /api/insights/feedback: post: tags: - Insights summary: Rate one recommendation description: "Records a thumbs-up or thumbs-down against one recommendation, with the rendered text snapshotted alongside the id. The server fills the provider and prompt-version attribution from the account's own state so a client cannot skew the slice the quality dashboard reports on. Idempotent: send `Idempotency-Key` and a retry replays the first response rather than writing twice. Rate-limited 60/h per account — the unique index only catches EXACT replays, so without the cap a client could distort the aggregator by varying the id or the text per request. Body capped at 64 KiB. Cookie or Bearer auth; not delegable." parameters: - *a6 requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/InsightsFeedbackRequest" responses: "201": description: The rating was recorded. content: application/json: 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 — this route does not use the multi-issue form. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": description: More than 60 ratings in the trailing hour for this account. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/insights/coach-read: get: tags: - Insights summary: The two-line Coach read for one metric description: "The own-baseline placement and the single strongest lagged association whose outcome is the named metric — the two server-authoritative lines a metric sub-page renders above its chart. Pure compute over the baseline and correlation engines: no provider call and no cache table, so web and native decode the same DTO rather than each deriving one. Line two arrives as a finished sentence in the reader's language and is printed verbatim, so the resolved locale decides the row; a correlation failure degrades it to null and never sinks line one. Both lines are computed, so no AI switch, opt-out or module refuses it (the name is historical). Shared analytics-read budget. Delegable at MANAGE level over the whole record: it is computed across the record with no provider on the path. Cookie or Bearer auth." parameters: - in: query name: metric schema: description: "The metric the strip is about. Closed enum: an unknown value 422s." $ref: "#/components/schemas/MeasurementType" required: true description: "The metric the strip is about. Closed enum: an unknown value 422s." responses: "200": description: The strip. content: application/json: schema: $ref: "#/components/schemas/CoachReadStripEnvelope" "401": *a1 "403": *a15 "422": *a4 "429": *a5 /api/insights/narrative: get: tags: - Insights summary: The latest week or month retrospective description: "The last generated period summary for the record. Read-only by construction: it never blocks on a provider, serves the last good row immediately, and warms out of band when the row is stale or missing. A provider-less account is not left empty — the generator falls back to deterministic, non-causal prose, so even the no-key demo gets a retrospective on the next read. `revalidating` reports whether this read enqueued a warm, and is always FALSE on a delegated request: the route warms unconditionally rather than on a miss, so without that suppression a manager's first navigation here would be an egress of the owner's record the owner never asked for. Rows are keyed per locale, so `locale` selects a different stored row rather than translating one. A mixed read: a narrative the deterministic composer wrote is data and always served; one a model wrote is served only while the `periodNarrative` AI capability is available, otherwise `narrative` is null and `ai` says why. While the capability is unavailable a warm is enqueued only when nothing servable exists (the worker then writes the deterministic narrative). Never refused for an AI reason. Delegable at MANAGE level over the whole record. Cookie or Bearer auth." parameters: - in: query name: period schema: type: string enum: - week - month description: "Which retrospective to read. Closed enum: unknown 422s." required: true description: "Which retrospective to read. Closed enum: unknown 422s." - in: query name: locale schema: description: Override the reader's resolved locale. The narrative row is keyed per locale, so this selects a different stored row rather than translating one; an unrecognised value falls back to the app default instead of 422-ing. type: string description: Override the reader's resolved locale. The narrative row is keyed per locale, so this selects a different stored row rather than translating one; an unrecognised value falls back to the app default instead of 422-ing. responses: "200": description: "The retrospective, or `narrative: null` when none is on file yet. Always 200 — absence is a normal answer." content: application/json: schema: $ref: "#/components/schemas/NarrativeEnvelope" "401": *a1 "403": *a15 "422": *a4 "429": *a5 /api/insights/glp1-timeline: get: tags: - Insights summary: GLP-1 therapy timeline description: "A chronological merge of every GLP-1-relevant event the record holds: titration steps, injections with their sites, legacy stock-ledger movements, and side-effect-tagged mood days from the trailing 90 days. `hasGlp1: false` with no entries when the record holds no medication classified GLP-1, so the surface hides cleanly. Two limits worth knowing before reading the output as complete: inventory entries come from the LEGACY running-sum ledger only, so a medication tracked with per-container inventory shows none, and the side-effect scan matches a FIXED bilingual list of English and German tag strings, so a tag written in another language contributes nothing. `limit` is forgiving — an out-of-range or non-numeric value falls back to the default rather than 422-ing. Delegable at MANAGE level over the whole record: it spans medications and mood, with no provider on the path. Cookie or Bearer auth." parameters: - in: query name: limit schema: default: 60 description: Entry cap, 1..200, default 60. A non-integer, out-of-range or non-numeric value falls back to the default rather than 422-ing. type: integer minimum: 1 maximum: 200 description: Entry cap, 1..200, default 60. A non-integer, out-of-range or non-numeric value falls back to the default rather than 422-ing. responses: "200": description: The timeline, newest first. content: application/json: schema: $ref: "#/components/schemas/Glp1TimelineEnvelope" "401": *a1 "403": *a15 "422": *a4 "429": *a5 /api/insights/pulse/intraday: get: tags: - Insights summary: One day's intraday heart-rate shape description: "The day's ten-minute mean heart-rate series plus, when every confidence gate holds, at most ONE cautious elevated-at-rest window. Computed from raw samples through the read-swap pattern rather than persisted as ten-minute rollups for all history, so a day outside the dense-retention window reads back at the coarser hourly grain instead of empty — `resolution` says which, and `tension` is always null on an hourly day because that read needs per-sample resolution. The bar for a tension window is deliberately high; under-flagging is the intended failure mode, and it is awareness, never a diagnosis. `date` is STRICT here, unlike the forgiving date filters elsewhere on the surface: anything but a `YYYY-MM-DD` literal is a 422. Pulse is a core vital, so no module gates it and no AI switch refuses it. Delegable at MANAGE level over the whole record. Cookie or Bearer auth." parameters: - in: query name: date schema: description: The local day to read. Defaults to the record's local today. Anything but a `YYYY-MM-DD` literal is a 422 — this parameter is strict where most of the surface's date filters are forgiving. type: string pattern: ^\d{4}-\d{2}-\d{2}$ description: The local day to read. Defaults to the record's local today. Anything but a `YYYY-MM-DD` literal is a 422 — this parameter is strict where most of the surface's date filters are forgiving. responses: "200": description: The day's series. `Cache-Control` is the bfcache-friendly `private, max-age=0, must-revalidate` rather than `no-store`. content: application/json: schema: $ref: "#/components/schemas/IntradayPulseEnvelope" "401": *a1 "403": *a15 "422": *a4 "429": *a5 /api/insights/settings: get: tags: - Insights summary: Read the account's AI-provider settings description: Returns the account's own provider connection state and privacy mode alongside PRESENCE-ONLY flags for the operator's shared key and shared ChatGPT connection. No key, token or account id is ever on this wire. Cookie or Bearer auth; the caller is always resolved as themselves, so this read cannot be delegated to a shared record. responses: "200": description: The settings. content: application/json: schema: $ref: "#/components/schemas/InsightsSettingsEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 put: tags: - Insights summary: Update the account's AI privacy mode description: "Writes `privacyMode` and nothing else. Changing it clears the cached insight text and its timestamp so the next generation runs under the new mode. The body is inspected key by key rather than Zod-parsed: an unknown key is ignored rather than refused, and a body that recognises nothing is refused with 422 `No changes` rather than answered as a no-op — so a client cannot tell a typo'd field name from a rejected value except by reading the message. Body capped at 64 KiB. Cookie or Bearer auth; not delegable." requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/InsightsSettingsPutRequest" responses: "200": description: Saved. content: application/json: 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 validates by hand rather than through Zod." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 /api/insights/targets: get: tags: - Insights summary: Per-metric target tiles + consistency strips description: "The whole targets page in one read: a tile per metric with its band, its current and 30-day-average values, and the seven-day consistency strip behind it, plus the page summary, the diastolic companion figures and the profile facts every band was resolved from. Pure compute over the record's own data — no provider anywhere on the path. Stale-while-revalidate: past the fresh window the prior body serves immediately while one background rebuild warms the cell, and a write hard-evicts the bucket so the account's own action lands on the next read. Delegable at MANAGE level over the whole record: the walk spans vitals, sleep, medications, mood and glucose, so a section-scoped grant is refused rather than served a filtered page. Not module-gated as a whole; a tile whose data does not exist is simply absent. Cookie or Bearer auth." responses: "200": description: The target tiles and their page summary. content: application/json: schema: $ref: "#/components/schemas/TargetsEnvelope" "401": *a1 "403": *a15 "422": *a4 "429": *a5 /api/analytics/range: get: tags: - Analytics summary: Single-metric period-over-period range delta description: v1.9.0 — returns the current-window aggregate, the previous comparable window, and the composed delta for ONE metric type over a `7d` / `30d` / `90d` / `1y` range. Single-type by construction (the metric page is single-metric), so the read is one rollup-tier call covering the trailing 2N days sliced into the two halves — no per-type fan-out. Additive route; the `/api/analytics` envelope is unchanged. Auth via cookie or Bearer. parameters: - in: query name: type schema: description: "The measurement type to read (single metric — no fan-out). Closed enum: an unknown type 422s." $ref: "#/components/schemas/MeasurementType" required: true description: "The measurement type to read (single metric — no fan-out). Closed enum: an unknown type 422s." - in: query name: range schema: type: string enum: - 7d - 30d - 90d - 1y description: "Trailing window: `7d` / `30d` / `90d` / `1y`. The previous comparable window is the equally-sized span immediately before it." required: true description: "Trailing window: `7d` / `30d` / `90d` / `1y`. The previous comparable window is the equally-sized span immediately before it." responses: "200": description: Current + previous window aggregates and the delta. content: application/json: schema: $ref: "#/components/schemas/AnalyticsRangeResponseEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/insights/blood-pressure-status: get: tags: - Insights summary: Blood-pressure assessment description: "Data-driven plain-language assessment of the user's recent blood-pressure readings. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). The note is model text: it is served, and warmed, only while the `statusText` AI capability is available. Otherwise the answer is 200 with the note null, `preparing` false, `hasProvider` as provider presence and `ai` naming the reason; nothing is read from the cache or queued. Never refused for an AI reason, and the `insights` module (the AI analysis opt-out) is folded into the capability. Auth via cookie or Bearer." parameters: - in: query name: locale schema: description: Optional UI-locale override; defaults to the session locale. type: string enum: - de - en description: Optional UI-locale override; defaults to the session locale. responses: "200": description: Assessment envelope (fresh, cached, or preparing). content: application/json: schema: $ref: "#/components/schemas/BloodPressureStatusResponseEnvelope" "401": *a1 "403": *a15 "422": *a4 "429": *a5 /api/insights/pulse-status: get: tags: - Insights summary: Pulse assessment description: "Data-driven plain-language assessment of the user's recent resting-pulse readings. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). The note is model text: it is served, and warmed, only while the `statusText` AI capability is available. Otherwise the answer is 200 with the note null, `preparing` false, `hasProvider` as provider presence and `ai` naming the reason; nothing is read from the cache or queued. Never refused for an AI reason, and the `insights` module (the AI analysis opt-out) is folded into the capability. Auth via cookie or Bearer." parameters: - in: query name: locale schema: description: Optional UI-locale override; defaults to the session locale. type: string enum: - de - en description: Optional UI-locale override; defaults to the session locale. responses: "200": description: Assessment envelope (fresh, cached, or preparing). content: application/json: schema: $ref: "#/components/schemas/PulseStatusResponseEnvelope" "401": *a1 "403": *a15 "422": *a4 "429": *a5 /api/insights/weight-status: get: tags: - Insights summary: Weight assessment description: "Data-driven plain-language assessment of the user's recent weight trend. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). The note is model text: it is served, and warmed, only while the `statusText` AI capability is available. Otherwise the answer is 200 with the note null, `preparing` false, `hasProvider` as provider presence and `ai` naming the reason; nothing is read from the cache or queued. Never refused for an AI reason, and the `insights` module (the AI analysis opt-out) is folded into the capability. Auth via cookie or Bearer." parameters: - in: query name: locale schema: description: Optional UI-locale override; defaults to the session locale. type: string enum: - de - en description: Optional UI-locale override; defaults to the session locale. responses: "200": description: Assessment envelope (fresh, cached, or preparing). content: application/json: schema: $ref: "#/components/schemas/WeightStatusResponseEnvelope" "401": *a1 "403": *a15 "422": *a4 "429": *a5 /api/insights/bmi-status: get: tags: - Insights summary: BMI assessment description: "Data-driven plain-language assessment of the user's body-mass index. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). The note is model text: it is served, and warmed, only while the `statusText` AI capability is available. Otherwise the answer is 200 with the note null, `preparing` false, `hasProvider` as provider presence and `ai` naming the reason; nothing is read from the cache or queued. Never refused for an AI reason, and the `insights` module (the AI analysis opt-out) is folded into the capability. Auth via cookie or Bearer." parameters: - in: query name: locale schema: description: Optional UI-locale override; defaults to the session locale. type: string enum: - de - en description: Optional UI-locale override; defaults to the session locale. responses: "200": description: Assessment envelope (fresh, cached, or preparing). content: application/json: schema: $ref: "#/components/schemas/BmiStatusResponseEnvelope" "401": *a1 "403": *a15 "422": *a4 "429": *a5 /api/insights/mood-status: get: tags: - Insights summary: Mood assessment description: "Data-driven plain-language assessment of the user's recent mood entries. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). The note is model text: it is served, and warmed, only while the `statusText` AI capability is available. Otherwise the answer is 200 with the note null, `preparing` false, `hasProvider` as provider presence and `ai` naming the reason; nothing is read from the cache or queued. Never refused for an AI reason, and the `insights` module (the AI analysis opt-out) is folded into the capability. Auth via cookie or Bearer." parameters: - in: query name: locale schema: description: Optional UI-locale override; defaults to the session locale. type: string enum: - de - en description: Optional UI-locale override; defaults to the session locale. responses: "200": description: Assessment envelope (fresh, cached, or preparing). content: application/json: schema: $ref: "#/components/schemas/MoodStatusResponseEnvelope" "401": *a1 "403": *a15 "422": *a4 "429": *a5 /api/insights/medication-compliance-status: get: tags: - Insights summary: Medication-compliance assessment description: "Data-driven plain-language assessment of the user's medication compliance — an overall `summary` plus a per-medication note array. Read-only: a cache miss warms a generation out of band and serves the last-good envelope meanwhile (stale-while-revalidate). The note is model text: it is served, and warmed, only while the `statusText` AI capability is available. Otherwise the answer is 200 with the note null, `preparing` false, `hasProvider` as provider presence and `ai` naming the reason; nothing is read from the cache or queued. Never refused for an AI reason, and the `insights` module (the AI analysis opt-out) is folded into the capability. Auth via cookie or Bearer." parameters: - in: query name: locale schema: description: Optional UI-locale override; defaults to the session locale. type: string enum: - de - en description: Optional UI-locale override; defaults to the session locale. responses: "200": description: Compliance assessment envelope (fresh, cached, or preparing). content: application/json: schema: $ref: "#/components/schemas/MedicationComplianceStatusResponseEnvelope" "401": *a1 "403": *a15 "422": *a4 "429": *a5 /api/insights/metric-status: get: tags: - Insights summary: Generic per-HealthKit-metric assessment description: "v1.8.7.1 — data-driven plain-language assessment for any registered HealthKit metric (resting heart rate, sleep, glucose, body composition, gait, audio exposure, …). One generic route covering ~30 metric pages via archetype prompt templates + per-metric metadata. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). An unknown `metric` 422s against the closed registry enum. The note is model text: it is served, and warmed, only while the `statusText` AI capability is available. Otherwise the answer is 200 with the note null, `preparing` false, `hasProvider` as provider presence and `ai` naming the reason; nothing is read from the cache or queued. Never refused for an AI reason, and the `insights` module (the AI analysis opt-out) is folded into the capability. Auth via cookie or Bearer." parameters: - in: query name: metric schema: type: string enum: - RESTING_HEART_RATE - HEART_RATE_VARIABILITY - OXYGEN_SATURATION - RESPIRATORY_RATE - BODY_TEMPERATURE - SKIN_TEMPERATURE - BLOOD_GLUCOSE - WALKING_HEART_RATE_AVERAGE - PULSE_WAVE_VELOCITY - VASCULAR_AGE - STEPS - ACTIVE_ENERGY - FLIGHTS_CLIMBED - WALKING_RUNNING_DISTANCE - TIME_IN_DAYLIGHT - VO2_MAX - TOTAL_BODY_WATER - BONE_MASS - FAT_FREE_MASS - FAT_MASS - MUSCLE_MASS - LEAN_BODY_MASS - VISCERAL_FAT - WALKING_STEADINESS - WALKING_ASYMMETRY - WALKING_DOUBLE_SUPPORT - WALKING_STEP_LENGTH - WALKING_SPEED - AUDIO_EXPOSURE_ENV - AUDIO_EXPOSURE_HEADPHONE - AUDIO_EXPOSURE_EVENT - SLEEP_DURATION - CARDIO_RECOVERY - WRIST_TEMPERATURE - FALL_COUNT - SIX_MINUTE_WALK_DISTANCE - STAIR_ASCENT_SPEED - STAIR_DESCENT_SPEED - BREATHING_DISTURBANCES - SLEEP_SCORE - ANS_CHARGE - DAY_STRAIN - WORKOUT_STRAIN - CARDIO_LOAD - AVERAGE_HEART_RATE - MAX_HEART_RATE - ENERGY_EXPENDITURE_KJ - GRIP_STRENGTH - PAIN_NRS - WAIST_CIRCUMFERENCE - WAIST_TO_HEIGHT description: "HealthKit metric id to assess (e.g. RESTING_HEART_RATE, SLEEP_DURATION). Closed enum: an unknown id 422s. The seven specialised metrics are served by their own routes and are not accepted here." required: true description: "HealthKit metric id to assess (e.g. RESTING_HEART_RATE, SLEEP_DURATION). Closed enum: an unknown id 422s. The seven specialised metrics are served by their own routes and are not accepted here." - in: query name: locale schema: description: Optional UI-locale override; defaults to the session locale. type: string enum: - de - en description: Optional UI-locale override; defaults to the session locale. responses: "200": description: Assessment envelope (fresh, cached, or preparing). content: application/json: schema: $ref: "#/components/schemas/MetricStatusResponseEnvelope" "401": *a1 "403": *a15 "422": *a4 "429": *a5 /api/insights/biomarker-assessment: get: tags: - Insights summary: Per-biomarker assessment description: "Data-driven plain-language assessment for one user-scoped lab biomarker, reading its `LabResult` history. Identical envelope to the metric-status card so the `InsightStatusCard` consumes it unchanged. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate); the assessment regenerates only when a new reading lands. A marker with no numeric readings returns `insufficient` without an LLM call. The note is model text: it is served, and warmed, only while the `statusText` AI capability is available. Otherwise the answer is 200 with the note null, `preparing` false, `hasProvider` as provider presence and `ai` naming the reason; nothing is read from the cache or queued. Never refused for an AI reason, and the `insights` module (the AI analysis opt-out) is folded into the capability. Auth via cookie or Bearer." parameters: - in: query name: biomarkerId schema: type: string minLength: 1 maxLength: 64 description: User-scoped biomarker id to assess. A cross-user or unknown id returns an `insufficient` envelope, not a 404 (existence sealed). required: true description: User-scoped biomarker id to assess. A cross-user or unknown id returns an `insufficient` envelope, not a 404 (existence sealed). - in: query name: locale schema: description: Optional UI-locale override; defaults to the session locale. type: string enum: - de - en description: Optional UI-locale override; defaults to the session locale. responses: "200": description: Assessment envelope (fresh, cached, or preparing). content: application/json: schema: $ref: "#/components/schemas/BiomarkerAssessmentResponseEnvelope" "401": *a1 "403": *a15 "422": *a4 "429": *a5 /api/insights/derived: get: tags: - Insights summary: Derived wellness metric (compute-once) description: v1.10.0 — the compute-once `Derived` value for any registered derived wellness metric (personal typical-range vitals baseline, cardio-fitness band, vascular-age delta, sleep score, readiness, coincident-deviation flag). One generic route over a closed registry enum; an unknown `metric` 422s. Pure compute over the rollup tier with a per-type live fallback on a coverage miss — no LLM call, no narrative, no cache table. Returns the flat `Derived` union so the native client can decode one stable shape and combine values across metrics. `windowDays` widens or narrows the trailing window a metric summarises; `provenance.windowDays` always reports the window actually used and `coverage.historyDays` the days that actually backed it. Never refused for an AI reason and not gated on the `insights` module (the AI analysis opt-out). The one AI part is the warmer model-written `assessment` text, served (and warmed) only while the `statusText` capability is available; otherwise the deterministic assessment stands and `ai` says why. Auth via cookie or Bearer. parameters: - in: query name: metric schema: type: string enum: - VITALS_BASELINE - FITNESS_AGE - VASCULAR_AGE_DELTA - SLEEP_SCORE - READINESS - COINCIDENT_DEVIATION - HRV_BALANCE - BMI - RECOVERY_SCORE - STRESS_SCORE - STRAIN_SCORE - WRIST_TEMPERATURE_BASELINE - STAIR_ASCENT_SPEED_BASELINE - STAIR_DESCENT_SPEED_BASELINE - SIX_MINUTE_WALK_BAND - TRAJECTORY - HEALTH_SCORE - SAME_TIME_BASELINE description: "Derived-metric id to compute (e.g. VITALS_BASELINE, FITNESS_AGE, VASCULAR_AGE_DELTA, HRV_BALANCE, BMI, READINESS). Closed enum: an unknown id 422s. Metrics whose compute has not yet landed return an `insufficient` value with reason `not_implemented`." required: true description: "Derived-metric id to compute (e.g. VITALS_BASELINE, FITNESS_AGE, VASCULAR_AGE_DELTA, HRV_BALANCE, BMI, READINESS). Closed enum: an unknown id 422s. Metrics whose compute has not yet landed return an `insufficient` value with reason `not_implemented`." - in: query name: type schema: description: The single measurement type a baseline metric works over. `VITALS_BASELINE` takes one of the eleven vitals (`RESTING_HEART_RATE` through `WEIGHT`; defaults to `RESTING_HEART_RATE`). `SAME_TIME_BASELINE` takes one of the four cumulative day metrics — `ACTIVITY_STEPS`, `ACTIVE_ENERGY_BURNED`, `WALKING_RUNNING_DISTANCE`, `FLIGHTS_CLIMBED`. Ignored by the composite metrics. A type the named metric does not support yields an `insufficient` value rather than a 422, so client metric combinations stay forgiving. type: string enum: - RESTING_HEART_RATE - HEART_RATE_VARIABILITY - RESPIRATORY_RATE - OXYGEN_SATURATION - BODY_TEMPERATURE - SKIN_TEMPERATURE - BLOOD_PRESSURE_SYS - BLOOD_PRESSURE_DIA - BLOOD_GLUCOSE - PULSE - WEIGHT - ACTIVITY_STEPS - ACTIVE_ENERGY_BURNED - WALKING_RUNNING_DISTANCE - FLIGHTS_CLIMBED description: The single measurement type a baseline metric works over. `VITALS_BASELINE` takes one of the eleven vitals (`RESTING_HEART_RATE` through `WEIGHT`; defaults to `RESTING_HEART_RATE`). `SAME_TIME_BASELINE` takes one of the four cumulative day metrics — `ACTIVITY_STEPS`, `ACTIVE_ENERGY_BURNED`, `WALKING_RUNNING_DISTANCE`, `FLIGHTS_CLIMBED`. Ignored by the composite metrics. A type the named metric does not support yields an `insufficient` value rather than a 422, so client metric combinations stay forgiving. - in: query name: windowDays schema: description: "Trailing window the metric summarises, in days (1–90). Omit it and each metric keeps its own default — 14 days for the wellness-score trend, 30 for the vitals baselines, 90 for BMI, 180 for cardio fitness, 365 for the vascular-age delta. A value outside the range is a 422, never a silent clamp; the ceiling is where the derived tier stops resolving against DAY rollup buckets and would fall back to an uncapped raw read. Always read `provenance.windowDays` back: it is the window the engine ACTUALLY used, and `HEALTH_SCORE` composes fixed per-pillar windows and reports the widest of them rather than the one you asked for. The window is a request, not a promise about coverage — `coverage.historyDays` is how many days of record actually backed the answer, and it is smaller than `windowDays` for anyone whose history is shorter than the window. Not accepted on the batch route, which always runs the engine defaults." type: integer minimum: 1 maximum: 90 description: "Trailing window the metric summarises, in days (1–90). Omit it and each metric keeps its own default — 14 days for the wellness-score trend, 30 for the vitals baselines, 90 for BMI, 180 for cardio fitness, 365 for the vascular-age delta. A value outside the range is a 422, never a silent clamp; the ceiling is where the derived tier stops resolving against DAY rollup buckets and would fall back to an uncapped raw read. Always read `provenance.windowDays` back: it is the window the engine ACTUALLY used, and `HEALTH_SCORE` composes fixed per-pillar windows and reports the widest of them rather than the one you asked for. The window is a request, not a promise about coverage — `coverage.historyDays` is how many days of record actually backed the answer, and it is smaller than `windowDays` for anyone whose history is shorter than the window. Not accepted on the batch route, which always runs the engine defaults." responses: "200": description: The flat derived-metric value (ok or insufficient). content: application/json: schema: $ref: "#/components/schemas/DerivedMetricResponseEnvelope" "401": *a1 "403": *a15 "422": *a4 "429": *a5 /api/insights/derived/batch: get: tags: - Insights summary: Derived wellness metrics (batched compute-once) description: v1.10.0 — resolve several derived wellness metrics in ONE request. The `metrics` CSV names the metrics (a `metric:type` token sub-targets a VITALS_BASELINE vital); the server fans out under a bounded limiter with the profile loaded once and returns a map keyed by the per-request token. Collapses the Insights cold-mount fan-out of 14+ independent single-metric requests — the pool-starvation class that surfaces as a hang-then-recover. The single-metric route stays for the per-score detail pages. Deterministic assessments only, so no AI gate and no `insights` module gate; each metric keeps its own owning module. Auth via cookie or Bearer. parameters: - in: query name: metrics schema: type: string minLength: 1 maxLength: 1024 description: Comma-separated derived-metric tokens. Each is a `` or `:` (the colon sub-targets the single measurement type a baseline metric works over — a vital for `VITALS_BASELINE`, a cumulative day metric for `SAME_TIME_BASELINE`; see the `type` parameter on `GET /api/insights/derived`). An unknown id 422s; a `type` outside the MeasurementType enum 422s; at most 24 tokens; duplicates collapse. required: true description: Comma-separated derived-metric tokens. Each is a `` or `:` (the colon sub-targets the single measurement type a baseline metric works over — a vital for `VITALS_BASELINE`, a cumulative day metric for `SAME_TIME_BASELINE`; see the `type` parameter on `GET /api/insights/derived`). An unknown id 422s; a `type` outside the MeasurementType enum 422s; at most 24 tokens; duplicates collapse. responses: "200": description: The map of derived-metric values, keyed by token. content: application/json: schema: $ref: "#/components/schemas/DerivedBatchResponseEnvelope" "401": *a1 "403": *a15 "422": *a4 "429": *a5 /api/insights/score-history: get: tags: - Insights summary: A score's daily history description: "v1.42 — the daily course of the health score, readiness or the sleep score over a trailing window, one point per local day, for the history chart on the score's page. Each point is the value the day view shows for that day: the stored health-score day, the nightly readiness blend filed on its wake day, the sleep score of the night that ended that morning. The health score's points carry `seamBreak` where the recipe changed, and the chart draws no line across it. `band` is the usual range behind the newest point. Readiness is gated on the `recovery` module and the sleep score on `sleep` (403 `module.disabled`); the health score is core. Pure reads, no AI. Delegable at MANAGE level over the whole record. Additive. Cookie or Bearer auth." parameters: - in: query name: score schema: type: string enum: - HEALTH_SCORE - READINESS - SLEEP_SCORE description: The score to chart. `HEALTH_SCORE` reads the stored day scores, `READINESS` the nightly readiness blend, `SLEEP_SCORE` the per-night sleep score. The nightly recovery, stress and strain scores are stored measurements and chart through the measurement series instead. required: true description: The score to chart. `HEALTH_SCORE` reads the stored day scores, `READINESS` the nightly readiness blend, `SLEEP_SCORE` the per-night sleep score. The nightly recovery, stress and strain scores are stored measurements and chart through the measurement series instead. - in: query name: days schema: type: integer minimum: 1 maximum: 3650 description: Trailing window in local calendar days ending today (1–3650). The web chart sends 7, 30, 90, or 3650 for its "All" tab. Out of range is a 422, never a silent clamp. required: true description: Trailing window in local calendar days ending today (1–3650). The web chart sends 7, 30, 90, or 3650 for its "All" tab. Out of range is a 422, never a silent clamp. responses: "200": description: The score's daily points and usual range. content: application/json: schema: $ref: "#/components/schemas/ScoreHistoryResponseEnvelope" "401": *a1 "403": *a15 "422": *a4 "429": *a5 /api/insights/glp1-plateau: get: tags: - Insights summary: GLP-1 weight-plateau detection description: "Deterministic (non-LLM) weight-plateau read for users on an active GLP-1 medication: flags a stable dose held for at least the trailing window with no weight loss beyond the threshold. `plateau` is null whenever the condition does not hold, so clients hide the note cleanly. Association only — no verdict, no dose advice. Auth via cookie or Bearer." responses: "200": description: Plateau context (or null) plus the window length. content: application/json: schema: $ref: "#/components/schemas/InsightsGlp1PlateauResponseEnvelope" "401": *a1 "403": *a15 "422": *a4 "429": *a5 /api/insights/ecg: get: tags: - Insights summary: ECG recording list (metadata only) description: "v1.28.50 — the authenticated user's ECG recordings as a cheap, index-covered metadata list (recorded time, duration, sampling rate, sample count, average heart rate, lead, and the DEVICE's own rhythm classification). NEVER decrypts or returns the waveform — the per-recording strip is fetched on demand from GET /api/insights/ecg/{id}. Reflects only the recording device's certified on-device classification, verbatim; HealthLog never re-classifies an ECG or produces a diagnosis. Data-availability-gated: an empty account returns `hasRecordings: false`. Device data: no module gate, no AI gate and no LLM call. Auth via cookie or Bearer." responses: "200": description: The ECG recording list (possibly empty). content: application/json: schema: $ref: "#/components/schemas/EcgListResponseEnvelope" "401": *a1 "403": *a15 "422": *a4 "429": *a5 post: tags: - Insights summary: Ingest one ECG recording description: "The live ECG ingest: one Apple Watch recording per request, for a client draining its HealthKit ECG observer. Before this existed, a watch ECG could only reach HealthLog inside a full `export.zip`. One recording per request because a 30 s / 512 Hz strip is ~15 360 samples. Samples are INTEGER MICRO-VOLTS (convert from HealthKit's Volts), stored AES-256-GCM encrypted; `sampleCount` and `durationSeconds` are derived server-side. The `classification` is the device's own verdict stored verbatim — HealthLog never reads the waveform to produce or revise one. `source` accepts `APPLE_HEALTH` only; `userId` comes from the session and is never a body field. Unknown body keys are rejected with a 422 naming them. No `Idempotency-Key` is needed: the recording carries its own identity, so a retry resolves to the same row by construction — see `status` for what a re-post reports. Limits: 32 768 samples, 2 MB body, 60 recordings per minute per user. Device data: never refused because AI is off or the AI analysis opt-out is on; no module gate and no LLM call. Auth via cookie or Bearer." requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/EcgIngestRequest" responses: "200": description: "The recording was already stored: `updated` (same id, overwritten in place) or `duplicate` (already present under a different id, nothing written)." content: application/json: schema: $ref: "#/components/schemas/EcgIngestResponseEnvelope" "201": description: "A new recording was stored (`status: inserted`)." content: application/json: schema: $ref: "#/components/schemas/EcgIngestCreatedEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/insights/ecg/{id}: get: tags: - Insights summary: One ECG recording with waveform description: "v1.28.50 — one recording's decrypted waveform plus metadata and the DEVICE's verbatim classification. Ownership is narrowed in the query where (`{ id, userId }`) so a cross-user read is structurally impossible; a foreign or unknown id 404s (existence sealed). The waveform is AES-256-GCM at rest, decrypted through the fail-closed codec. By default the ~9000-sample strip is min/max-decimated to ~2500 display points so R-wave peaks survive; `?full=1` returns the raw array. HealthLog does not interpret the trace, measure intervals, annotate beats, or emit a verdict of its own. Device data: no module gate, no AI gate and no LLM call. `no-store`. Auth via cookie or Bearer." parameters: - in: path name: id schema: type: string description: The ECG recording id (cuid). required: true description: The ECG recording id (cuid). - in: query name: full schema: description: "`1` returns the RAW, un-decimated sample array (the true-calibration 25 mm/s · 10 mm/mV zoom view). Omitted returns the min/max-decimated ~2500-point fit-to-width overview (`decimated: true`)." type: string const: "1" description: "`1` returns the RAW, un-decimated sample array (the true-calibration 25 mm/s · 10 mm/mV zoom view). Omitted returns the min/max-decimated ~2500-point fit-to-width overview (`decimated: true`)." responses: "200": description: The recording's waveform + metadata + device classification. content: application/json: schema: $ref: "#/components/schemas/EcgDetailResponseEnvelope" "401": *a1 "403": *a15 "404": description: No ECG recording with that id for the authenticated user (existence sealed — a foreign id is indistinguishable from a missing one). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/insights/rhythm-events: get: tags: - Insights summary: Device-flagged rhythm/HR/steadiness event timeline description: "v1.10.0 (WX-B) — the authenticated user's timeline of device-flagged EVENT rows: irregular-rhythm / high-HR / low-HR / walking-steadiness / breathing-disturbance notifications the user's wearable (Apple Watch / Withings ScanWatch) already produced and synced. AWARENESS / SCREENING of the DEVICE's own decision — HealthLog stores and reflects ONLY the classification result the device's certified on-device algorithm emitted, verbatim; it never re-classifies and never produces a HealthLog diagnosis. `classification` carries the full six-value verdict set (the three ECG verdicts plus the two walking-steadiness severities plus the neutral FIRED verdict) — a distinct, wider enum than the three-value one on GET /api/insights/ecg. Data-availability-gated: an account with no event rows returns `hasEvents: false`. Device data: no module gate, no AI gate and no LLM call. Auth via cookie or Bearer." responses: "200": description: The device-flagged event timeline (possibly empty). content: application/json: schema: $ref: "#/components/schemas/RhythmEventsResponseEnvelope" "401": *a1 "403": *a15 "422": *a4 "429": *a5 /api/insights/correlations: get: tags: - Insights summary: Correlation discovery (FDR-controlled) description: "v1.10.0 — scans a curated behaviour × outcome matrix (daylight / mood / glucose / BP / steps × sleep / HRV / resting HR / weight), lag-joins each behaviour day to the next day's outcome, runs Pearson with the exact Student-t p-value, and applies Benjamini-Hochberg FDR control across every tested pair. Only statistically-defensible pairs surface, each carrying n, r, p, and the BH-adjusted q. Descriptive, never causal. Statistics, not model output: no AI switch, opt-out or `insights` module gate refuses them. Auth via cookie or Bearer." responses: "200": description: The discovered correlations + the tested-pair count. content: application/json: schema: $ref: "#/components/schemas/CorrelationDiscoveryResponseEnvelope" "401": *a1 "403": *a15 "422": *a4 "429": *a5 /api/insights/patterns: get: tags: - Insights summary: List current correlation patterns description: Returns the authenticated account's currently accepted persisted correlation identities, evidence, and dismissal timestamps. Statistics, not model output, so no AI or `insights` module gate applies. responses: "200": description: Current persisted correlation patterns. content: application/json: schema: $ref: "#/components/schemas/CorrelationPatternListResponseEnvelope" "401": *a1 "403": *a15 "422": *a4 "429": *a5 /api/insights/patterns/{id}: patch: tags: - Insights summary: Update correlation pattern dismissal description: Dismisses or restores one current account-owned pattern. A dismissal remains effective until the accepted evidence changes materially. No AI or `insights` module gate applies. parameters: - in: path name: id schema: type: string required: true requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpdateCorrelationPatternRequest" responses: "200": description: Updated dismissal decision. content: application/json: schema: $ref: "#/components/schemas/UpdateCorrelationPatternResponseEnvelope" "401": *a1 "403": *a15 "404": description: No current pattern with that id for this account. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/insights/health-status: get: tags: - Insights summary: Baseline-drift health status description: Surfaces vitals drifting from the user's personal normal — out-of-band deviations from the personal-baseline engine plus dated, sustained level shifts from the changepoint detector. Pure compute over the rollup tier; awareness only, never a diagnosis. Requires the insights module. Cookie or Bearer auth. responses: "200": description: The drift summary. content: application/json: schema: $ref: "#/components/schemas/InsightsHealthStatusEnvelope" "401": *a1 "403": *a15 "422": *a4 "429": *a5 /api/insights/breathing-screening: get: tags: - Insights summary: Sleep-breathing screening signal description: Summarises the last ~30 nights of the per-night sleep-breathing-disturbance index plus device-flagged events into a calm awareness read. A screening signal only — never a HealthLog diagnosis. Requires the insights module. Cookie or Bearer auth. responses: "200": description: The breathing-screening summary. content: application/json: schema: $ref: "#/components/schemas/InsightsBreathingScreeningEnvelope" "401": *a1 "403": *a15 "422": *a4 "429": *a5 /api/insights/labs-changes: get: tags: - Insights summary: What changed since your last lab panel description: Pairs the two most-recent numeric lab panels and reports each shared analyte's signed delta plus its reference-band standing. Absent when there are fewer than two panels or no analyte is shared. Neutral framing, never a diagnosis. Cookie or Bearer auth. responses: "200": description: The lab-change summary. content: application/json: schema: $ref: "#/components/schemas/InsightsLabsChangesEnvelope" "401": *a1 "403": *a15 "422": *a4 "429": *a5 /api/mood/analytics: get: tags: - Mood summary: The canonical daily mood series description: One series, read by the mood page sparkline, the dashboard tile and the native client alike, so all three render the same number — the dashboard snapshot resolves it through the same builder rather than aggregating again. Answered from the mood rollup tier with a live fallback on a coverage miss; which tier answered is annotated for operators and is not on the wire. Plain read-through cache (NOT stale-while-revalidate, unlike the insights sibling). Module-gated on `mood`; a disabled module answers 403 `module.disabled` even for a valid Bearer token. Cookie or Bearer auth; the caller is always resolved as themselves, so this read cannot be delegated to a shared record. responses: "200": description: The daily series and its summary. content: application/json: schema: $ref: "#/components/schemas/MoodDailySeriesEnvelope" "401": *a1 "403": *a11 "422": *a4 "429": *a5 /api/mood/insights: get: tags: - Mood summary: Pre-computed mood aggregates (v1.8.5) description: "The whole Mood Insights page in one read. The compute runs server-side and only pre-shaped blocks leave — no raw mood row is on this wire, and no LLM is reachable from this path. Stale-while-revalidate: past the fresh window the prior aggregate is served immediately while one background recompute warms a new one, so an active logger never re-pays the cold compute. Module-gated on `mood`; a disabled module answers 403 `module.disabled` even for a valid Bearer token. Cookie or Bearer auth; the caller is always resolved as themselves, so this read cannot be delegated to a shared record — which makes it the one mood surface a delegate cannot reach. v1.42.0 — `days` cuts `stability` to the period a client shows; every other block is unchanged by it." parameters: - in: query name: days schema: description: "Trailing window for `stability`, in days. Omitted: 365. Any other value is a 422." type: string enum: - "30" - "90" - "180" - "365" description: "Trailing window for `stability`, in days. Omitted: 365. Any other value is a 422." responses: "200": description: The aggregate bundle. content: application/json: schema: $ref: "#/components/schemas/MoodAggregatesEnvelope" "401": *a1 "403": *a11 "422": *a4 "429": *a5 /api/mood/prognosis: get: tags: - Mood summary: The day's own rating and what the account's days imply (v1.38) description: "The newest stored comparison for the account, with the person's own rating for that day beside it. Written nightly by a job and never by a request: the value cannot be overwritten, which is what keeps the two readings separate. Absence is explicit (`present: false` with a reason and a count) and is a normal answer, not an error — the comparison refuses when the history does not support one. Whole-record read: the fit takes the day's context AND the sleep, activity and recovery figures the owning modules hold, and it names them, so a section-scoped grant is refused rather than served a filtered list. Gated: `module.disabled` 403 when the mood module is off." responses: "200": description: The day's two readings, or an explained absence. content: application/json: schema: $ref: "#/components/schemas/MoodPrognosisEnvelope" "401": *a1 "403": *a17 "422": *a4 "429": *a5 /api/mood-entries/bulk: post: tags: - Mood summary: Bulk mood backfill (iOS SyncMode) description: "Drains the iOS local mood log in one shot: up to 500 entries per call with per-entry UPSERT semantics keyed by `externalId`, so an adopt-on-pair backfill or a retried batch is idempotent. Idempotent via the `Idempotency-Key` header too. Per-entry status (`inserted` / `duplicate` / `skipped`) advances the client cursor. Rate-limited 60/min/user. An over-size batch 422s (`meta.errorCode = mood.bulk.too_large`); a malformed body 422s (`mood.bulk.invalid`)." parameters: - *a6 requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/BulkMoodRequest" responses: "200": description: Batch processed (always 200 on a well-formed body). content: application/json: schema: $ref: "#/components/schemas/BulkMoodResponseEnvelope" "401": *a1 "403": *a9 "409": *a7 "422": *a4 "429": *a5 /api/mood/tags: get: tags: - Mood summary: Effective per-user mood-tag tree (v1.8.5 / v1.13.0 / v1.17.0) description: "The fully-resolved, ordered Category → Tag tree: seeded catalogue + the caller's custom tags and groups, per-user layout (group order + placements) applied server-side, hidden catalogue tags omitted. `include` is a comma list: `hidden` (hidden catalogue tags, `hidden:true`), `archived` (own inactive custom tags, `archived:true`), `usage` (per-tag `usageCount`). With any flag set, the caller's own empty groups are kept; the plain read drops empty categories." parameters: - in: query name: include schema: description: Comma list of `hidden`, `archived`, `usage`. type: string description: Comma list of `hidden`, `archived`, `usage`. responses: "200": description: The effective tag tree. content: application/json: schema: $ref: "#/components/schemas/MoodTagTreeEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/mood/tags/custom: post: tags: - Mood summary: Create a custom mood tag (v1.13.0 / v1.17.0) description: "Mints a `custom:` key, encrypts the label at rest, stores a BINARY tag owned by the caller under the chosen group (default: the seeded `custom` category). 422 over the 50-tag cap or on an unknown / foreign `categoryKey`." requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CreateMoodTagRequest" responses: "201": description: Custom tag created. content: application/json: schema: $ref: "#/components/schemas/MoodCustomTagCreatedEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/mood/tags/custom/{key}: patch: tags: - Mood summary: Update a custom mood tag (v1.13.0 / v1.17.0) description: "Owner-scoped: another user's key or a catalogue key 404s. Rename (re-encrypts), re-icon, archive/restore via `isActive`, or move groups via `categoryKey`." parameters: - in: path name: key schema: type: string required: true requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpdateMoodTagRequest" responses: "200": description: Custom tag updated. content: application/json: schema: $ref: "#/components/schemas/MoodCustomTagPatchEnvelope" "401": *a1 "403": *a9 "404": &a27 description: Not found / not owned by the caller. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 delete: tags: - Mood summary: Archive or purge a custom mood tag (v1.13.0) description: "Default: soft-deactivates (`isActive:false`) — history intact, restorable via PATCH. `?purge=true`: hard-deletes the row; the FK cascade removes its entry links. Owner-scoped 404." parameters: - in: path name: key schema: type: string required: true - in: query name: purge schema: type: string enum: - "true" - "false" responses: "200": description: Archived (or purged). content: application/json: schema: $ref: "#/components/schemas/MoodCustomTagDeleteEnvelope" "401": *a1 "403": *a9 "404": *a27 "422": *a4 "429": *a5 /api/mood/tags/{key}/hidden: put: tags: - Mood summary: Hide / show a catalogue mood tag (v1.13.0) description: Per-user hide of a CATALOGUE tag (upserts / removes a `mood_tag_hidden` row). 400 on a `custom:` key — custom tags archive via their own `isActive`. parameters: - in: path name: key schema: type: string required: true requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/HideMoodTagRequest" responses: "200": description: Visibility updated. content: application/json: schema: $ref: "#/components/schemas/MoodTagHiddenEnvelope" "400": description: Custom-tag key — use the custom PATCH instead. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 "403": *a9 "404": *a27 "422": *a4 "429": *a5 /api/mood/tags/groups: post: tags: - Mood summary: Create a custom mood-tag group (v1.17.0) description: Mints a `customcat:` key, encrypts the label at rest, stores a group owned by the caller. 422 over the 12-group cap. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CreateMoodTagGroupRequest" responses: "201": description: Custom group created. content: application/json: schema: $ref: "#/components/schemas/MoodTagGroupCreatedEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/mood/tags/groups/{key}: patch: tags: - Mood summary: Update a custom mood-tag group (v1.17.0) description: "Owner-scoped: another user's key or a seeded category key 404s. Rename (re-encrypts), re-icon, retire/restore via `isActive`." parameters: - in: path name: key schema: type: string required: true requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpdateMoodTagGroupRequest" responses: "200": description: Custom group updated. content: application/json: schema: $ref: "#/components/schemas/MoodTagGroupPatchEnvelope" "401": *a1 "403": *a9 "404": *a27 "422": *a4 "429": *a5 delete: tags: - Mood summary: Delete a custom mood-tag group (v1.17.0) description: "Non-destructive: the group's custom tags re-home to the seeded `custom` category, catalogue-tag placements evaporate back to their seeded category, the layout drops the group, then the row soft-deactivates (default) or hard-deletes (`?purge=true`). No tag and no entry link is deleted." parameters: - in: path name: key schema: type: string required: true - in: query name: purge schema: type: string enum: - "true" - "false" responses: "200": description: Group deleted; `rehomedCount` custom tags moved. content: application/json: schema: $ref: "#/components/schemas/MoodTagGroupDeleteEnvelope" "401": *a1 "403": *a9 "404": *a27 "422": *a4 "429": *a5 /api/mood/tags/layout: get: tags: - Mood summary: Read the per-user mood-tag layout (v1.17.0) description: "Returns the stored blob merged over defaults: `groupOrder` resolved against the caller's effective category set (seeded + own groups), `placements` as stored." responses: "200": description: Resolved layout. content: application/json: schema: $ref: "#/components/schemas/MoodTagLayoutGetEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 put: tags: - Mood summary: Update the per-user mood-tag layout (v1.17.0) description: "Preserve-when-absent merge: a `groupOrder`-only PUT keeps the stored `placements` and vice versa. Bounded: ≤ 50 groups, ≤ 400 placement entries, keys ≤ 80 chars. Returns the resolved layout." requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/MoodTagLayoutPutRequest" responses: "200": description: Merged + resolved layout with the fresh token. content: application/json: 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. `meta.errorCode` = `mood_tag_layout_conflict`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a19 "429": *a5 /api/mood-entries: get: tags: - Mood summary: List mood entries description: The record's mood entries, filtered and paged. Soft-deleted (tombstoned) rows are hidden; the `/api/sync/changes` feed is where tombstones surface. Every row carries the same `MoodEntryDTO` shape the write paths answer, structured-tag keys and rated factors included, so the edit form pre-populates without per-row requests. A malformed query 422s with every issue listed (`meta.errorCode` = `mood.list.invalid`). parameters: - in: query name: mood schema: type: string enum: - SUPER_GUT - GUT - OKAY - SCHLECHT - LAUSIG - in: query name: source schema: type: string enum: - MANUAL - MOODLOG - WEB - TELEGRAM - DAYLIO - NOTIFICATION - in: query name: from schema: type: string pattern: ^\d{4}-\d{2}-\d{2}$ - in: query name: to schema: type: string pattern: ^\d{4}-\d{2}-\d{2}$ - in: query name: limit schema: default: 100 type: integer minimum: 1 maximum: 500 - in: query name: offset schema: default: 0 type: integer minimum: 0 maximum: 9007199254740991 - in: query name: sortBy schema: default: moodLoggedAt type: string enum: - date - mood - score - moodLoggedAt - source - in: query name: sortDir schema: default: desc type: string enum: - asc - desc responses: "200": description: One page of entries plus paging meta. content: application/json: schema: $ref: "#/components/schemas/MoodEntryListEnvelope" "401": *a1 "403": *a15 "422": *a4 "429": *a5 post: tags: - Mood summary: Create a mood entry description: Writes the entry, its structured-tag links and its day context in one transaction — a link or context failure rolls the entry back rather than letting a retry mint a duplicate. Idempotent via the `Idempotency-Key` header. With `externalId` the write UPSERTS on `(userId, source, externalId)` (a re-post updates the row in place and restates pleasantness from the label, while A2-A5 and a stored context are only touched when the request carries them); without it, a same-`(date, moodLoggedAt)` re-post 409s. A delegated manager cannot supply `externalId` (422, `meta.errorCode` = `mood.create.external_id_not_delegable`) — it is a device's contract with its own rows. A rated factor outside its catalog scale 422s (`mood.ratedFactor.out_of_range`); a malformed body 422s (`mood.create.invalid`). An unstable `externalId` (an object description or memory address that rotates per launch) is refused at validation. parameters: - *a6 requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CreateMoodEntryRequest" responses: "201": description: Entry created (or upserted in place via `externalId`), with its persisted tag keys, rated factors and context. content: application/json: schema: $ref: "#/components/schemas/MoodEntryEnvelope" "401": *a1 "403": *a15 "409": &a28 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. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a16 /api/mood-entries/{id}: get: tags: - Mood summary: Read one mood entry description: The full entry with its day context, so the edit surface loads both in one request. A soft-deleted row 404s, matching the list read's invariant. parameters: - in: path name: id schema: type: string required: true responses: "200": description: The entry. content: application/json: schema: $ref: "#/components/schemas/MoodEntryEnvelope" "401": *a1 "403": *a15 "404": *a27 "422": *a4 "429": *a5 put: tags: - Mood summary: Edit a mood entry description: "Per-field edit: omitted fields keep their stored value; `tagKeys`, `ratedFactors` and `context` replace their whole set when present (`null` clears). Editing `mood` re-derives `score` and — when the label actually moved — pleasantness `a1`; editing `moodLoggedAt` re-anchors the row's `date` and `tz` to the account's display timezone. Bumps `syncVersion` so paired clients reconcile last-writer-wins. A soft-deleted row 404s (`meta.errorCode` = `mood.not_found`); an edit landing on another row's `(date, moodLoggedAt)` 409s (`mood.duplicate_timestamp`); a rated factor outside its catalog scale 422s (`mood.ratedFactor.out_of_range`); a malformed body 422s (`mood.update.invalid`)." parameters: - in: path name: id schema: type: string required: true requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpdateMoodEntryRequest" responses: "200": description: The entry after the edit, with its persisted tag keys, rated factors and context. content: application/json: schema: $ref: "#/components/schemas/MoodEntryEnvelope" "401": *a1 "403": *a15 "404": *a27 "409": *a28 "422": *a4 "429": *a5 delete: tags: - Mood summary: Delete a mood entry (soft) description: Tombstones the row (`deletedAt` set, `syncVersion` bumped) rather than hard-deleting, so the `/api/sync/changes` feed carries the tombstone to paired clients that were offline. Invisible to every normal read from here on; `POST /api/mood-entries/restore` puts it back. Re-deleting an already-tombstoned row is a harmless idempotent no-op. parameters: - in: path name: id schema: type: string required: true responses: "200": description: Entry tombstoned. content: application/json: schema: $ref: "#/components/schemas/MoodEntryDeleteEnvelope" "401": *a1 "403": *a15 "404": *a27 "422": *a4 "429": *a5 /api/mood-entries/restore: post: tags: - Mood summary: Restore soft-deleted mood entries description: Un-tombstones every owned, currently-deleted id in one statement — the delete-Undo affordance. Clears `deletedAt` and bumps `syncVersion` so the row re-surfaces in normal reads and rides the `/api/sync/changes` feed as an upsert. A forged / foreign / not-deleted id is a silent no-op, never a 404 existence leak. Idempotent via the `Idempotency-Key` header. Rate-limited 60/min (keyed on the ACTING user, so a manager burns their own allowance). A malformed body 422s (`meta.errorCode` = `mood.restore.invalid`). parameters: - *a6 requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/MoodEntryIdsRequest" responses: "200": description: "`restored` counts the rows actually un-tombstoned." content: application/json: schema: $ref: "#/components/schemas/MoodEntriesRestoreEnvelope" "401": *a1 "403": *a15 "409": *a7 "422": *a4 "429": *a5 /api/mood-entries/bulk-delete: post: tags: - Mood summary: Soft-delete mood entries in bulk description: Tombstones every owned, not-already-deleted id in one statement — the management list's multi-select delete, matching the single-DELETE tombstone contract. A forged / foreign id is a silent no-op, never a 404 existence leak. Idempotent via the `Idempotency-Key` header. Rate-limited 60/min (keyed on the ACTING user). A malformed body 422s (`meta.errorCode` = `mood.bulk-delete.invalid`). parameters: - *a6 requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/MoodEntryIdsRequest" responses: "200": description: "`deleted` counts the rows actually tombstoned." content: application/json: schema: $ref: "#/components/schemas/MoodEntriesBulkDeleteEnvelope" "401": *a1 "403": *a15 "409": *a7 "422": *a4 "429": *a5 /api/mood/linked-context: get: tags: - Mood summary: The linked figures of one local day description: "What the modules that own sleep, activity, vitals and body symptoms already know about one day, resolved at read time and never stored on the mood row — a corrected sleep session shows up here with nothing to re-sync. Keyed by local day rather than entry id, deliberately: the capture sheet needs the figures BEFORE an entry exists. `tz` anchors the day's boundaries — the capture sheet sends the browser's zone, the edit dialog the entry's stored `tz`; absent falls back to the account's display timezone. Whole-record read: the payload crosses into the measurements and illness sections from a mood surface, so a section-scoped grant is refused outright rather than served a filtered day that would read as empty. Gated: 403 `module.disabled` when the mood module is off. A malformed query 422s (`meta.errorCode` = `mood.linkedContext.invalid`)." parameters: - in: query name: date schema: type: string pattern: ^\d{4}-\d{2}-\d{2}$ description: The local day to resolve, YYYY-MM-DD. required: true description: The local day to resolve, YYYY-MM-DD. - in: query name: tz schema: description: IANA zone the day is anchored to. Send the entry's stored `tz` when editing; omit to use the account's display timezone. type: string minLength: 1 maxLength: 64 description: IANA zone the day is anchored to. Send the entry's stored `tz` when editing; omit to use the account's display timezone. responses: "200": description: The day's linked blocks; absence is explicit per figure and per module. content: application/json: schema: $ref: "#/components/schemas/MoodLinkedContextEnvelope" "401": *a1 "403": *a17 "422": *a4 "429": *a5 /api/settings/ntfy: get: tags: - Notifications summary: Read the caller's ntfy channel config description: 'Returns the stored server URL, topic and enabled flag. The auth token is reported as `hasAuthToken` only. When the user has no ntfy channel row the defaults `{ enabled: false, serverUrl: "https://ntfy.sh", topic: "", hasAuthToken: false }` are returned rather than a 404. Auth via cookie or Bearer.' responses: "200": description: Stored (or default) ntfy config. content: application/json: schema: $ref: "#/components/schemas/GetNtfySettings" "401": *a1 "403": *a9 "422": *a4 "429": *a5 put: tags: - Notifications summary: Update the caller's ntfy channel config description: >- Upserts the channel. Accepts either the toggle-only body or the full config. The response is `{ saved: true }` and carries no config echo, so re-read the GET if the client needs the resolved state. Auth via cookie or Bearer. An operator can switch ntfy off for the whole instance. While that switch is off, a body that sets `enabled` to true is refused with 403 and nothing is stored — the channel would not deliver, so the toggle does not pretend otherwise. Disabling the channel keeps working either way. Read the switch from `GET /api/settings/global-services`. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/NtfySettingsPutRequest" responses: "200": description: Config stored. content: application/json: schema: $ref: "#/components/schemas/PutNtfySettingsResponse" "400": &a29 description: Body is not parseable JSON. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 "403": 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": &a30 description: Body exceeds 64 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "415": &a31 description: Content-Type is not `application/json`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": description: Body matched neither accepted shape, or `enabled` is true while the stored config has no server URL and topic; or the server URL is on a private network the operator has not listed in `NOTIFICATION_PRIVATE_ORIGINS` (`meta.errorCode` = `private_origin_not_approved`), or it is a link-local, metadata or unspecified address that no grant can open (`private_origin_not_grantable`). Nothing was stored. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 /api/settings/telegram: get: tags: - Notifications summary: Read the caller's Telegram channel config description: Returns the enabled flag, the chat id and `hasBotToken`. The bot token is never returned. Auth via cookie or Bearer. responses: "200": description: Stored Telegram config. content: application/json: schema: $ref: "#/components/schemas/GetTelegramSettings" "401": *a1 "403": *a9 "422": *a4 "429": *a5 put: tags: - Notifications summary: Update the caller's Telegram channel config description: >- Accepts either the toggle-only body or the full config, then registers (on enable) or deletes (on disable) the Telegram webhook for the bot before persisting. Webhook deletion is best-effort and its failure does not fail the write; webhook REGISTRATION is not — a rejected registration aborts the whole call and nothing is stored. The response is `{ updated: true }` with no config echo. Auth via cookie or Bearer. An operator can switch Telegram off for the whole instance. While that switch is off, a body that sets `enabled` to true is refused with 403 before any webhook call and nothing is stored. Disabling the channel keeps working either way. Read the switch from `GET /api/settings/global-services`. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/TelegramSettingsPutRequest" responses: "200": description: Config stored. content: application/json: schema: $ref: "#/components/schemas/PutTelegramSettingsResponse" "400": *a29 "401": *a1 "403": 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: $ref: "#/components/schemas/ErrorEnvelope" "404": description: The session's user row no longer exists. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "413": *a30 "415": *a31 "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 registration. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 "500": description: The deployment cannot register a Telegram webhook because `APP_URL` (or `NEXT_PUBLIC_APP_URL`) is missing or unparseable. An operator configuration problem, not a client one — retrying will not help. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/settings/webhook: get: tags: - Notifications summary: Read the caller's generic-webhook channel config description: 'Returns the stored URL, header name and enabled flag. The secret header value is reported as `hasHeaderValue` only. When the user has no webhook channel row the defaults `{ enabled: false, url: "", headerName: "", hasHeaderValue: false }` are returned rather than a 404. Auth via cookie or Bearer.' responses: "200": description: Stored (or default) webhook config. content: application/json: schema: $ref: "#/components/schemas/GetWebhookSettings" "401": *a1 "403": *a9 "422": *a4 "429": *a5 put: tags: - Notifications summary: Update the caller's generic-webhook channel config description: "Upserts the channel. Accepts either the toggle-only body or the full config. The response is `{ saved: true }` and carries no config echo. Auth via cookie or Bearer." requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/WebhookSettingsPutRequest" responses: "200": description: Config stored. content: application/json: schema: $ref: "#/components/schemas/PutWebhookSettingsResponse" "400": *a29 "401": *a1 "403": *a9 "413": *a30 "415": *a31 "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` = `private_origin_not_approved`), or it is a link-local, metadata or unspecified address that no grant can open (`private_origin_not_grantable`). Nothing was stored. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 /api/settings/reminder-thresholds: get: tags: - Notifications summary: Read the medication reminder thresholds description: Returns the operator-level late/missed minute thresholds that tier an open dose's status, plus the calling user's low-stock runway threshold (days; null = alert off). One endpoint so every threshold consumer reads one shape. Auth via cookie or Bearer. responses: "200": description: Resolved thresholds. content: application/json: schema: $ref: "#/components/schemas/GetReminderThresholdsResponse" "401": *a1 "403": *a15 "422": *a4 "429": *a5 /api/settings/global-services: get: tags: - Notifications summary: Read the operator's instance-wide service switches description: >- Which optional services this instance offers at all, so a client can hide a channel the operator has switched off rather than let a user configure something that will never deliver. Auth via cookie or a wildcard Bearer token; the answer is about the DEPLOYMENT and is identical for every account on it. Reads fail soft: when the settings row cannot be loaded the handler returns all-true rather than an error, so a true flag means “not switched off”, never “configured and working”. responses: "200": description: The instance's service switches. content: application/json: schema: $ref: "#/components/schemas/GlobalServiceAvailabilityEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/settings/account: delete: tags: - Auth summary: Delete the account and everything in it description: >- Erasure, after a grace window. With `ACCOUNT_DELETION_GRACE_DAYS` above 0 (14 by default) the account is locked at once — every session, refresh token, API token, connector and share link is revoked, sign-in is refused with 403 `auth.account.deletion_pending` and the erasure date in `meta.purgeAt`, and nothing acts for the account any more — and the purge job erases it when the window ends. Until then an admin can undo the deletion, and the record is exactly as it was. `scheduledPurgeAt` in the answer is that date. With the window set to 0 the account is erased in this request, as before, and `scheduledPurgeAt` is null. The erasure itself: the user row goes, every related row cascades with it, the off-host copies are purged, and the account's own audit history — IP addresses, login geo — is purged inside the same transaction rather than being left orphaned. Two gates, and they compose. The body must carry the typed confirmation. And for an account with a second factor enrolled — a confirmed TOTP secret OR a registered security key — the caller must additionally present a fresh factor proof. An account with NO second factor keeps the typed-confirmation-only contract on either transport, deliberately, because a user who never enrolled has no ceremony to complete. How the fresh proof is presented depends on the transport. A cookie session carries it on the session row: complete the second factor within the last five minutes and retry. A Bearer caller presents a single-use elevation from `POST /api/auth/step-up` in the `X-Step-Up` header, minted against a `totp`, `webauthn`, or `passkey` proof — a `password`-proved elevation is refused here exactly as a password login never satisfies step-up on the web. The elevation is bound to the token that minted it, valid five minutes, and spent only when the deletion is about to run, so a rejected confirmation does not burn it. A recovery code is not accepted on the Bearer path: the mint does not take one, so an account that has lost its authenticator deletes itself from the web. Two refusals are not about the caller's credentials at all. The last remaining admin cannot delete themselves, and the last Guardian of a managed profile cannot leave that profile unattended. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/DeleteAccountRequest" responses: "200": description: Account locked for deletion (erased at `scheduledPurgeAt`), or erased now when there is no grace window (`scheduledPurgeAt` null). content: application/json: schema: $ref: "#/components/schemas/DeleteAccountEnvelope" "400": description: This is the last admin account on the instance and cannot be deleted. Promote another admin first. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": description: Not authenticated — or the account has a second factor and the caller carries no proof of it fresh enough for a destructive action (`meta.errorCode` = `auth.stepup.required`, or `auth.stepup.mfa_not_enrolled`). On a cookie session, re-verify the factor and retry; on a Bearer token, mint an elevation at `POST /api/auth/step-up` with a `totp`, `webauthn`, or `passkey` proof and resend with `X-Step-Up`. The same code covers an elevation that is unknown, expired, already spent, minted for a different token, or minted from too weak a factor — the refusals are deliberately indistinguishable on the wire, and the real reason is audited. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "403": 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 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` = `sharing.not_permitted`). This operation resolves the CALLER and never a named record, so under a switch it refuses instead of quietly answering with the caller's own — leave the shared record first. No grant at any level opens it. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "409": description: The caller is the last Guardian of a managed profile (`meta.errorCode` = `managed_profile.guardian.required`). Add another Guardian, or delete the managed profile, before deleting this account. Nothing was deleted. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "413": description: Request body exceeds 65536 bytes. Nothing was deleted. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/settings/email: get: tags: - Notifications summary: Read the email channel description: >- The caller's recipient address and opt-in, plus whether the operator has configured SMTP at all. An account that has never saved a channel gets `{ enabled: false, recipient: "" }` rather than a 404, so a client can render the empty form without branching. Read `smtpConfigured` before offering the control: it is an instance fact, and when it is false nothing this endpoint saves can ever be delivered. responses: "200": description: The channel, and whether the instance can send at all. content: application/json: schema: $ref: "#/components/schemas/GetEmailChannelEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 put: tags: - Notifications summary: Save the email channel description: >- Accepts either shape: `{ enabled }` to toggle an existing channel, or `{ recipient, enabled }` to upsert both. The recipient is stored encrypted; SMTP credentials are the operator's and never enter it. Enabling without a saved recipient is refused — both when the toggle-only body finds no stored address and when the full body carries `enabled: true` with an empty one. Body capped at 16 KB. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/EmailChannelPutRequest" responses: "200": description: Channel saved. content: application/json: schema: $ref: "#/components/schemas/PutEmailChannelEnvelope" "400": description: The operator has configured no SMTP transport, so there is nothing to save a channel against. An instance-level refusal, not a validation error — which is why it is 400 rather than 422. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/settings/email/test: post: tags: - Notifications summary: Send a test email to the saved recipient description: >- Fires one message through the operator's SMTP transport to the address saved on this account. Takes no body. Rate-limited 5 per 5 minutes per user. Refuses with 400 when the instance has no SMTP transport, when the account has no email channel, or when the channel carries no recipient — three distinguishable prose messages on one status. A failure at the mail server or on the way to it is a 502 that names its cause; only a failure the server cannot name is a 500. responses: "200": description: The provider accepted the message. Not proof it was delivered. content: application/json: schema: $ref: "#/components/schemas/EmailTestEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 "500": description: The send failed in a way the server could not name. An internal fault, not a statement about the mail server. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "502": description: "The mail server, or the way to it, failed. `meta.errorCode` names the cause: `credentials_rejected` (the server refused the SMTP login), `upstream_rejected` (a permanent 5xx rejection, e.g. an unknown mailbox or relay denied, or an envelope or message refused without a reply code), `upstream_error` (a temporary 4xx rejection), `timeout`, or `connection_failed` (including a TLS handshake or protocol failure). `meta.smtpCode` carries the SMTP reply code when the mail server sent one." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/settings/ntfy/test: post: tags: - Notifications summary: Send a test message to the saved ntfy topic description: Publishes one message to the account's configured ntfy server and topic. Takes no body. Rate-limited 5 per 5 minutes per user. Refuses with 400 when no ntfy channel is saved or when the saved config is missing a server URL or topic. A failure at the ntfy server or on the way to it is a 502 that carries the upstream status and, when safe, what the server answered; only a failure the server cannot name is a 500. A server on a private network is refused with 422 and `meta.errorCode` = `private_origin_not_approved` unless the operator listed its exact origin in `NOTIFICATION_PRIVATE_ORIGINS`; the test and the scheduled delivery take the same decision. responses: "200": description: The provider accepted the message. Not proof it was delivered. content: application/json: 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 (`private_origin_not_grantable`). The resolved address is never echoed. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 "500": description: The send failed in a way the server could not name. An internal fault, not a statement about the ntfy server. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "502": description: "The ntfy server, or the way to it, failed. `meta.errorCode` names the cause: `upstream_rejected` (the other end refused the payload, e.g. 400/409/415/422), `credentials_rejected` (401/403), `endpoint_not_found` (404/410), `rate_limited` (429), `upstream_error` (5xx), `redirected` (3xx, never followed), `timeout`, or `connection_failed`. `meta.upstreamStatus` carries the HTTP status whenever one arrived, and `meta.upstreamBody` up to 200 characters of what the other end answered, with control characters removed; it is omitted when the body was empty or could carry a credential: a token-shaped string, or the saved auth token, the topic, or a credential in the server URL (userinfo, a query value, a path segment of 16 or more characters), literal, JSON-escaped or percent-encoded." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/settings/telegram/test: post: tags: - Notifications summary: Send a test message to the saved Telegram chat description: >- Sends one message through the account's saved bot token and chat id, in the account's own interface language. Takes no body. Rate-limited 5 per 5 minutes per user. This one differs from its siblings: both the not-configured case and the send failure answer 422 rather than 400 and 500. The failure is genuinely ambiguous — a wrong token and a wrong chat id look the same from here — so one message covers both. responses: "200": description: The provider accepted the message. Not proof it was delivered. content: application/json: schema: $ref: "#/components/schemas/TelegramTestEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/settings/webhook/test: post: tags: - Notifications summary: Send a test payload to the saved webhook description: Posts one payload to the account's configured webhook URL. Takes no body. Rate-limited 5 per 5 minutes per user. Refuses with 400 when no webhook channel is saved or the saved config has no URL. A failure at the relay or on the way to it is a 502 that carries the upstream status and, when safe, what the relay answered; only a failure the server cannot name is a 500. The URL was checked against the SSRF floor when it was saved, so a test cannot be used to reach an internal host. A URL on a private network is refused with 422 and `meta.errorCode` = `private_origin_not_approved` unless the operator listed its exact origin in `NOTIFICATION_PRIVATE_ORIGINS`; the test and the scheduled delivery take the same decision. responses: "200": description: The provider accepted the message. Not proof it was delivered. content: application/json: 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 (`private_origin_not_grantable`). The resolved address is never echoed. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 "500": description: The send failed in a way the server could not name. An internal fault, not a statement about the relay. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "502": description: "The relay, or the way to it, failed. `meta.errorCode` names the cause: `upstream_rejected` (the other end refused the payload, e.g. 400/409/415/422), `credentials_rejected` (401/403), `endpoint_not_found` (404/410), `rate_limited` (429), `upstream_error` (5xx), `redirected` (3xx, never followed), `timeout`, or `connection_failed`. `meta.upstreamStatus` carries the HTTP status whenever one arrived, and `meta.upstreamBody` up to 200 characters of what the other end answered, with control characters removed; it is omitted when the body was empty or could carry a credential: a token-shaped string, or the saved header value (and its token part), the URL's userinfo, a query value, or a path segment of 16 or more characters (the token in a Discord or Slack webhook URL), literal, JSON-escaped or percent-encoded." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/settings/data: delete: tags: - Auth summary: Erase the record, keeping the account description: >- The other half of account deletion: this removes what the account HAS and keeps what signs into it. Ninety-nine models are hard-deleted for this user — measurements and every derived rollup, medications and their intake history, mood, labs, documents, cycle, workouts, nutrients, notification channels, Coach conversations, consent receipts, audit history — with no `deletedAt` filter, so soft-deleted tombstones go too. The account row survives with its personal columns reset: body and identity fields, home location, cached AI output. What is deliberately kept is the credential set (password hash, SSO binding, second-factor secret and enrolment, trusted-device state) and the interface preferences (timezone, locale, units, time and date format, glucose unit), because those are about the account rather than the record. The model list is not written at the route. It is declared once in the wipe plan and held against the Prisma schema by a completeness test, which exists because this endpoint once deleted thirteen tables out of eighty-odd while telling people it had deleted everything. One transaction, with a two-minute budget: a half-applied wipe is worse than none, because the person is told it succeeded either way. Every cached payload for the account is evicted afterwards so no surface serves pre-wipe data. With a deletion grace window (`ACCOUNT_DELETION_GRACE_DAYS`, 14 days by default) the whole record is first stored as an account copy of its own (`PRE_WIPE_`), through the writer the weekly copy uses. The wipe leaves that copy in place, an admin can restore it until `restorableUntil`, and the off-host purge waits until then too. A copy that cannot be written stops the wipe before anything is deleted: 503 `data_wipe.copy_failed`. Gated like account deletion: the typed confirmation, plus a fresh factor proof for an MFA-enrolled account — on a cookie session the second factor completed within the last five minutes, on a Bearer token a single-use `X-Step-Up` elevation minted at `POST /api/auth/step-up` against a `totp`, `webauthn`, or `passkey` proof. The confirmation string is `DELETE`, NOT the `DELETE_ACCOUNT` that route wants. One audit row survives the wipe that clears the rest of the audit history: it is written after the transaction commits, as the receipt for the erasure the person asked for. A Bearer client should expect to be signed out by its own request: API tokens are on the wipe list, so the token that called this — and any step-up elevation minted from it — is removed along with the record. Sign in again rather than retrying with the same credential. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/DataWipeRequest" responses: "200": description: Record erased. The counts say what was actually removed. content: application/json: schema: $ref: "#/components/schemas/DataWipeEnvelope" "401": description: Not authenticated — or the account has a second factor and the caller carries no proof of it fresh enough for a destructive action (`meta.errorCode` = `auth.stepup.required`, or `auth.stepup.mfa_not_enrolled`). A Bearer caller clears it with an `X-Step-Up` elevation from `POST /api/auth/step-up`; the same code covers an elevation that is unknown, expired, already spent, bound to another token, or minted from too weak a factor. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "403": 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 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` = `sharing.not_permitted`). This operation resolves the CALLER and never a named record, so under a switch it refuses instead of quietly answering with the caller's own — leave the shared record first. No grant at any level opens it. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "409": description: The caller is the last Guardian of a managed profile (`meta.errorCode` = `managed_profile.guardian.required`). Nothing was deleted — the managed records are locked for the whole transaction, so the refusal is atomic rather than partial. Add another Guardian, or delete the managed profile, first. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "413": description: Request body exceeds 65536 bytes. Nothing was deleted. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 "503": description: The account copy kept before a wipe could not be written (`meta.errorCode` = `data_wipe.copy_failed`), so nothing was deleted. Only with a deletion grace window. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/settings/module-list-layout: get: tags: - Meta summary: Get the list presentation of labs, illness and checkups description: Every list resolved, defaults filled in, with `saved` per list. Never writes a row. responses: "200": description: The resolved presentation of every list. content: application/json: schema: $ref: "#/components/schemas/ModuleListLayoutEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 put: tags: - Meta summary: Change the presentation of one list description: "Preserve-when-absent: the fields the body leaves out keep their stored value, and every other list stays as stored. Returns every list resolved." requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/ModuleListLayoutPutBody" responses: "200": description: The presentation as stored. content: application/json: schema: $ref: "#/components/schemas/ModuleListLayoutSavedEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/settings/privacy-summary: get: tags: - Meta summary: Read the retention and encryption facts description: What the Data & Privacy dashboard states, computed server-side so the page cannot claim a retention window the purge job does not enforce or an encryption count the registry does not back. Identical for every account on the instance; carries no key material and no per-row data. responses: "200": description: Retention windows and the encryption-at-rest summary. content: application/json: schema: $ref: "#/components/schemas/PrivacySummaryEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/consent/ai: post: tags: - Consent summary: Grant AI consent (signed artefact) description: >- Persist a fresh AI-consent receipt. Required before server-managed AI egress (the operator's global LLM key or shared ChatGPT account); BYOK / local / ChatGPT-OAuth chains are the user's own egress and are not gated. Append-only audit trail. v1.43.2: the body must carry `confirm: true`, sent only when the person taps an agree control that named the service and the data. A request without it (a launch-time sync from an older app build) writes nothing and answers 400 with `meta.errorCode: consent.ai.confirmRequired`; such a client reads the standing consent from `ai.consent` on GET /api/auth/me instead of re-minting it. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/ConsentPostBody" responses: "200": description: The minted receipt. content: application/json: schema: $ref: "#/components/schemas/ConsentReceiptResponseEnvelope" "400": description: "Request validation failed (consent routes use 400), or `confirm` was not `true` (`meta.errorCode: consent.ai.confirmRequired`, nothing written)." content: application/json: schema: $ref: "#/components/schemas/ConsentValidationErrorEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/consent/ai/web: post: tags: - Consent summary: Grant web AI consent (explicit ai_full) description: "Record the calling web user's explicit `ai_full` consent: they pressed the agree control on a card that named the service and the data. Mints a receipt if none is active; a no-op when one already exists. As the person's own consent act it may follow an earlier withdrawal. The body must be `{ \"confirm\": true }`; anything else mints nothing and answers 400 `consent.ai.confirmRequired`. Revocation flows through DELETE /api/consent/ai/latest." requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/WebConsentGrantBody" responses: "200": description: Grant outcome. content: application/json: schema: $ref: "#/components/schemas/WebConsentGrantResponseEnvelope" "400": description: "`confirm` was not `true` (`meta.errorCode: consent.ai.confirmRequired`). Nothing was written." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/consent/ai/latest: get: tags: - Consent summary: Read the latest active consent receipt(s) description: >- The response SHAPE depends on the query, which is the one thing a client has to get right here. With `?kind=` it is `{ kind, receipt }` for that kind alone; WITHOUT it, a map keyed by all three kinds. There is no shape that covers both — decode on whether you sent the parameter. Only non-revoked receipts are considered: a revoked one is never returned, and the reader does not distinguish “revoked” from “never granted”. Auth via cookie or a wildcard Bearer token; rate-limited on the shared per-user consent bucket. parameters: - in: query name: kind schema: description: Narrow to one consent kind. Omit for the full keyspace. type: string enum: - ai_full - ai_insights_only - ai_coach - ai_extraction description: Narrow to one consent kind. Omit for the full keyspace. responses: "200": description: The receipt for the requested kind, or the map across all three when `kind` was omitted. content: application/json: schema: anyOf: - $ref: "#/components/schemas/ConsentLatestForKindEnvelope" - $ref: "#/components/schemas/ConsentLatestByKindEnvelope" "400": &a32 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." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 delete: tags: - Consent summary: Revoke the latest consent receipt, or all of them description: >- With `?kind=` it revokes the latest active receipt of that kind; WITHOUT it, it revokes the latest active receipt of EVERY kind — the master “turn AI off” switch. As with the GET, the two arms answer different shapes. Idempotent on purpose, and it is worth knowing why: revoking a kind that has nothing active answers 200 with `receipt: null` rather than 404, because the client toggle fires this on every flip and a 404 would render as a failure the user cannot act on. Revocation is append-only in effect — the row is stamped, never deleted — and each revocation is audit-logged. Revoking `ai_full` or `ai_extraction` does NOT switch off `documentsAutoAiRead`, but it does stop document reading: the document gate reads the receipt, not the switch, so a revocation wins over a switch left on. Turning the switch on again mints a fresh `ai_extraction` receipt through PATCH /api/auth/me/documents-auto-ai-read. A client offering both controls should show them as the two separate things they are. When a revocation leaves no receipt covering the analysis (`ai_insights_only` or `ai_full`), the same transaction deletes the text a model wrote under it that can be written again: per-metric status notes, the cached briefing, model-written period narratives, reaction lines and workout paragraphs. Coach conversations, facts and plans and document summaries are the person's records and stay. A deletion is audit-logged with counts only (`consent.ai.purge`). parameters: - in: query name: kind schema: description: Revoke one kind. Omit to revoke the latest active receipt across every kind. type: string enum: - ai_full - ai_insights_only - ai_coach - ai_extraction description: Revoke one kind. Omit to revoke the latest active receipt across every kind. responses: "200": description: "The revoked receipt for the named kind (or `receipt: null` when there was none), or the list of everything revoked when `kind` was omitted." content: application/json: schema: anyOf: - $ref: "#/components/schemas/ConsentRevokeForKindEnvelope" - $ref: "#/components/schemas/ConsentRevokeAllEnvelope" "400": *a32 "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/import/health-connect-export: post: tags: - Import summary: Upload a Health Connect export for background import description: "Takes the ZIP the Android Health Connect app writes (it holds `health_connect_export.db`) and hands it to a background worker. The response is **202 with a `jobId`**; poll `GET /api/import/health-connect-export/status`. Body is `multipart/form-data` with the file under `file`, streamed to disk after authentication like the Apple Health upload. Re-uploading the same bytes resolves to the still-viable job and returns `idempotent: true`. Imported rows carry `source = HEALTH_CONNECT`. Cookie or wildcard Bearer." requestBody: required: true content: multipart/form-data: schema: type: object properties: file: type: string description: The Health Connect export ZIP, under the field name `file`. required: - file responses: "202": description: Accepted and queued, or matched to a job already carrying these bytes. content: application/json: schema: $ref: "#/components/schemas/HealthConnectImportKickoffEnvelope" "400": description: The request carried no body at all. content: application/json: 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. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "413": description: The declared `Content-Length` exceeds the 1 GiB upload cap. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": description: "The multipart body could not be read: no `file` part, a malformed body, or more bytes than the cap. Whether the ZIP is a readable Health Connect export is decided by the worker and reported on the job (`status: failed`, `failureReason`)." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 "503": description: No background worker is bound, or the queue did not take the job, so nothing would run the import. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/import/health-connect-export/status: get: tags: - Import summary: The caller's latest Health Connect import description: The most recent Health Connect import job of the caller, or `null` when there has never been one. responses: "200": description: The latest job, or null. content: application/json: schema: $ref: "#/components/schemas/HealthConnectImportStatusEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/import/csv: post: tags: - Import summary: Import measurements from CSV description: "Bulk-import measurements from a CSV file (web cold-start escape hatch). Body is raw `text/csv` (≤ 16 MB, ≤ 10 000 valid rows). Header (order-independent): `type,value,unit,measuredAt[,glucoseContext,notes,externalId]`. `measuredAt` must carry an explicit ISO-8601 offset and is bounded (no future beyond a 5-min skew, no instant before 1900). Glucose accepts `mmol/L` (converted to canonical `mg/dL`); weight accepts `lb` (converted to `kg`). `glucoseContext` is optional on a `BLOOD_GLUCOSE` row — a blank cell stores no context, which is the normal shape of a continuous-sensor export; a non-empty value is still checked against the enum, and the column is refused on any other type. Re-uploading a file is idempotent. With an `externalId` column a row upserts on `(userId, type, source=IMPORT, externalId)`: a changed row is `updated`, an unchanged one is `skipped` with reason `duplicate` and not written. Without it a row is deduplicated on `(type, measuredAt)` and a repeat is `skipped` / `duplicate`. Writes commit in chunks rather than as one transaction; a failure part-way leaves earlier chunks written, and re-uploading the same file completes the rest. `?dryRun=1` validates + previews without writing. Shares the 5/hour `import:` rate bucket. An unknown query parameter is refused with 422, so a misspelt `dryRun` never writes." parameters: - in: query name: dryRun schema: description: "`1` validates every row and reports what would happen, writing nothing." default: "0" type: string enum: - "0" - "1" - "true" - "false" description: "`1` validates every row and reports what would happen, writing nothing." requestBody: required: true content: text/csv: schema: type: string example: |- type,value,unit,measuredAt,glucoseContext,notes,externalId WEIGHT,80.5,kg,2026-05-01T08:00:00Z,,morning, BLOOD_GLUCOSE,5.3,mmol/L,2026-05-01T08:05:00+02:00,FASTING,,meter-001 BLOOD_GLUCOSE,5.9,mmol/L,2026-05-01T08:20:00+02:00,,,sensor-001 responses: "200": description: Per-row import outcome. content: application/json: schema: $ref: "#/components/schemas/CsvImportResponseEnvelope" "401": *a1 "403": *a9 "413": description: CSV exceeds the 16 MB limit. content: application/json: schema: $ref: "#/components/schemas/CsvImportTooLargeEnvelope" "422": *a4 "429": *a5 /api/import/labs-csv: post: tags: - Import summary: Import lab results from CSV description: "Body is the raw CSV file (`text/csv`, ≤ 16 MB), read as a stream: a UTF-8 byte-order mark is dropped, the separator is `,`, `;` or a tab, quoted fields may hold separators and line breaks. Every row is checked on its own and a refused row is reported by line with a reason, never guessed (a number or date that reads two ways is refused). Re-uploading the same file writes nothing: every row is reported as a duplicate. `dryRun=1` checks the whole file without writing and returns the first rows as they would be stored; it spends a separate bucket of 30 checks an hour, the import itself the shared 5-an-hour `import:` bucket. Owner only; refused with `module.disabled` when the module is switched off. An unknown query parameter is refused with 422, so a misspelt `dryRun` never writes. Each row's name resolves to the account's marker the way a scanned report's rows do (name, alias, catalog name); a new analyte mints one marker from its first row. A numeric result needs a unit; one in another unit is converted when the conversion is known and keeps the printed value beside it, otherwise the row is refused with `unit_mismatch`. Any other text in the value column is a qualitative result. A date without a time is stored as that calendar date. At most 20 000 data rows." parameters: - in: query name: dryRun schema: description: "`1` validates every row and reports what would happen, writing nothing." default: "0" type: string enum: - "0" - "1" - "true" - "false" description: "`1` validates every row and reports what would happen, writing nothing." - in: query name: header schema: description: "`1` when the first row holds column names and is not data." default: "1" type: string enum: - "0" - "1" description: "`1` when the first row holds column names and is not data." - in: query name: delimiter schema: default: auto description: Column separator; `auto` detects it from the first line. type: string enum: - auto - comma - semicolon - tab description: Column separator; `auto` detects it from the first line. - in: query name: decimal schema: default: dot description: Decimal mark of the numbers. A number that reads differently in the two conventions (`1,234` with `dot`) is refused per row as ambiguous. type: string enum: - dot - comma description: Decimal mark of the numbers. A number that reads differently in the two conventions (`1,234` with `dot`) is refused per row as ambiguous. - in: query name: dateOrder schema: default: iso description: "`iso` = YYYY-MM-DD[THH:mm[:ss][offset]], `dmy` = DD.MM.YYYY (also / or -), `mdy` = MM/DD/YYYY. Two-digit years are refused." type: string enum: - iso - dmy - mdy description: "`iso` = YYYY-MM-DD[THH:mm[:ss][offset]], `dmy` = DD.MM.YYYY (also / or -), `mdy` = MM/DD/YYYY. Two-digit years are refused." - in: query name: timezone schema: description: IANA zone a date-time without an offset is read in. Defaults to the account's zone. A date without a time is stored as that calendar date. type: string maxLength: 64 description: IANA zone a date-time without an offset is read in. Defaults to the account's zone. A date without a time is stored as that calendar date. - in: query name: analyte schema: type: integer minimum: 0 maximum: 99 description: Column of the marker name (required). required: true description: Column of the marker name (required). - in: query name: value schema: type: integer minimum: 0 maximum: 99 description: Column of the result (required). A number is a numeric result; any other text (`negative`, `<0.5`) is kept as a qualitative result. required: true description: Column of the result (required). A number is a numeric result; any other text (`negative`, `<0.5`) is kept as a qualitative result. - in: query name: unit schema: description: Column of the unit. Required for a numeric result. type: integer minimum: 0 maximum: 99 description: Column of the unit. Required for a numeric result. - in: query name: date schema: type: integer minimum: 0 maximum: 99 description: Column of the date, or date and time (required). required: true description: Column of the date, or date and time (required). - in: query name: time schema: description: Column of the time, when separate. type: integer minimum: 0 maximum: 99 description: Column of the time, when separate. - in: query name: referenceLow schema: type: integer minimum: 0 maximum: 99 description: 0-based index of the file column this field is read from. - in: query name: referenceHigh schema: type: integer minimum: 0 maximum: 99 description: 0-based index of the file column this field is read from. - in: query name: referenceRange schema: description: Column of a printed range such as `4.0-5.6` or `< 200`. type: integer minimum: 0 maximum: 99 description: Column of a printed range such as `4.0-5.6` or `< 200`. - in: query name: panel schema: type: integer minimum: 0 maximum: 99 description: 0-based index of the file column this field is read from. - in: query name: note schema: description: Column of a note; stored encrypted. type: integer minimum: 0 maximum: 99 description: Column of a note; stored encrypted. requestBody: required: true content: text/csv: schema: type: string example: |- Analyte;Value;Unit;Date;Range HbA1c;5,4;%;01.03.2025;4,0-5,6 Urine protein;negative;;01.03.2025; responses: "200": description: Per-file and per-row outcome. content: application/json: schema: $ref: "#/components/schemas/LabsCsvImportEnvelope" "401": *a1 "403": 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: $ref: "#/components/schemas/ErrorEnvelope" "413": description: "`import.csv.too_large`: the file is larger than 16 MB." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "415": description: "`import.csv.content_type`: the body is not `text/csv`." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/import/medications-csv: post: tags: - Import summary: Import medications from CSV description: "Body is the raw CSV file (`text/csv`, ≤ 16 MB), read as a stream: a UTF-8 byte-order mark is dropped, the separator is `,`, `;` or a tab, quoted fields may hold separators and line breaks. Every row is checked on its own and a refused row is reported by line with a reason, never guessed (a number or date that reads two ways is refused). Re-uploading the same file writes nothing: every row is reported as a duplicate. `dryRun=1` checks the whole file without writing and returns the first rows as they would be stored; it spends a separate bucket of 30 checks an hour, the import itself the shared 5-an-hour `import:` bucket. Owner only; refused with `module.disabled` when the module is switched off. An unknown query parameter is refused with 422, so a misspelt `dryRun` never writes. Maps name, dose (as written), daily intake times, an as-needed mark and a start and end date; every other rhythm is set up afterwards in the app. A row needs times unless it is marked as needed. Each row is checked against the create route's own schema; a dated window opens a course. Reminders stay off unless `reminders=1`. A medication the record holds under the same name and dose is a duplicate. At most 500 data rows." parameters: - in: query name: dryRun schema: description: "`1` validates every row and reports what would happen, writing nothing." default: "0" type: string enum: - "0" - "1" - "true" - "false" description: "`1` validates every row and reports what would happen, writing nothing." - in: query name: header schema: description: "`1` when the first row holds column names and is not data." default: "1" type: string enum: - "0" - "1" description: "`1` when the first row holds column names and is not data." - in: query name: delimiter schema: default: auto description: Column separator; `auto` detects it from the first line. type: string enum: - auto - comma - semicolon - tab description: Column separator; `auto` detects it from the first line. - in: query name: decimal schema: default: dot description: Decimal mark of the numbers. A number that reads differently in the two conventions (`1,234` with `dot`) is refused per row as ambiguous. type: string enum: - dot - comma description: Decimal mark of the numbers. A number that reads differently in the two conventions (`1,234` with `dot`) is refused per row as ambiguous. - in: query name: dateOrder schema: default: iso description: "`iso` = YYYY-MM-DD[THH:mm[:ss][offset]], `dmy` = DD.MM.YYYY (also / or -), `mdy` = MM/DD/YYYY. Two-digit years are refused." type: string enum: - iso - dmy - mdy description: "`iso` = YYYY-MM-DD[THH:mm[:ss][offset]], `dmy` = DD.MM.YYYY (also / or -), `mdy` = MM/DD/YYYY. Two-digit years are refused." - in: query name: timezone schema: description: IANA zone a date-time without an offset is read in. Defaults to the account's zone. A date without a time is stored as that calendar date. type: string maxLength: 64 description: IANA zone a date-time without an offset is read in. Defaults to the account's zone. A date without a time is stored as that calendar date. - in: query name: name schema: type: integer minimum: 0 maximum: 99 description: Column of the medication name (required). required: true description: Column of the medication name (required). - in: query name: dose schema: type: integer minimum: 0 maximum: 99 description: Column of the dose as written, e.g. `10 mg` (required). required: true description: Column of the dose as written, e.g. `10 mg` (required). - in: query name: times schema: description: "Column of daily intake times (`08:00`, or several: `08:00 20:00`, separated by space, comma, semicolon or `|`). A medication with times is taken every day at them; any other rhythm is set up afterwards in the app. A row needs times unless it is marked as needed." type: integer minimum: 0 maximum: 99 description: "Column of daily intake times (`08:00`, or several: `08:00 20:00`, separated by space, comma, semicolon or `|`). A medication with times is taken every day at them; any other rhythm is set up afterwards in the app. A row needs times unless it is marked as needed." - in: query name: asNeeded schema: description: Column marking an as-needed medication (yes/no). type: integer minimum: 0 maximum: 99 description: Column marking an as-needed medication (yes/no). - in: query name: startsOn schema: description: Column of the start date. type: integer minimum: 0 maximum: 99 description: Column of the start date. - in: query name: endsOn schema: description: Column of the end date. type: integer minimum: 0 maximum: 99 description: Column of the end date. - in: query name: reminders schema: description: "`1` turns reminders on for imported medications that have times. Off by default, so an import never starts reminding on its own." default: "0" type: string enum: - "0" - "1" description: "`1` turns reminders on for imported medications that have times. Off by default, so an import never starts reminding on its own." requestBody: required: true content: text/csv: schema: type: string example: |- name,dose,times,as needed,start,end Ramipril,5 mg,08:00,,2025-01-01, Ibuprofen,400 mg,,yes,, responses: "200": description: Per-file and per-row outcome. content: application/json: schema: $ref: "#/components/schemas/MedicationsCsvImportEnvelope" "401": *a1 "403": 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: $ref: "#/components/schemas/ErrorEnvelope" "413": description: "`import.csv.too_large`: the file is larger than 16 MB." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "415": description: "`import.csv.content_type`: the body is not `text/csv`." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/import: post: tags: - Import summary: Import measurements and mood entries from JSON description: "Imports the app's own files back: the JSON export (`GET /api/export?format=json`, read inside its `data` envelope) or the portable full backup (`GET /api/export/full-backup`). Every field either file writes for a reading or a mood entry is carried, apart from the row ids, the source and the sync bookkeeping; a backup's deletion records are skipped and counted. Up to 10 000 measurements plus 10 000 mood entries per call, body ≤ 16 MB. Rows carrying an `externalId` upsert idempotently (re-import updates in place); rows without one are first-write-wins and dedup against the existing unique keys, so an exact duplicate is `skipped` rather than doubled. A row with an unstable `externalId` is skipped while the rest of the file still lands. Every imported row stores `source = IMPORT`; a mood entry without `a1` derives pleasantness from its label. Writes are per-row, not transactional: on a mid-file write failure the response is 503 (`meta.errorCode` = `import.write_failed`, `meta.retryable` = true) with `meta.stats` carrying what landed — rows already written stay written, and a retry is only safe against duplication for rows that carry an `externalId`. Validation is fail-fast (the 422 carries the first issue's message, not the multi-issue envelope). Rate-limited 5/hour per user (shared `import:` bucket with the CSV importer)." requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/JsonImportRequest" responses: "200": description: Import completed; per-section outcome counts. content: application/json: schema: $ref: "#/components/schemas/JsonImportStatsEnvelope" "401": *a1 "403": *a9 "413": description: Body exceeds the 16 MB limit. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 "503": description: A write failed partway through the file. `meta.errorCode` = `import.write_failed`, `meta.retryable` = true, `meta.stats` carries the counts that landed before the failure. Rows already written stay written; retry the same file — rows with `externalId` re-import idempotently, rows without one may duplicate. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/import/apple-health-export: post: tags: - Import summary: Upload an Apple Health export.zip for background import description: >- Takes the `export.zip` the Health app produces and hands it to a background worker. The response is **202 with a `jobId`** — nothing has been imported yet. Poll `GET /api/import/apple-health-export/{jobId}/status` for the outcome. Body is `multipart/form-data` with the file under the field name `file`, capped at 1.5 GB. The upload is streamed straight to disk rather than buffered, computing a SHA-256 in the same pass; the declared `Content-Length` is checked first as a cheap pre-flight, but the streaming sink enforces the cap independently, so a missing or lying header does not get past it. **Idempotency is by CONTENT, not by an `Idempotency-Key`.** A 1 GB upload has no stable client-side key, so re-uploading the exact same bytes resolves to the previous job and returns `idempotent: true` without re-queueing. The match only covers a job that is still viable — queued, in flight, or done. A previously FAILED job is deliberately not matched, so the same export can always be retried with the same bytes rather than being tombstoned by its own failure. Rate-limited to 3 uploads per minute per user, checked before the body is touched. Cookie or wildcard Bearer. requestBody: required: true content: multipart/form-data: schema: type: object properties: file: type: string description: The Apple Health `export.zip`, up to 1.5 GB. The field name is load-bearing — a part under any other name is not found and the request fails as a malformed upload. required: - file responses: "202": description: Accepted and queued (or matched to a job already carrying these bytes). `status` is `queued` for a fresh job and the existing job's current status on a content match. content: application/json: schema: $ref: "#/components/schemas/AppleHealthImportKickoffEnvelope" "400": description: The request carried no body at all. content: application/json: 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` names the running job; poll it, then upload again. The upload was discarded. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "413": description: The declared `Content-Length` exceeds 1.5 GB. A body that exceeds the cap without declaring it is refused by the streaming sink instead, which surfaces as the 422 below. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": description: The multipart body could not be streamed to disk — no `file` part, a malformed boundary, or the size cap tripped mid-stream. The message names the failure. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": description: More than 3 uploads in a minute for this user. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "503": description: No background worker is bound to this process, or the queue did not accept the job, so nothing will run it. The staged upload is discarded (and a created job is marked failed); retry once a worker is running. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/import/apple-health-export/{jobId}/status: get: tags: - Import summary: Poll an Apple Health import job description: >- The state of one import job. The web client polls every 2 seconds while the job is running and every 30 seconds once it is terminal. Visible to the job's owner, and to the admin who triggered it through the admin variant. Anyone else gets **404, not 403** — existence is not leaked to an outsider, so a 404 means either 'no such job' or 'not yours' and a client cannot tell which. The `result` block is scrubbed before it is served: the ECG counters are re-read as bounded non-negative integers so a malformed worker payload cannot put arbitrary values on this wire. parameters: - in: path name: jobId schema: type: string description: The id returned by the upload. required: true description: The id returned by the upload. responses: "200": description: The job's current state. content: application/json: 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: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/measurement-reminders: get: tags: - MeasurementReminders summary: List Vorsorge reminders (v1.17.1) description: "Returns the owner's live (non-tombstoned) Vorsorge reminders, sorted by server-computed nextDueAt ascending (nulls last). Each row carries the canonical nextDueAt the client renders without recomputing. Since v1.39.2 a reminder whose cycle is longer than seven days (intervalDays > 7, an rrule whose next occurrence is more than seven days out) or that has no cadence keeps its nextDueAt after its reminder is sent, with or without a measurementType: it stays due, then overdue, until it is satisfied (a matching reading or lab result, or completed), skipped or snoozed, and the server repeats the reminder at most once every seven local days while it stays open. A reminder on a weekly or shorter cycle still rolls on to its next slot after a delivered reminder." responses: "200": description: The owner's measurement reminders. content: application/json: schema: $ref: "#/components/schemas/MeasurementReminderListEnvelope" "401": *a1 "403": *a15 "422": *a4 "429": *a5 post: tags: - MeasurementReminders summary: Create a Vorsorge reminder (v1.17.1) description: Creates a reminder and computes its server-authoritative nextDueAt. Exactly one of intervalDays (rolling) or rrule (RFC-5545) is required. Wrapped in withIdempotency. 201 on insert. parameters: - *a6 requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/MeasurementReminderCreate" responses: "201": description: Reminder created. content: application/json: schema: $ref: "#/components/schemas/MeasurementReminderCreatedEnvelope" "401": *a1 "403": *a15 "409": *a7 "422": *a4 "429": *a5 /api/measurement-reminders/{id}: get: tags: - MeasurementReminders summary: Read a single Vorsorge reminder (v1.17.1) description: Owner-scoped; a cross-user or tombstoned id 404s. parameters: - in: path name: id schema: type: string required: true responses: "200": description: The reminder. content: application/json: schema: $ref: "#/components/schemas/MeasurementReminderEnvelope" "401": *a1 "403": *a15 "404": &a33 description: Measurement reminder not found / not owned. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 patch: tags: - MeasurementReminders summary: Edit a Vorsorge reminder (v1.17.1) description: Partial edit; omitted fields are left untouched. nextDueAt is recomputed server-side only when the edit changes when the reminder recurs (intervalDays, rrule, anchorDate compared by value, or enabled switched back on); that recompute also clears a snooze. A notifyHour-only edit keeps the due day and moves the hour on it. Any other edit (label, location, measurementType) leaves nextDueAt as it is, so an open, overdue check-up stays due. Owner-scoped. parameters: - in: path name: id schema: type: string required: true requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/MeasurementReminderUpdate" responses: "200": description: Reminder updated. content: application/json: schema: $ref: "#/components/schemas/MeasurementReminderPatchEnvelope" "401": *a1 "403": *a15 "404": *a33 "422": *a4 "429": *a5 delete: tags: - MeasurementReminders summary: Soft-delete a Vorsorge reminder (v1.17.1) description: Sets deletedAt (tombstone). Idempotent. Owner-scoped. parameters: - in: path name: id schema: type: string required: true responses: "200": description: Soft-deleted. content: application/json: schema: $ref: "#/components/schemas/MeasurementReminderDeleteEnvelope" "401": *a1 "403": *a15 "404": *a33 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/satisfy: post: tags: - MeasurementReminders summary: Mark a Vorsorge reminder done (v1.17.1) description: "Manual 'Erledigt': stamps lastSatisfiedAt = now and recomputes nextDueAt past now. Free-text reminders resolve only through this path; typed reminders also auto-resolve in the cron when a matching reading lands." parameters: - in: path name: id schema: type: string required: true responses: "200": description: Reminder satisfied; next-due re-anchored. content: application/json: schema: $ref: "#/components/schemas/MeasurementReminderSatisfyEnvelope" "401": *a1 "403": *a15 "404": *a33 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/complete: post: tags: - MeasurementReminders summary: Complete a Vorsorge reminder (v1.18.6) description: "Explicit user-action completion (iOS #23): the app marks a reminder done server-side instead of only dismissing it locally. Routes through the same satisfaction primitive as the cron auto-resolve — stamps lastSatisfiedAt = now, re-anchors nextDueAt, fires no notification. Idempotent: completing an already-completed / auto-satisfied reminder returns 200 with completed=false. Owner-scoped." parameters: - in: path name: id schema: type: string required: true responses: "200": description: Completion applied (completed=true) or already-satisfied no-op (completed=false); reminder carries the canonical post-completion DTO. content: application/json: schema: $ref: "#/components/schemas/MeasurementReminderCompleteEnvelope" "401": *a1 "403": *a15 "404": *a33 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/skip: post: tags: - MeasurementReminders summary: Skip the current due cycle (v1.37.20) description: "Honestly skips the current due cycle — no body; the skip instant is the server clock. The interval restarts from the skip (rolling: skip + N days at the notify hour; rrule: next occurrence strictly after the skip), lastSkippedAt is stamped, skipCount incremented, any snooze cleared, and a SKIPPED row lands in the completion ledger. lastSatisfiedAt is never touched: a skip is not a completion. Screening reminders are skippable (the satisfy-side 409 guards claimed fulfilment; a skip claims the opposite). Owner-scoped; an appointment (encounter) reminder 404s." parameters: - in: path name: id schema: type: string required: true responses: "200": description: Skip applied (skipped=true) or forward-only no-op (skipped=false); reminder carries the canonical post-skip DTO. content: application/json: schema: $ref: "#/components/schemas/MeasurementReminderSkipEnvelope" "401": *a1 "403": *a15 "404": *a33 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/snooze: post: tags: - MeasurementReminders summary: Snooze to a calendar day (v1.37.20) description: "Pushes the current due date back to a named calendar day (YYYY-MM-DD), resolved server-side to the reminder's notifyHour in the profile timezone. snoozedUntil and nextDueAt are set to the same instant, so every due-state consumer moves together and the cursor self-expires. The regular cadence is untouched — lastSatisfiedAt, lastSkippedAt and the anchor stay put. Must be at least tomorrow and at most five years out (422 otherwise). Repeated snoozes: last one wins. Owner-scoped; an appointment reminder 404s." parameters: - in: path name: id schema: type: string required: true requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/MeasurementReminderSnooze" responses: "200": description: Snooze applied; reminder carries the resolved DTO. content: application/json: schema: $ref: "#/components/schemas/MeasurementReminderSnoozeEnvelope" "401": *a1 "403": *a15 "404": *a33 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/history: get: tags: - MeasurementReminders summary: Completion ledger, newest first (v1.37.20) description: "Paginated completion ledger for one reminder (iOS #68): one row per satisfy (from any path) and per skip, newest first. onTime is derived server-side at write time against the due instant that was current when the event landed. History begins at the release that introduced the ledger — the single-cursor engine holds nothing to backfill from. Owner-scoped; an appointment reminder 404s." parameters: - in: path name: id schema: type: string required: true - in: query name: limit schema: default: 50 type: integer minimum: 1 maximum: 100 - in: query name: offset schema: default: 0 type: integer minimum: 0 maximum: 9007199254740991 responses: "200": description: The reminder's completion ledger page. content: application/json: schema: $ref: "#/components/schemas/MeasurementReminderHistoryEnvelope" "401": *a1 "403": *a15 "404": *a33 "422": *a4 "429": *a5 /api/labs: get: tags: - Labs summary: List the caller's lab results description: Returns the caller's live (non-deleted) lab results, newest first, with optional analyte / panel / date-range filters. Each row carries a server-computed neutral `rangeStatus` and a `hasNote` flag (the encrypted note itself is not echoed). parameters: - in: query name: biomarkerId schema: type: string minLength: 1 maxLength: 64 - in: query name: analyte schema: type: string minLength: 1 maxLength: 120 - in: query name: panel schema: type: string minLength: 1 maxLength: 120 - in: query name: q schema: type: string minLength: 1 maxLength: 120 - in: query name: from schema: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z))$ - in: query name: to schema: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z))$ - in: query name: limit schema: default: 100 type: integer minimum: 1 maximum: 500 - in: query name: offset schema: default: 0 type: integer minimum: 0 maximum: 9007199254740991 - in: query name: sortDir schema: default: desc type: string enum: - asc - desc responses: "200": description: Lab-result list. content: application/json: schema: $ref: "#/components/schemas/ListLabResultsEnvelope" "401": *a1 "403": *a15 "422": *a4 "429": *a5 post: tags: - Labs summary: Record a lab result description: Creates a single lab result for the caller. The optional `source` marks provenance (`"MANUAL"` | `"OCR"`); it defaults to `"MANUAL"` when omitted, so the legacy hand-entry contract is unchanged. The on-device-OCR client posts `"OCR"` after the human confirms the parsed values — the raw image never touches the server on this path. The optional note is AES-256-GCM encrypted before write. Audits as `labResult.create`. parameters: - *a6 requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CreateLabResultRequest" responses: "201": description: Created lab result. content: application/json: schema: $ref: "#/components/schemas/CreateLabResultResponse" "401": *a1 "403": *a15 "409": *a7 "422": description: "Nothing was written. `meta.errorCode` = `labs.create.invalid` when the body failed validation (every issue under `details.issues`), or `labs.unit.mismatch` when a numeric reading states a `unit` other than the one its marker is tracked in AND the pair cannot be converted (`meta.markerUnit`, `meta.readingUnit`, `meta.reason` = `unknown_analyte` | `unknown_unit` | `not_finite`). A unit is compared only when one is sent; with a `biomarkerId` it may be omitted and the marker's unit is then the stated one. Spelling is forgiven (`mmol/l`, `ug/L` for `µg/L`); a different unit is converted when the marker's analyte is known and the pair is in the unit-conversion registry (the 201 response then carries `converted: true`), and refused otherwise, never relabelled." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a16 /api/labs/overview: get: tags: - Labs summary: Per-marker overview of the caller's lab history description: "One entry per biomarker over every live lab result, newest reading first: the newest reading, the reading count and a short trend. Unlike `GET /api/labs` it is not paged, so a marker measured only long ago is never cut off by a page size." parameters: [] responses: "200": description: Lab overview. content: application/json: schema: $ref: "#/components/schemas/LabOverviewEnvelope" "401": *a1 "403": *a15 "422": *a4 "429": *a5 /api/labs/{id}: get: tags: - Labs summary: Fetch a single lab result description: Returns the lab result including its decrypted `note`. Cross-user rows surface as 404. parameters: - in: path name: id schema: type: string required: true responses: "200": description: Lab-result detail. content: application/json: schema: $ref: "#/components/schemas/GetLabResultResponse" "401": *a1 "403": *a15 "404": &a34 description: Lab result not found (or owned by another user). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 put: tags: - Labs summary: Edit a lab result description: Partial edit; omitted fields are untouched, an explicit null clears `panel` / `note` / a reference bound. Audits as `labResult.update`. parameters: - in: path name: id schema: type: string required: true requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpdateLabResultRequest" responses: "200": description: Updated lab result. content: application/json: schema: $ref: "#/components/schemas/UpdateLabResultResponse" "401": *a1 "403": *a15 "404": *a34 "422": *a4 "429": *a5 delete: tags: - Labs summary: Delete a lab result description: Soft-deletes the lab result (stamps `deletedAt`). Idempotent. Audits as `labResult.delete`. parameters: - in: path name: id schema: type: string required: true responses: "200": description: Deletion succeeded. content: application/json: schema: $ref: "#/components/schemas/DeleteLabResultResponse" "401": *a1 "403": *a15 "404": *a34 "422": *a4 "429": *a5 /api/labs/restore: post: tags: - Labs summary: Restore soft-deleted lab results description: Un-tombstones 1..200 of the caller's soft-deleted lab results in one pass (the delete-Undo affordance). A foreign / live id is a silent no-op. Audits via the wide-event ledger. parameters: - *a6 requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/RestoreLabResultsRequest" responses: "200": description: Restore count. content: application/json: schema: $ref: "#/components/schemas/RestoreLabResultsResponse" "401": *a1 "403": *a15 "409": *a7 "422": *a4 "429": *a5 /api/labs/ocr/capability: get: tags: - Labs summary: Probe Lab-OCR availability description: Cheap probe (no provider call) the Labs UI uses to decide whether to show the scan affordance. Reports whether the caller's configured AI provider can read images, why not, and whether PDFs are accepted. responses: "200": description: Capability flags. content: application/json: schema: $ref: "#/components/schemas/OcrCapabilityEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/labs/ocr/extract: post: tags: - Labs summary: Extract lab readings from a photo, PDF, or OCR'd text description: "Read-only (NOT idempotent) extraction. Two modes by content-type. VISION (`multipart/form-data`): a `file` (JPEG/PNG/WebP, or PDF — read natively on Anthropic, else rasterized to page images for any other vision provider; ≤ 12 MiB) is run through the user's vision-capable provider; the upload is never logged. TEXT (`application/json`, opt-in local OCR): the browser OCR's the image (tesseract.js) and POSTs `{ mode: \"text\", text }` — only the extracted text reaches the server, so a text-only provider (e.g. ChatGPT-OAuth) reaches the same review/commit flow. Both modes are model work and answer under the `labsOcr` capability (the operator's reading-documents switch, the labs module, and — because a lab report is a document — an active `ai_extraction` or `ai_full` consent receipt for any provider that leaves the machine, checked for the provider actually picked: the first vision-capable entry in vision mode, the chain head in text mode). No provider is 422 `labs.ocr.providerUnsupported`; text mode without the local-OCR opt-in is 422 `labs.ocr.localOcrDisabled`. Then both pass a per-user hourly rate bucket (default 6, operator-tunable via `LABS_OCR_LIMIT_PER_HOUR`; a slot is only consumed when the scan reaches the provider, and the 429 carries `Retry-After`, the `X-RateLimit-*` triple and `meta.retryAt`) and the per-day token budget, and the read is queued: since v1.40 the route answers 202 with a run id and `GET /api/ai-runs/{id}` serves the proposed rows (`OcrExtractResponse`) for the mandatory human review screen. The upload or text is held encrypted in the run until it is read, then dropped; the run is deleted an hour after it ends. Nothing is written to the record — the commit route is the only write. A file too large is 413 `labs.ocr.fileTooLarge`, an unknown type 415 `labs.ocr.fileType`, both before anything is queued. Extracted content is treated as untrusted (prompt-injection); the review step is the safety boundary." requestBody: required: true content: multipart/form-data: schema: type: object properties: file: type: string format: binary description: The lab-report image or PDF. Validated by magic-byte MIME sniff, not the wire Content-Type. required: - file application/json: schema: $ref: "#/components/schemas/OcrTextExtractRequest" responses: "202": &a44 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. headers: Preference-Applied: required: true description: "The route honoured `Prefer: respond-async`." schema: type: string const: respond-async description: "The route honoured `Prefer: respond-async`." Location: required: true description: The run's poll address. schema: type: string description: The run's poll address. content: application/json: schema: $ref: "#/components/schemas/AiRunAcceptedEnvelope" "401": *a1 "403": 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: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 "503": description: "`ai.unavailable` — the inputs behind the AI capability could not be read, and the answer failed closed. Nothing was sent. Retry later." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/labs/ocr/commit: post: tags: - Labs summary: Commit confirmed Lab-OCR rows description: 'Writes ONLY the rows the human confirmed on the review screen. Each row resolves-or-mints a user-scoped biomarker and creates a `LabResult` with `source: "OCR"`; a row that duplicates a live reading, or states a unit other than that of its marker, is skipped (`skipped[].reason`). Idempotent (Idempotency-Key). Audits as `labs.ocr.commit`. `userId` is narrowed from the session, never a body field.' parameters: - *a6 requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/OcrCommitRequest" responses: "200": description: Inserted + skipped rows. content: application/json: schema: $ref: "#/components/schemas/OcrCommitEnvelope" "401": *a1 "403": *a9 "409": *a7 "422": *a4 "429": *a5 /api/ai-runs/{id}: get: tags: - Documents summary: Poll a background document AI run description: "Where one background read stands, and its result or failure once it ends. Scoped to the caller: another account's run, a run that never existed, and a run more than an hour past its end are the same 404 `aiRuns.notFound`. A plain read of the caller's own run; it calls no model and asks no AI capability (those were asked when the run was queued, and again in the worker before anything left the server). Auth via cookie or Bearer." parameters: - name: id in: path required: true schema: type: string responses: "200": description: The run. content: application/json: schema: $ref: "#/components/schemas/AiRunEnvelope" "401": *a1 "403": *a9 "404": description: "`aiRuns.notFound`." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/biomarkers: get: tags: - Labs summary: List the caller's biomarker catalog description: Returns every biomarker the caller has defined, name-ordered. Each carries its unit, reference bounds, and decrypted context. responses: "200": description: Biomarker catalog. content: application/json: schema: $ref: "#/components/schemas/ListBiomarkersEnvelope" "401": *a1 "403": *a15 "422": *a4 "429": *a5 post: tags: - Labs summary: Define a biomarker description: Creates a user-scoped biomarker. The optional context note is AES-256-GCM encrypted before write. Audits as `biomarker.create`. parameters: - *a6 requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CreateBiomarkerRequest" responses: "201": description: Created biomarker. content: application/json: schema: $ref: "#/components/schemas/CreateBiomarkerResponse" "401": *a1 "403": *a15 "409": description: A biomarker with this name already exists for the caller. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a16 /api/biomarkers/{id}: get: tags: - Labs summary: Fetch a single biomarker description: Returns the biomarker including its decrypted `context`, since v1.43 its `aliases`, and since v1.44 its `trend` over every stored reading. Cross-user rows surface as 404. A marker merged into another answers 404 with `meta.mergedInto`. parameters: - in: path name: id schema: type: string required: true responses: "200": description: Biomarker detail. content: application/json: schema: $ref: "#/components/schemas/GetBiomarkerResponse" "401": *a1 "403": *a15 "404": &a35 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." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 put: tags: - Labs summary: Edit a biomarker description: "Partial edit; omitted fields are untouched, an explicit null clears `context` / `panel` / a bound. Since v1.43 `aliases` (0..50, each 1..120 characters) replaces the marker's set of other spellings: listed spellings stay or are added, unlisted ones are removed; omitting the field leaves them as they are. Audits as `biomarker.update`. Since v1.40.0 a `unit` different from the stored one is refused with 422 `biomarkers.unit.locked` while any reading is linked to the marker (`meta.readingCount` carries how many): readers label every linked reading with the marker's unit, so a change would relabel stored numbers without converting them. Sending the current unit, or changing it on a marker with no readings, is accepted." parameters: - in: path name: id schema: type: string required: true requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpdateBiomarkerRequest" responses: "200": description: Updated biomarker. content: application/json: schema: $ref: "#/components/schemas/UpdateBiomarkerResponse" "401": *a1 "403": *a15 "404": *a35 "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; `meta.alias` names the spelling and `meta.biomarkerId` the marker that answers to it." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 delete: tags: - Labs summary: Delete a biomarker description: Hard-deletes the catalog definition together with every reading filed under it, in one transaction. Since v1.43 it needs the same record authority as the edit (manage on the labs section), so a manager of a shared record may delete and the owner is unaffected. Audits as `biomarker.delete` with the marker's name, unit and the number of readings removed. parameters: - in: path name: id schema: type: string required: true responses: "200": description: Deletion succeeded. content: application/json: schema: $ref: "#/components/schemas/DeleteBiomarkerResponse" "401": *a1 "403": *a15 "404": *a35 "422": *a4 "429": *a5 /api/biomarkers/{id}/merge: post: tags: - Labs summary: Merge markers into this one (v1.43) description: Moves every reading of the source markers onto the marker in the path, converting units where the analyte's conversion table knows them, turns the source names into aliases and deletes the sources, in one transaction. A source in a unit without a known conversion refuses the whole merge with 422 `labs.biomarker.merge.unitMismatch` (`meta.sources` names each `{ id, unit, targetUnit, reason }`) and nothing moves. 404 `labs.biomarker.notFound` with `meta.ids` when the target or a source is missing; 422 `labs.biomarker.merge.invalid` for a malformed body or a target among the sources. No undo. Only when `features.biomarkerMerge` is true. parameters: - in: path name: id schema: type: string required: true - *a6 requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/BiomarkerMergeRequest" responses: "200": description: 'The merge result, or the dry-run preview. A preview reports a source that cannot be converted with `conversion: "refused"` and its `refusal`; the same request without `dryRun` answers 422 `labs.biomarker.merge.unitMismatch`.' content: application/json: schema: $ref: "#/components/schemas/MergeBiomarkersResponse" "401": *a1 "403": *a15 "404": *a35 "409": *a7 "422": description: "`labs.biomarker.merge.invalid`: a malformed body, or the target among the sources (Zod issues in `details.issues[]`). `labs.biomarker.merge.unitMismatch`: a source holds readings in a unit the registry cannot convert to the target's; `meta.sources` lists each `{ id, unit, targetUnit, reason }` with `reason` one of `unknown_analyte`, `unknown_unit`, `not_finite`. Nothing was moved." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a16 /api/encounters: get: tags: - Records summary: List visits description: Returns the caller's live visits, split into `upcoming` (soonest first) and `past` (newest first). `limit` and `offset` apply to each list; `meta.truncated` is true when either list has rows past this page, so a client reads the next `offset` until it is false. Soft-deleted visits are excluded. parameters: - in: query name: from schema: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - in: query name: to schema: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - in: query name: status schema: type: string enum: - PLANNED - DONE - CANCELLED - NO_SHOW - in: query name: practitionerId schema: type: string minLength: 1 maxLength: 40 - in: query name: episodeId schema: type: string minLength: 1 maxLength: 40 - in: query name: limit schema: type: integer minimum: 1 maximum: 200 - in: query name: offset schema: type: integer minimum: 0 maximum: 100000 responses: "200": description: The caller's visits. content: application/json: schema: $ref: "#/components/schemas/ListEncountersEnvelope" "401": *a1 "403": *a15 "422": *a4 "429": *a5 post: tags: - Records summary: File or book a visit description: "Creates one visit. A future instant with `status: PLANNED` books an appointment and mints its reminder; a past instant with `status: DONE` and a `reminderId` closes that checkup. Claiming both is a 422 — a visit that has not happened cannot have closed anything. Honours `Idempotency-Key`. Audits as `encounter.visit.create`." parameters: - *a6 requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CreateEncounterRequest" responses: "201": description: Visit filed. content: application/json: schema: $ref: "#/components/schemas/CreateEncounterEnvelope" "401": *a1 "403": *a15 "409": *a7 "422": *a4 "429": *a16 /api/encounters/procedures: get: tags: - Records summary: List procedures and surgeries description: "The caller's procedure and surgery history: every live visit of kind PROCEDURE with status DONE, newest first, bounded at 500. Planned, cancelled and missed procedures are not in it; a booked one stays in the visit list's `upcoming` half. Read at the same section and level as the visit list." parameters: - in: query name: q schema: type: string maxLength: 200 - in: query name: laterality schema: type: string enum: - LEFT - RIGHT - BOTH responses: "200": description: The procedure history. content: application/json: schema: $ref: "#/components/schemas/ListProceduresEnvelope" "401": *a1 "403": *a15 "422": *a4 "429": *a5 /api/body-sites: get: tags: - Records summary: List body sites, or what is filed at one description: The body sites the record holds, across visits and conditions, and with `site` the visits and conditions filed at that site with their linked documents, lab results and visits. The same list serves as suggestions for a body-site field, so a site is written the same way twice. Body sites are encrypted at rest, so grouping and matching run server-side after the decrypt, over at most 500 rows per kind. Read at the same section and level as the visit list; conditions join only when the caller may read the illness section and the record has the illness module on. parameters: - in: query name: site schema: type: string minLength: 1 maxLength: 200 - in: query name: laterality schema: type: string enum: - LEFT - RIGHT - BOTH responses: "200": description: The body sites, and the selection when one was named. content: application/json: schema: $ref: "#/components/schemas/ListBodySitesEnvelope" "401": *a1 "403": *a15 "422": *a4 "429": *a5 /api/encounters/suggest: get: tags: - Records summary: Which visit does a record dated around this day belong to? description: "Resolves the caller's live DONE or PLANNED visits within ±7 days of `anchor` into a verdict, so the three capture moments that ask the question — a document arriving, a lab panel committed from an extraction, a lab panel typed by hand — all read the same answer. `one` means pre-select it visibly with one undo; `many` means offer a picker with NOTHING pre-selected, because pre-selecting the first of two teaches a person to stop reading suggestions; `none` means offer nothing at all. Cancelled visits and no-shows are never candidates: neither happened, so neither produced the record being filed. No AI provider is involved and none is required." parameters: - in: query name: anchor schema: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: true responses: "200": description: The verdict. content: application/json: schema: $ref: "#/components/schemas/SuggestEncounterEnvelope" "401": *a1 "403": *a15 "422": *a4 "429": *a5 /api/encounters/{id}: get: tags: - Records summary: Read a single visit description: Returns the visit with its three link families resolved. Owner-scoped; a cross-user or tombstoned id 404s. parameters: - in: path name: id schema: type: string required: true responses: "200": description: The visit. content: application/json: schema: $ref: "#/components/schemas/GetEncounterEnvelope" "401": *a1 "403": *a15 "404": &a36 description: Visit not found (or owned by another user). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 patch: tags: - Records summary: Edit a visit description: Partial edit; omitted fields are left untouched. Audits as `encounter.visit.update`. Owner-scoped. parameters: - in: path name: id schema: type: string required: true requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpdateEncounterRequest" responses: "200": description: Visit updated. content: application/json: schema: $ref: "#/components/schemas/UpdateEncounterEnvelope" "401": *a1 "403": *a15 "404": *a36 "422": *a4 "429": *a5 delete: tags: - Records summary: Soft-delete a visit description: Stamps `deletedAt` and retires the visit's own appointment reminder with it — never a checkup the visit closed, which belongs to the preventive-care list and outlives it. Idempotent. Audits as `encounter.visit.delete`. parameters: - in: path name: id schema: type: string required: true responses: "200": description: Soft-deleted. content: application/json: schema: $ref: "#/components/schemas/DeleteEncounterEnvelope" "401": *a1 "403": *a15 "404": *a36 "422": *a4 "429": *a5 /api/encounters/{id}/restore: post: tags: - Records summary: Restore a soft-deleted visit description: Clears the tombstone. The link rows survived the soft delete, so the visit returns with its documents, labs and conditions still filed against it. Its appointment reminder returns only if the appointment is still in the future. Restoring a live visit is a no-op that still succeeds. parameters: - in: path name: id schema: type: string required: true responses: "200": description: Visit restored. content: application/json: schema: $ref: "#/components/schemas/RestoreEncounterEnvelope" "401": *a1 "403": *a15 "404": *a36 "422": *a4 "429": *a5 /api/encounters/{id}/links: post: tags: - Records summary: File targets against a visit description: "Adds links. Idempotent: a pair that already exists succeeds and changes nothing. Session-authenticated only, matching document link and unlink. Since v1.43 `targetKind` also takes `medication` and `vaccination`; those links show in the visit's `related` list (`GET /api/encounters/{id}`), not in `links`." parameters: - in: path name: id schema: type: string required: true requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/EncounterLinkRequest" responses: "200": description: The visit's links after the change. content: application/json: schema: $ref: "#/components/schemas/LinkEncounterEnvelope" "401": *a1 "403": *a9 "404": *a36 "422": *a4 "429": *a5 delete: tags: - Records summary: Unfile targets from a visit description: Removes links. Idempotent, and it still works for a target the account has since deleted — refusing would leave a filing nobody can clear. parameters: - in: path name: id schema: type: string required: true requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/EncounterLinkRequest" responses: "200": description: The visit's links after the change. content: application/json: schema: $ref: "#/components/schemas/UnlinkEncounterEnvelope" "401": *a1 "403": *a9 "404": *a36 "422": *a4 "429": *a5 /api/practitioners: get: tags: - Records summary: List practitioners description: Returns the caller's address book, name-ordered, with an optional case-insensitive search over name, practice and specialty. Paged with `limit` and `offset`; `meta.truncated` says whether another page exists. parameters: - in: query name: q schema: type: string minLength: 1 maxLength: 160 - in: query name: limit schema: type: integer minimum: 1 maximum: 200 - in: query name: offset schema: description: Rows to skip, for reading the address book page by page; `meta.truncated` says whether another page exists. default: 0 type: integer minimum: 0 maximum: 100000 description: Rows to skip, for reading the address book page by page; `meta.truncated` says whether another page exists. responses: "200": description: The caller's address book. content: application/json: schema: $ref: "#/components/schemas/ListPractitionersEnvelope" "401": *a1 "403": *a15 "422": *a4 "429": *a5 post: tags: - Records summary: Add a practitioner description: Creates one address-book entry. Honours `Idempotency-Key`. Audits as `practitioner.contact.create`. parameters: - *a6 requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CreatePractitionerRequest" responses: "201": description: Practitioner added. content: application/json: schema: $ref: "#/components/schemas/CreatePractitionerEnvelope" "401": *a1 "403": *a15 "409": *a7 "422": *a4 "429": *a5 /api/practitioners/{id}: get: tags: - Records summary: Read a single practitioner description: Returns the entry including its decrypted `note`. Owner-scoped; a cross-user or tombstoned id 404s. parameters: - in: path name: id schema: type: string required: true responses: "200": description: The practitioner. content: application/json: schema: $ref: "#/components/schemas/GetPractitionerEnvelope" "401": *a1 "403": *a15 "404": &a37 description: Practitioner not found (or owned by another user). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 patch: tags: - Records summary: Edit a practitioner description: Partial edit; omitted fields are left untouched. Audits as `practitioner.contact.update`. Owner-scoped. parameters: - in: path name: id schema: type: string required: true requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpdatePractitionerRequest" responses: "200": description: Practitioner updated. content: application/json: schema: $ref: "#/components/schemas/UpdatePractitionerEnvelope" "401": *a1 "403": *a15 "404": *a37 "422": *a4 "429": *a5 delete: tags: - Records summary: Soft-delete a practitioner description: Stamps `deletedAt`. The entry leaves the address book and the pickers; every live visit and vaccination that named it keeps showing it, and the tombstone is kept while one does. Idempotent. Audits as `practitioner.contact.delete`. parameters: - in: path name: id schema: type: string required: true responses: "200": description: Soft-deleted. content: application/json: schema: $ref: "#/components/schemas/DeletePractitionerEnvelope" "401": *a1 "403": *a15 "404": *a37 "422": *a4 "429": *a5 /api/practitioners/{id}/restore: post: tags: - Records summary: Restore a soft-deleted practitioner description: Clears the tombstone. It does NOT re-attach the visits whose reference was nulled on delete — which visits meant this practice is not recoverable, and guessing would be worse than the gap. Restoring a live entry is a no-op that still succeeds. parameters: - in: path name: id schema: type: string required: true responses: "200": description: Practitioner restored. content: application/json: schema: $ref: "#/components/schemas/RestorePractitionerEnvelope" "401": *a1 "403": *a15 "404": *a37 "422": *a4 "429": *a5 /api/vaccinations/custom: get: tags: - Records summary: List the record's own vaccines description: The vaccine definitions the record's owner added for products the shipped catalogue does not list, by name. Removed definitions are excluded. Readable under a `profile` grant like the dose list. responses: "200": description: The definitions, by name. content: application/json: schema: $ref: "#/components/schemas/ListCustomVaccinesEnvelope" "401": *a1 "403": *a15 "422": *a4 "429": *a5 post: tags: - Records summary: Add an own vaccine description: Defines a vaccine in the same shape a catalogue entry resolves to, so the series and booster logic treat both alike. Names are unique per record, compared without case; adding a name the person removed earlier brings that definition back with the new values (its doses were let go on removal and stay so). Audits as `vaccination.custom.create`. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CreateCustomVaccineRequest" responses: "201": description: Created. content: application/json: schema: $ref: "#/components/schemas/CreateCustomVaccineEnvelope" "401": *a1 "403": *a15 "409": &a38 description: A live definition on the record already carries this name, compared without case (`vaccination.custom.name-taken`). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a16 /api/vaccinations/custom/{id}: patch: tags: - Records summary: Edit an own vaccine description: Changes one definition; every dose logged against it reads the change. Owner-scoped; an unknown or removed id 404s. Audits as `vaccination.custom.update`, naming a rename without quoting it. parameters: - in: path name: id schema: type: string required: true requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpdateCustomVaccineRequest" responses: "200": description: Updated. content: application/json: schema: $ref: "#/components/schemas/UpdateCustomVaccineEnvelope" "401": *a1 "403": *a15 "404": &a39 description: No live definition with this id on the record (`vaccination.custom.not-found`). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "409": *a38 "422": *a4 "429": *a5 delete: tags: - Records summary: Remove an own vaccine description: "Soft-deletes one definition and lets go of every dose that named it. A dose keeps a name: one without wording of its own takes the definition's name as its `vaccineName` first. Removing twice succeeds. Audits as `vaccination.custom.delete` with the number of doses let go." parameters: - in: path name: id schema: type: string required: true responses: "200": description: Removed. content: application/json: schema: $ref: "#/components/schemas/DeleteCustomVaccineEnvelope" "401": *a1 "403": *a15 "404": *a39 "422": *a4 "429": *a5 /api/vaccinations: get: tags: - Records summary: List vaccinations description: Returns the caller's live doses, newest first, each with its series resolved per component antigen over the whole record, never over the page. Paged with `limit` (default 300) and `offset`; `meta.truncated` says whether another page exists. Soft-deleted doses are excluded. parameters: - in: query name: antigenSlug schema: type: string minLength: 1 maxLength: 64 - in: query name: from schema: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - in: query name: to schema: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - in: query name: limit schema: type: integer minimum: 1 maximum: 500 - in: query name: offset schema: description: Doses to skip, for reading the log page by page; `meta.truncated` says whether another page exists. default: 0 type: integer minimum: 0 maximum: 100000 description: Doses to skip, for reading the log page by page; `meta.truncated` says whether another page exists. responses: "200": description: The caller's immunization history. content: application/json: schema: $ref: "#/components/schemas/ListVaccinationsEnvelope" "401": *a1 "403": *a15 "422": *a4 "429": *a5 post: tags: - Records summary: Log a vaccination description: "Records one administered dose and satisfies every live booster reminder keyed to one of its component antigens — so a single combined dose settles each of the antigens it contains. Satisfaction is forward-only: a dose older than a reminder's last satisfaction is a no-op, which is what makes back-entering an old paper record safe. Honours `Idempotency-Key`. Audits as `vaccination.record.create`." parameters: - *a6 requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CreateVaccinationRequest" responses: "201": description: Dose logged. content: application/json: schema: $ref: "#/components/schemas/CreateVaccinationEnvelope" "401": *a1 "403": *a15 "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`). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "409": *a7 "422": *a4 "429": *a16 /api/vaccinations/{id}: get: tags: - Records summary: Read a single vaccination description: Returns the dose with its linked pages resolved. Owner-scoped; a cross-user or tombstoned id 404s. parameters: - in: path name: id schema: type: string required: true responses: "200": description: The dose. content: application/json: schema: $ref: "#/components/schemas/GetVaccinationEnvelope" "401": *a1 "403": *a15 "404": &a40 description: Vaccination not found (or owned by another user). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 patch: tags: - Records summary: Edit a vaccination description: Partial edit; omitted fields are left untouched. Never re-runs booster satisfaction. Audits as `vaccination.record.update`. Owner-scoped. parameters: - in: path name: id schema: type: string required: true requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpdateVaccinationRequest" responses: "200": description: Dose updated. content: application/json: schema: $ref: "#/components/schemas/UpdateVaccinationEnvelope" "401": *a1 "403": *a15 "404": *a40 "422": *a4 "429": *a5 delete: tags: - Records summary: Soft-delete a vaccination description: "Stamps `deletedAt`. A booster reminder the dose once satisfied is deliberately not rewound: the reminder belongs to the preventive-care list and outlives the record, and tidying a transcription is not evidence the dose never happened. Idempotent. Audits as `vaccination.record.delete`." parameters: - in: path name: id schema: type: string required: true responses: "200": description: Soft-deleted. content: application/json: schema: $ref: "#/components/schemas/DeleteVaccinationEnvelope" "401": *a1 "403": *a15 "404": *a40 "422": *a4 "429": *a5 /api/vaccinations/{id}/restore: post: tags: - Records summary: Restore a soft-deleted vaccination description: Clears the tombstone. The link rows survived the soft delete, so the dose returns with its pages still filed against it. Restoring a live dose is a no-op that still succeeds. parameters: - in: path name: id schema: type: string required: true responses: "200": description: Dose restored. content: application/json: schema: $ref: "#/components/schemas/RestoreVaccinationEnvelope" "401": *a1 "403": *a15 "404": *a40 "422": *a4 "429": *a5 /api/vaccinations/{id}/booster: post: tags: - Records summary: Plan a booster reminder from a dose description: >- Mints (or re-anchors) the booster reminder a logged dose suggests. The minted row is an ordinary `origin: VORSORGE` measurement reminder carrying the dose's primary antigen as a server-side match key; it lists on the preventive-care surface and rings through the same engine as every other checkup. Re-confirming re-anchors the existing reminder instead of minting a second one: 201 carries a fresh mint (`minted: true`), 200 a re-anchor. A free-text-only dose has no antigen to remind on and is refused with 422. Audits as `vaccination.booster.planned`. The operation is declared under the health background and the row it mints belongs to the measurements section, so it crosses a section boundary: a delegate whose grant does not also open `measurements` is refused with 403 `vaccination.booster-out-of-scope` and nothing is written. The dose itself still logs — only the booster does not. parameters: - in: path name: id schema: type: string required: true requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/VaccinationBoosterRequest" responses: "200": description: Existing booster reminder re-anchored. content: application/json: schema: $ref: "#/components/schemas/ReanchorVaccinationBoosterEnvelope" "201": description: Booster reminder minted. content: application/json: schema: $ref: "#/components/schemas/MintVaccinationBoosterEnvelope" "401": *a1 "403": 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: 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`). 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: $ref: "#/components/schemas/ErrorEnvelope" "404": *a40 "422": *a4 "429": *a5 /api/vaccinations/suggest: get: tags: - Records summary: Suggest the dose a document belongs to description: Which dose does a document dated `anchor` most plausibly belong to? The document upload review asks this when a scan is classified `VACCINATION`. The window (±7 days, shared with the visit moment) and the one-pre-selects / many-offer-a-picker verdict are decided server-side so the browser never re-derives them. Owner-scoped; the anchor is the only input and it is a date, not an id. parameters: - in: query name: anchor schema: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: true responses: "200": description: The suggestion verdict. content: application/json: schema: $ref: "#/components/schemas/VaccinationSuggestEnvelope" "401": *a1 "403": *a15 "422": *a4 "429": *a5 /api/vaccinations/{id}/links: post: tags: - Records summary: File documents or a visit against a vaccination description: "Adds links. Idempotent: a pair that already exists succeeds and changes nothing. Session-authenticated only, matching document and visit link and unlink. Since v1.43 `targetKind` also takes `encounter` (the visit the dose was given at); that link shows in the dose's `related` list (`GET /api/vaccinations/{id}`), not in `documents`." parameters: - in: path name: id schema: type: string required: true requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/VaccinationLinkRequest" responses: "200": description: The dose's linked pages after the change. content: application/json: schema: $ref: "#/components/schemas/LinkVaccinationEnvelope" "401": *a1 "403": *a9 "404": *a40 "422": *a4 "429": *a5 delete: tags: - Records summary: Unfile documents or a visit from a vaccination description: Removes links. Idempotent, and it still works for a page the account has since deleted — refusing would leave a filing nobody can clear. parameters: - in: path name: id schema: type: string required: true requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/VaccinationLinkRequest" responses: "200": description: The dose's linked pages after the change. content: application/json: schema: $ref: "#/components/schemas/UnlinkVaccinationEnvelope" "401": *a1 "403": *a9 "404": *a40 "422": *a4 "429": *a5 /api/illness/episodes: get: tags: - Illness summary: List illness episodes (v1.18.1) description: Returns the caller's live (non-deleted) illness/condition episodes, newest-first by onset. `includeResolved=false` hides episodes that already carry a `resolvedAt`. Born-gated. parameters: - in: query name: limit schema: type: integer minimum: 1 maximum: 100 - in: query name: includeResolved schema: anyOf: - type: string const: "true" - type: string const: "false" - in: query name: offset schema: type: integer minimum: 0 maximum: 100000 - in: query name: from schema: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - in: query name: to schema: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - in: query name: sortDir schema: type: string enum: - asc - desc - in: query name: q schema: type: string minLength: 1 maxLength: 120 responses: "200": description: The caller's illness episodes. content: application/json: schema: $ref: "#/components/schemas/ListIllnessEpisodesEnvelope" "401": *a1 "403": *a15 "422": *a4 "429": *a5 post: tags: - Illness summary: Open an illness episode (v1.18.1) description: Creates one episode for the caller. `onsetAt` defaults to 'now' when omitted; the optional note is AES-256-GCM encrypted before write. A `parentConditionId` must reference an owned, live episode. Audits as `illness.episode.create`. Born-gated. parameters: - *a6 requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CreateIllnessEpisodeRequest" responses: "201": description: Episode created. content: application/json: schema: $ref: "#/components/schemas/CreateIllnessEpisodeEnvelope" "401": *a1 "403": *a15 "409": *a7 "422": *a4 "429": *a5 /api/illness/episodes/{id}: get: tags: - Illness summary: Read a single illness episode (v1.18.1) description: Returns the episode including its decrypted `note`. Owner-scoped; a cross-user or tombstoned id 404s. Born-gated. parameters: - in: path name: id schema: type: string required: true responses: "200": description: The episode. content: application/json: schema: $ref: "#/components/schemas/GetIllnessEpisodeEnvelope" "401": *a1 "403": *a15 "404": &a41 description: Illness episode not found (or owned by another user). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 patch: tags: - Illness summary: Edit an illness episode (v1.18.1) description: Partial edit; omitted fields are left untouched. A re-parent must point at an owned, live episode (and never the episode itself). Audits as `illness.episode.update`. Owner-scoped + module-gated. parameters: - in: path name: id schema: type: string required: true requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpdateIllnessEpisodeRequest" responses: "200": description: Episode updated. content: application/json: schema: $ref: "#/components/schemas/UpdateIllnessEpisodeEnvelope" "401": *a1 "403": *a15 "404": *a41 "422": *a4 "429": *a5 delete: tags: - Illness summary: Soft-delete an illness episode (v1.18.1) description: "Stamps `deletedAt` (tombstone). Idempotent — a re-delete is a no-op. Returns the `{ deleted: true }` envelope (the cross-module DELETE shape). Audits as `illness.episode.delete`. Owner-scoped + module-gated." parameters: - in: path name: id schema: type: string required: true responses: "200": description: Soft-deleted. content: application/json: schema: $ref: "#/components/schemas/DeleteIllnessEpisodeEnvelope" "401": *a1 "403": *a15 "404": *a41 "422": *a4 "429": *a5 /api/illness/episodes/{id}/restore: post: tags: - Illness summary: Restore a soft-deleted illness episode (v1.18.3) description: Clears `deletedAt`, bringing the episode and its preserved day-logs + encrypted note back into every read. A soft-delete keeps the row and children intact, so restore is a pure flag flip — nothing is re-created (the delete-Undo affordance). Idempotent — restoring a live episode is a no-op. Audits as `illness.episode.restore`. Owner-scoped + born-gated. parameters: - in: path name: id schema: type: string required: true - *a6 responses: "200": description: Episode restored. content: application/json: schema: $ref: "#/components/schemas/RestoreIllnessEpisodeEnvelope" "401": *a1 "403": *a15 "404": *a41 "409": *a7 "422": *a4 "429": *a5 /api/illness/episodes/{id}/resolve: patch: tags: - Illness summary: Mark an illness episode recovered (v1.18.1) description: Stamps `resolvedAt` (defaults to 'now' when the body omits it). A CHRONIC_ONGOING episode has no recovery date and 422s with `errorCode:"illness.episode.chronic-no-resolve"`. Audits as `illness.episode.resolve`. Owner-scoped + module-gated. parameters: - in: path name: id schema: type: string required: true requestBody: required: false content: application/json: schema: $ref: "#/components/schemas/ResolveIllnessEpisodeRequest" responses: "200": description: Episode resolved. content: application/json: schema: $ref: "#/components/schemas/ResolveIllnessEpisodeEnvelope" "401": *a1 "403": *a15 "404": *a41 "422": *a4 "429": *a5 /api/illness/episodes/{id}/day-logs: get: tags: - Illness summary: Read an episode's day-log(s) (v1.18.1, list v1.18.3) description: "Two modes on one path, picked by the `date` param. With `date=YYYY-MM-DD`: the single day-log for that day, or `null` when nothing is logged (lets the log-day sheet pre-fill). Without `date`: the date-less LIST — the episode's day-logs newest-first (override with `sortDir`), paged via `limit`/`offset` with `meta.total`. The parent episode must be owned + live. Born-gated." parameters: - in: path name: id schema: type: string required: true - in: query name: date schema: type: string pattern: ^\d{4}-\d{2}-\d{2}$ - in: query name: limit schema: type: integer minimum: 1 maximum: 200 - in: query name: offset schema: type: integer minimum: 0 maximum: 9007199254740991 - in: query name: sortDir schema: type: string enum: - asc - desc responses: "200": description: "With `date`: the day-log or null. Without `date`: the paged day-log list." content: application/json: schema: $ref: "#/components/schemas/GetIllnessDayLogEnvelope" "401": *a1 "403": *a15 "404": *a41 "422": *a4 "429": *a5 post: tags: - Illness summary: Upsert an episode day-log (v1.18.1) description: "Upserts the day's symptom / functional-impact / fever timeline row on `(episodeId, date)`: 201 on insert, 200 on update. The parent episode must be owned + live; the optional note is encrypted at rest. Audits as `illness.day-log.upsert`. Born-gated." parameters: - in: path name: id schema: type: string required: true requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpsertIllnessDayLogRequest" responses: "200": description: Existing day-log updated. content: application/json: schema: $ref: "#/components/schemas/UpsertIllnessDayLogUpdatedEnvelope" "201": description: New day-log created. content: application/json: schema: $ref: "#/components/schemas/UpsertIllnessDayLogCreatedEnvelope" "401": *a1 "403": *a15 "404": *a41 "422": *a4 "429": *a5 /api/illness/episodes/{id}/correlation: get: tags: - Illness summary: Per-episode retrospective correlation (v1.18.1) description: "Returns the coverage-gated `Derived` correlation for one episode: the pre-onset anomaly scan, the nadir, per-vital physiological returns, the headline recovery-gap, and any red flags. The findings derive from the user's OWN baseline (median ± MAD) over a contamination-guarded window — never a population constant. `status:\"insufficient\"` carries coverage + a reason. Retrospective ONLY — never a predictor or diagnoser. Owner-scoped + module-gated." parameters: - in: path name: id schema: type: string required: true responses: "200": description: The correlation Derived DTO (ok or insufficient). content: application/json: schema: $ref: "#/components/schemas/IllnessCorrelationEnvelope" "401": *a1 "403": *a9 "404": *a41 "422": *a4 "429": *a5 /api/illness/insights: get: tags: - Illness summary: Cross-episode retrospective insights (v1.18.1) description: "Returns the cross-episode retrospective summary over a trailing window: 'sick N times · typical recovery gap X days', a recurrence-by-month tally, and a per-type breakdown. The typical gap is withheld (null) below the min-sample floor (asserts nothing thin). Retrospective ONLY — the recurrence figure is a count of the past, never a forecast. Born-gated + owner-scoped." parameters: - in: query name: windowDays schema: type: integer minimum: 30 maximum: 1095 - in: query name: includeRecoveryGap schema: type: string enum: - "true" - "false" responses: "200": description: The cross-episode retrospective summary. content: application/json: schema: $ref: "#/components/schemas/IllnessInsightsEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/mental-health/assessments: get: tags: - Mental health summary: List the caller's screener history description: Returns PHQ-9 / GAD-7 / WHO-5 / SCI administrations (newest first). Totals + bands + flags only; raw item answers are never returned. parameters: - in: query name: instrument schema: type: string enum: - PHQ9 - GAD7 - WHO5 - SCI - in: query name: limit schema: type: integer minimum: 1 maximum: 200 - in: query name: offset schema: type: integer minimum: 0 maximum: 9007199254740991 responses: "200": description: Screener history. content: application/json: schema: $ref: "#/components/schemas/ListMentalHealthAssessmentsEnvelope" "401": *a1 "403": *a15 "422": *a4 "429": *a5 post: tags: - Mental health summary: Record a completed screener description: >- Stores one administration (item answers encrypted) and writes the derived total as a server-owned PHQ9_SCORE / GAD7_SCORE / WHO5_SCORE / SCI_SCORE measurement (source COMPUTED — never client-writable). WHO-5 and SCI totals run HIGHER = better; their follow-up thresholds point downwards (≤ 50 / ≤ 16). On a positive PHQ-9 item-9 the response carries the locale-aware crisis-resource set. The projection row is the one write this operation makes outside its declared section: the operation is fenced on `mind`, and the measurement it derives lands in `measurements`. It is deliberate and unconditional — a screener whose own total is missing from the record would be an inconsistency inside the section the grant does open. The consent copy for the mood-and-mind section names it. parameters: - *a6 requestBody: content: application/json: schema: $ref: "#/components/schemas/CreateMentalHealthAssessmentRequest" responses: "201": description: Assessment recorded. content: application/json: schema: $ref: "#/components/schemas/CreateMentalHealthAssessmentEnvelope" "401": *a1 "403": *a15 "409": *a7 "422": *a4 "429": *a5 /api/mental-health/assessments/{id}: get: tags: - Mental health summary: Read one administration with its decrypted answers description: "Returns a single screener administration including the decrypted per-item answers (and the PHQ-9's unscored functional follow-up when answered) — the one surface that reads them back. When the stored blob cannot be decrypted the read degrades to `items: null` + `itemsUnavailable: true` instead of failing; the total / band / flag still answer. A soft-deleted or foreign row answers 404." parameters: - in: path name: id schema: type: string required: true responses: "200": description: The administration with its answer breakdown. content: application/json: schema: $ref: "#/components/schemas/GetMentalHealthAssessmentEnvelope" "401": *a1 "403": *a15 "422": *a4 "429": *a5 /api/allergies: get: tags: - Records summary: List allergies (v1.25) description: Returns the caller's live (non-deleted) allergy/intolerance records, newest-first. `includeInactive=false` returns only ACTIVE records. parameters: - in: query name: limit schema: type: integer minimum: 1 maximum: 200 - in: query name: includeInactive schema: anyOf: - type: string const: "true" - type: string const: "false" responses: "200": description: The caller's allergy records. content: application/json: schema: $ref: "#/components/schemas/ListAllergiesEnvelope" "401": *a1 "403": *a15 "422": *a4 "429": *a5 post: tags: - Records summary: Record an allergy (v1.25) description: Creates one allergy/intolerance record for the caller. The free-text reaction + note are AES-256-GCM encrypted before write. Audits as `allergy.create`. parameters: - *a6 requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CreateAllergyRequest" responses: "201": description: Allergy created. content: application/json: schema: $ref: "#/components/schemas/CreateAllergyEnvelope" "401": *a1 "403": *a15 "409": *a7 "422": *a4 "429": *a16 /api/allergies/{id}: get: tags: - Records summary: Read a single allergy (v1.25) description: Returns the record including its decrypted `reaction` + `note`. Owner-scoped; a cross-user or tombstoned id 404s. parameters: - in: path name: id schema: type: string required: true responses: "200": description: The allergy record. content: application/json: schema: $ref: "#/components/schemas/GetAllergyEnvelope" "401": *a1 "403": *a15 "404": &a42 description: Allergy not found (or owned by another user). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 patch: tags: - Records summary: Edit an allergy (v1.25) description: Partial edit; omitted fields are left untouched. Audits as `allergy.update`. Owner-scoped. parameters: - in: path name: id schema: type: string required: true requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpdateAllergyRequest" responses: "200": description: Allergy updated. content: application/json: schema: $ref: "#/components/schemas/UpdateAllergyEnvelope" "401": *a1 "403": *a15 "404": *a42 "422": *a4 "429": *a5 delete: tags: - Records summary: Soft-delete an allergy (v1.25) description: "Stamps `deletedAt` (tombstone). Idempotent — a re-delete is a no-op. Returns the `{ deleted: true }` envelope. Audits as `allergy.delete`. Owner-scoped." parameters: - in: path name: id schema: type: string required: true responses: "200": description: Soft-deleted. content: application/json: schema: $ref: "#/components/schemas/DeleteAllergyEnvelope" "401": *a1 "403": *a15 "404": *a42 "422": *a4 "429": *a5 /api/family-history: get: tags: - Records summary: List family history (v1.25) description: Returns the caller's live (non-deleted) family-history entries, newest-first. parameters: - in: query name: limit schema: type: integer minimum: 1 maximum: 200 responses: "200": description: The caller's family-history entries. content: application/json: schema: $ref: "#/components/schemas/ListFamilyHistoryEnvelope" "401": *a1 "403": *a15 "422": *a4 "429": *a5 post: tags: - Records summary: Record a family-history entry (v1.25) description: Creates one condition-by-relative record for the caller. The free-text note is AES-256-GCM encrypted before write. Audits as `family-history.create`. parameters: - *a6 requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CreateFamilyHistoryRequest" responses: "201": description: Entry created. content: application/json: schema: $ref: "#/components/schemas/CreateFamilyHistoryEnvelope" "401": *a1 "403": *a15 "409": *a7 "422": *a4 "429": *a5 /api/family-history/{id}: get: tags: - Records summary: Read a single family-history entry (v1.25) description: Returns the entry including its decrypted `note`. Owner-scoped; a cross-user or tombstoned id 404s. parameters: - in: path name: id schema: type: string required: true responses: "200": description: The family-history entry. content: application/json: schema: $ref: "#/components/schemas/GetFamilyHistoryEnvelope" "401": *a1 "403": *a15 "404": &a43 description: Family history entry not found (or owned by another user). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 patch: tags: - Records summary: Edit a family-history entry (v1.25) description: Partial edit; omitted fields are left untouched. Audits as `family-history.update`. Owner-scoped. parameters: - in: path name: id schema: type: string required: true requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpdateFamilyHistoryRequest" responses: "200": description: Entry updated. content: application/json: schema: $ref: "#/components/schemas/UpdateFamilyHistoryEnvelope" "401": *a1 "403": *a15 "404": *a43 "422": *a4 "429": *a5 delete: tags: - Records summary: Soft-delete a family-history entry (v1.25) description: "Stamps `deletedAt` (tombstone). Idempotent — a re-delete is a no-op. Returns the `{ deleted: true }` envelope. Audits as `family-history.delete`. Owner-scoped." parameters: - in: path name: id schema: type: string required: true responses: "200": description: Soft-deleted. content: application/json: schema: $ref: "#/components/schemas/DeleteFamilyHistoryEnvelope" "401": *a1 "403": *a15 "404": *a43 "422": *a4 "429": *a5 /api/documents/inbound: get: tags: - Documents summary: List stored documents description: The caller's stored documents with title/filename search, category filter, a `documentDate` range, sort, and keyset pagination. Owner-scoped; gated on the opt-in `inboundDocuments` module. parameters: - name: q in: query required: false schema: type: string maxLength: 100 description: Case-insensitive search over title + filename. - name: kind in: query required: false style: form explode: true schema: type: array items: type: string enum: - DOCTOR_REPORT - DISCHARGE_LETTER - LAB_RESULT - IMAGING - PRESCRIPTION - REFERRAL - INSURANCE - VACCINATION - SICK_NOTE - OTHER description: Category filter — OR inside the facet. Repeat the parameter (or send a comma-separated value). - name: episodeId in: query required: false schema: type: string description: Only documents linked to this illness/condition episode. - name: encounterId in: query required: false schema: type: string description: Only documents linked to this visit. The procedure filter sends a `linkedProcedures` id from the usage read; an id that is not one of the caller's visits matches nothing. - name: year in: query required: false schema: type: integer minimum: 1900 maximum: 9999 description: Only documents whose filing date falls in this calendar year (UTC). Takes precedence over from/to. - name: from in: query required: false schema: type: string format: date description: Inclusive lower bound on documentDate (YYYY-MM-DD). - name: to in: query required: false schema: type: string format: date description: Inclusive upper bound on documentDate (YYYY-MM-DD). - name: sort in: query required: false schema: type: string enum: - documentDate - createdAt - title default: documentDate - name: order in: query required: false schema: type: string enum: - asc - desc default: desc - name: cursor in: query required: false schema: type: string description: Keyset cursor (the `nextCursor` from a prior page). - name: limit in: query required: false schema: type: integer minimum: 1 maximum: 100 default: 50 responses: "200": description: A page of documents. content: application/json: schema: $ref: "#/components/schemas/InboundDocumentListEnvelope" "401": *a1 "403": *a15 "422": *a4 "429": *a5 post: tags: - Documents summary: Store a document (no extraction) description: >- STORE-ONLY upload. Stores the raw document ENCRYPTED at rest with `status: STORED` and runs NO extraction — provider-free, no AI consent / budget / egress. A file is always filable, even with no document-scan provider configured. `multipart/form-data`: a `file` plus optional `title`, `kind`, `documentDate` (YYYY-MM-DD), and repeated `episodeIds` form fields (pre-link to the caller's illness/condition episodes). Accepted types (magic-byte sniffed, never the wire Content-Type): PDF/JPEG/PNG/WebP/GIF render inline; Office (docx/xlsx/pptx/doc/xls/ppt), text/CSV/Markdown/RTF, TIFF, HEIC/HEIF, XML/JSON are stored verbatim and served download-only. HEIC is stored as-is but attachment-only — prefer transcoding to JPEG client-side for inline preview parity. Error contract: `413` with `meta.reason = "fileTooLarge"` (+ `maxFileBytes`) or `"quotaExceeded"` (+ `quotaBytes`, `usedBytes`); `415` with `meta.reason = "unsupportedType"`. A same-user duplicate (same bytes) returns 200 + `meta.duplicate: true` with the existing row — not an error. `Idempotency-Key` honoured. Read `GET /api/documents/inbound/usage` for the effective limits before offering an upload. AI extraction is a separate opt-in action — see `POST /api/documents/inbound/{id}/extract`. **Importing from another system (v1.39.2).** Optional `sourceSystem` (`PAPERLESS`, `PAPRA`, `OTHER`) and `sourceId` (its id there, printable, up to 128 characters, never `.` or `..`; needs `sourceSystem`) key the upload, optionally with `sourceInstance` (v1.39.3: the address of the Paperless-ngx or Papra it came from, normalised to its origin; without it the key matches any instance of the system). A re-send with a known key is a duplicate: 200 with the live row (`meta.duplicate: true`), or, when the owner DELETED that document, 200 with `data.deleted: true` and `meta.deleted: true` and nothing stored; this holds after the 30-day purge too. The key may also ride the query string (`?sourceSystem=…&sourceId=…`): a key the vault already holds is then answered at once, the file is not stored or processed, and the request counts only toward the lookup allowance shared with `GET /api/documents/inbound/source` (5000 an hour per token, per person for a session; 429 past it), not toward the hourly upload limit. A key in both places must agree (else 422). Every other upload counts toward the hourly upload limit, duplicates included. A key that is answered with an existing document by its bytes is remembered for that document, so deleting it keeps that key deleted too; one document takes at most 20 such keys, beyond which the upload is refused 409 (`documents.inbound.sourceAliasLimit`). `OTHER` is one shared namespace per account: two different systems both sent as `OTHER` must not reuse each other's ids. `aiRead=defer` holds back automatic AI reading for this upload: the thumbnail and a local text index still run, the summary and lab staging do not, and the document stays out of every automatic AI path (the summary catch-up, the automatic index, Index all for search) until the person reads it with AI or generates its summary. Absent, behaviour is unchanged. **Narrow token.** Also reachable with a `documents:write` Bearer (minted at `POST /api/tokens/documents`), which reaches only this route and the source-key lookup `GET /api/documents/inbound/source`. Such a caller draws on a bucket of its own, keyed on the token (default 120 an hour, `DOCUMENT_UPLOAD_LIMIT_PER_HOUR`, clamped 1-1000; the 429 carries `Retry-After` and `X-RateLimit-*`), and gets a `DocumentUploadReceipt` instead of the stored row; its 413 `quotaExceeded` carries no `quotaBytes` / `usedBytes`. The response is an untagged union (row or receipt) on purpose: a discriminator would need a new field on the row every existing client decodes. A cookie or wildcard caller keeps the 60-an-hour per-user bucket and the full row. With the vault module off the answer is 403 `module.disabled` for every caller. parameters: - *a6 - name: sourceSystem in: query required: false schema: type: string enum: - PAPERLESS - PAPRA - OTHER description: "Import source key, first half. Sent here (and, if also in the form, agreeing with it), a key the vault already holds is answered at once: the file is not stored or processed and the request does not count toward the hourly upload limit, only toward the lookup allowance shared with `GET /api/documents/inbound/source`." - name: sourceId in: query required: false schema: type: string maxLength: 128 description: Import source key, second half. Both halves or neither; half a key is 422. - name: sourceInstance in: query required: false schema: type: string maxLength: 512 description: "Optional, with a key. The instance of `sourceSystem` the id belongs to, as its http(s) address; normalised to the origin (`scheme://host[:port]`). The same id in two instances is two documents. Optional: a key stored without an instance (every upload before v1.39.3) matches any instance of its system, and a key sent without one matches any stored instance. If also sent in the form it must agree." requestBody: required: true content: multipart/form-data: schema: type: object properties: file: type: string format: binary description: The document. Validated by magic-byte MIME sniff, not the wire Content-Type. title: description: Optional user label. type: string kind: description: Optional category. type: string enum: - DOCTOR_REPORT - DISCHARGE_LETTER - LAB_RESULT - IMAGING - PRESCRIPTION - REFERRAL - INSURANCE - VACCINATION - SICK_NOTE - OTHER documentDate: description: Optional user filing date (YYYY-MM-DD). Defaults to the upload day when omitted so the library sort/filter/display stay aligned. type: string episodeIds: description: Optional condition pre-links (repeat the field per id). Every id must be a live episode of the caller. type: array items: type: string encounterIds: description: Optional visit pre-links (repeat the field per id). Every id must be a live visit of the caller; an unknown one refuses the upload rather than dropping the link the person asked for. type: array items: type: string sourceSystem: description: Optional. The system an imported document comes from. Shown on the detail view. type: string enum: - PAPERLESS - PAPRA - OTHER sourceId: description: Optional. The document's id in `sourceSystem`; requires it. Unique per account across live and deleted documents. type: string maxLength: 128 sourceInstance: description: "Optional; requires `sourceId`. The instance of `sourceSystem` the id belongs to, as its http(s) address; normalised to the origin (`scheme://host[:port]`). The same id in two instances is two documents. Optional: a key stored without an instance (every upload before v1.39.3) matches any instance of its system, and a key sent without one matches any stored instance." type: string maxLength: 512 aiRead: description: Optional. `defer` holds back automatic AI reading for this upload (local index and thumbnail only). type: string enum: - defer required: - file responses: "200": description: "Duplicate — the same bytes, or the same source key, are already stored: the existing row (a `DocumentUploadReceipt` for a `documents:write` token), with `meta.duplicate: true` at the envelope level. When the source key belongs to a document the owner deleted: a receipt with `deleted: true` and `meta.deleted: true`, nothing stored." content: application/json: schema: $ref: "#/components/schemas/DocumentUploadEnvelope" "201": description: The stored document (a `DocumentUploadReceipt` for a `documents:write` token). content: application/json: schema: $ref: "#/components/schemas/DocumentUploadEnvelope" "401": *a1 "403": *a9 "404": description: An `episodeIds` or `encounterIds` id is not a live episode or visit of the caller (`documents.inbound.episodeNotFound` / `documents.inbound.encounterNotFound`). Nothing was stored. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "409": description: The same bytes are already stored under 20 other import source ids (`documents.inbound.sourceAliasLimit`). Nothing was stored or remembered. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "413": description: "Too large: `meta.reason` is `fileTooLarge` (with `maxFileBytes`) or `quotaExceeded` (with `quotaBytes` + `usedBytes`)." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "415": description: Unsupported or unidentifiable type (`meta.reason = "unsupportedType"`). Executables, HTML/SVG, and generic archives are always refused. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/documents/inbound/source: get: tags: - Documents summary: Look up an import source key description: "Whether the vault already holds a document under this import source key, so an importer can skip a document before downloading it from the source system. Answers exactly what an upload with the same key would: `known`, the document `id` (null once a deleted document was purged), and whether the owner `deleted` it. Checks the document's own key, keys remembered for it when an import sent the same bytes under another key, and the keys of purged documents. Reachable with a `documents:write` Bearer (the scope's second and last route) or a cookie session; stores nothing. 5000 lookups an hour per token (per person for a session), shared with the upload's query-string key check; gated on the `inboundDocuments` module." parameters: - name: sourceSystem in: query required: true schema: type: string enum: - PAPERLESS - PAPRA - OTHER - name: sourceId in: query required: true schema: type: string maxLength: 128 - name: sourceInstance in: query required: false schema: type: string maxLength: 512 description: "The instance of `sourceSystem` the id belongs to, as its http(s) address; normalised to the origin (`scheme://host[:port]`). The same id in two instances is two documents. Optional: a key stored without an instance (every upload before v1.39.3) matches any instance of its system, and a key sent without one matches any stored instance." responses: "200": description: The answer for this key. content: application/json: schema: $ref: "#/components/schemas/DocumentSourceLookupEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/documents/inbound/capability: get: tags: - Documents summary: Probe document-AI availability + egress description: "Cheap probe (no provider call) the vault uses to decide the AI transport and whether to warn before a read leaves the machine. Resolved over the DOCUMENT provider order (local-first, ChatGPT-subscription OAuth last), so `mode` / `pdfSupported` / `egress` match exactly what the document AI routes do. `egress` is vendor-blind: `\"local\"` (a self-hosted model — the document never leaves the operator's machine) or `\"external\"` (a third-party AI service). The vault shows a per-egress notice before any external document read; sending a document to any external provider also requires an active `ai_extraction` or `ai_full` consent receipt. `ai` is the `documentAi` capability for the record: when the operator turned reading documents off, the module is off, or the record is somebody else's, `available` is false with a null `reason` and `ai.reason` says why. A missing consent receipt leaves the read offered (`ai.reason = \"consent_required\"`), because the read is where the person is asked for it." responses: "200": description: Document-AI capability flags + egress class. content: application/json: schema: $ref: "#/components/schemas/DocumentAiCapabilityEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/documents/inbound/usage: get: tags: - Documents summary: Storage usage + effective limits description: "The caller's vault usage and effective limits: `usedBytes` (every non-purged row — tombstones inside the 30-day undo grace still count), `quotaBytes` (per-user override ?? instance default), `maxFileBytes` (admin-tunable per-file cap), `acceptedExtensions` for picker `accept` lists, `linkedEpisodes` (episodes carrying at least one live document link — the condition-filter chips), and `linkedProcedures` (visits of kind PROCEDURE carrying at least one live document link, newest first — the procedure-filter choices; pass one's `encounterId` to the list's `encounterId` filter). `linkedProcedures` is empty when a sharing grant does not cover the visits section. Read this before offering an upload." responses: "200": description: Usage + limits. content: application/json: schema: $ref: "#/components/schemas/DocumentUsageEnvelope" "401": *a1 "403": *a15 "422": *a4 "429": *a5 /api/documents/inbound/layout: get: tags: - Documents summary: Get the vault presentation description: The resolved card/list view and month arrangement for the vault, with defaults filled in when nothing has been chosen. Inside a shared record this answers the owner's choice. responses: "200": description: The resolved presentation. content: application/json: schema: $ref: "#/components/schemas/DocumentsLayoutEnvelope" "401": *a1 "403": *a15 "422": *a4 "429": *a5 put: tags: - Documents summary: Change the vault presentation description: "Preserve-when-absent: a body carrying only `view` keeps the stored arrangement and vice versa. Returns the full resolved presentation. Refused while acting inside somebody else's record." requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/DocumentsLayoutPutBody" responses: "200": description: The presentation as stored. content: application/json: schema: $ref: "#/components/schemas/DocumentsLayoutSavedEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/documents/inbound/bulk: post: tags: - Documents summary: Bulk action over documents description: Applies one action to up to 100 owner-scoped documents and returns a per-id result array — a partial failure never aborts the batch. `error` is `notFound` (unknown / foreign / tombstoned where liveness is required) or `conflict` (restore blocked by a live duplicate of the same bytes). requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/DocumentBulkRequest" responses: "200": description: Per-id outcomes. content: application/json: schema: $ref: "#/components/schemas/DocumentBulkEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/documents/inbound/{id}: get: tags: - Documents summary: Get a document + its staged facts description: The document plus every staged fact for the review screen. Owner-scoped. parameters: - name: id in: path required: true schema: type: string responses: "200": description: Document detail. content: application/json: schema: $ref: "#/components/schemas/InboundDocumentDetailEnvelope" "401": *a1 "403": *a15 "422": *a4 "429": *a5 patch: tags: - Documents summary: Edit a document's metadata description: Rename / recategorise / set the user filing date on a stored document. Owner-scoped; no mass assignment. Returns the updated document + its staged facts. parameters: - name: id in: path required: true schema: type: string requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/DocumentUpdateRequest" responses: "200": description: The updated document. content: application/json: schema: $ref: "#/components/schemas/InboundDocumentUpdateEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 delete: tags: - Documents summary: Discard a document description: Soft-deletes the document + its staging. Facts already approved into the structured stores are independent rows and are NOT affected. parameters: - name: id in: path required: true schema: type: string responses: "200": description: Discarded. content: application/json: schema: $ref: "#/components/schemas/InboundDiscardEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/documents/inbound/{id}/restore: post: tags: - Documents summary: Restore a discarded document description: Clears the soft-delete tombstone within the 30-day undo grace, and forgets any import-ledger entry for the document's source keys. 409 when the tombstone is already purged (the undo window is over, or the document was deleted for good) or when an identical live document blocks the restore (`meta.reason = "duplicateExists"`). Restoring an already-live document is a no-op success. parameters: - name: id in: path required: true schema: type: string responses: "200": description: The restored document + its staged facts. content: application/json: 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: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/documents/inbound/deleted: get: tags: - Documents summary: List Recently deleted description: "The caller's deleted documents that the purge has not removed yet, newest deletion first, metadata only. Each carries `purgeAt`, when the daily purge becomes due for it. Restore one with `POST /api/documents/inbound/{id}/restore` (or the bulk `restore` action); remove one for good ahead of time with `POST /api/documents/inbound/{id}/purge`. Capped at 200 rows (`meta.truncated`, `meta.limit`). Owner-only: refused while acting inside somebody else's record." responses: "200": description: Recently deleted. content: application/json: schema: $ref: "#/components/schemas/DeletedDocumentListEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/documents/inbound/{id}/purge: post: tags: - Documents summary: Delete a deleted document for good description: "Removes one deleted document now instead of when the grace window ends, through the same purge the daily job runs: the row and everything that cascades from it go, and an imported document's source keys are kept in the import ledger, so an importer re-sending it is still answered as deleted. Only a deleted document can be purged: a live one answers 409 `documents.inbound.notDeleted`. 404 `documents.inbound.notFound` when the caller has no such document. Owner-only." parameters: - name: id in: path required: true schema: type: string responses: "200": description: Removed for good. content: application/json: schema: $ref: "#/components/schemas/DocumentPurgeResultEnvelope" "401": *a1 "403": *a9 "409": description: The document is not deleted. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/documents/inbound/{id}/original: get: tags: - Documents summary: View / download the original document description: "Decrypts and serves the raw uploaded document (the bytes stored encrypted at rest under `InboundDocument.contentEncrypted`). Owner-scoped + gated on the opt-in `inboundDocuments` module. Serving posture follows the document's `servingClass`: inline types (PDF/JPEG/PNG/WebP/GIF) come with their true Content-Type, `Content-Disposition: inline`, `nosniff`, and a `Content-Security-Policy: sandbox` header; attachment types (Office/text/TIFF/HEIC/XML/JSON) ALWAYS come as `application/octet-stream` + `Content-Disposition: attachment` + `nosniff` — never inline. A non-ASCII filename is carried via RFC 5987 `filename*`. Fails closed (500) on a decrypt error — it never returns the ciphertext." parameters: - name: id in: path required: true schema: type: string responses: "200": description: The decrypted original document bytes. Inline types carry the stored MIME type; attachment types are served as application/octet-stream. content: application/pdf: schema: type: string format: binary image/*: schema: type: string format: binary application/octet-stream: schema: type: string format: binary "401": *a1 "403": *a15 "422": *a4 "429": *a5 /api/documents/inbound/{id}/thumbnail: get: tags: - Documents summary: View the document preview thumbnail description: "Decrypts and serves the document's small JPEG preview thumbnail (~320px long edge), rendered in the background from the original and stored encrypted at rest under `DocumentThumbnail.thumbnailEncrypted`. Owner-scoped + gated on the opt-in `inboundDocuments` module. Always `Content-Type: image/jpeg` + `nosniff` + `Cache-Control: private, no-store`; loaded as an `` subresource. 404 when no thumbnail exists yet (freshly uploaded / still rendering / unsupported type) — the caller shows a placeholder. Fails closed (500) on a decrypt error — it never returns the ciphertext." parameters: - name: id in: path required: true schema: type: string responses: "200": description: The decrypted JPEG preview thumbnail bytes. content: image/jpeg: schema: type: string format: binary "401": *a1 "403": *a15 "422": *a4 "429": *a5 /api/documents/inbound/{id}/extract: post: tags: - Documents summary: Extract facts from a stored document description: "Optional AI enhancement on an already-stored document. Runs the dedicated OCR/vision provider over the stored original and stages STRUCTURED FACTS for review. Answers under the `documentAi` capability (403 with the capability envelope when it is closed), then rate / budget gated. With no provider configured this returns 422 (`documents.inbound.providerUnsupported`, with `meta.capability` and `meta.reason = \"no_provider\"`) — the stored document is untouched, only the enhancement fails. Three modes: VISION (empty body) decrypts and scans the stored original (a PDF is rendered and read a page at a time; one that cannot be rendered needs a provider that reads PDFs natively, else 422 `documents.inbound.pdfNeedsAnthropic`); TEXT (`application/json`, opt-in local OCR) `{ mode: \"text\", text }` structures browser-OCR'd text; STORED (`application/json`) `{ mode: \"stored\" }` structures the document's own stored extracted text (its content index) — the manual recovery for a skipped/failed automatic staging run, 422 `documents.inbound.notIndexed` when no stored text exists. A long document is read a page or a part at a time under the same per-read limits as the lab scan (`LABS_OCR_EXTRACT`), each read after the first reserved against the day's budget on its own. Without `Prefer: respond-async` the extraction runs inside the request and makes at most three reads (six model calls): a longer document has its pages or text shared out between them, and what does not fit is left out and reported as `extractionIncomplete: true` on the answered document; the background run reads it a page at a time within its own deadline; an extraction in which no read produced a complete fact fails with 422 `documents.inbound.outputLimit` when the reads stopped at the provider's output limit, 429 `documents.inbound.budgetExceeded` when the budget refused the reads, and 422 `documents.inbound.extractFailed` only when the provider's answers were not the schema. Extraction reproduces what the document states — it never interprets. Nothing reaches the structured stores here; the confirm route is the only write path. With `Prefer: respond-async` (v1.40) the extraction runs in the background: after the same quick refusals the route answers 202 with a run id, a second request while an extraction of this document from the same input is queued or running answers with that run, and `GET /api/ai-runs/{id}` serves `DocumentExtractRunResult` (`{ documentId, factsStaged, status }`, plus `incomplete` when part of the document may be missing from the staged facts; not the document detail) as the run's `result`. Staging re-checks the document under a row lock, so a review finished while the read ran fails the run with `documents.inbound.alreadyPartlyConfirmed` (409) or `documents.inbound.alreadyConfirmed` (422) and leaves the review untouched; the synchronous form answers the same in that race." parameters: - name: id in: path required: true schema: type: string - &a45 name: Prefer in: header required: false schema: type: string example: respond-async description: "Send `respond-async` (RFC 7240) to run the read in the background: the route answers 202 with a run id once every quick refusal has passed, and `GET /api/ai-runs/{id}` serves the result. Without it the route answers synchronously with the result, as it always has; that form is retained for clients that have not adopted runs, and its retirement will be announced one minor release ahead. A preference the route cannot apply is ignored and the response then carries no `Preference-Applied`." requestBody: required: false content: application/json: schema: $ref: "#/components/schemas/InboundExtractRequest" responses: "200": description: The document with its newly staged facts. content: application/json: schema: $ref: "#/components/schemas/InboundExtractEnvelope" "202": *a44 "401": *a1 "403": &a46 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: $ref: "#/components/schemas/ErrorEnvelope" "409": description: "Re-extraction refused: at least one fact on this document is already APPROVED. `meta.errorCode` = `documents.inbound.alreadyPartlyConfirmed`. Re-extracting would sever committed-record provenance and duplicate committed records, so the user must finish reviewing or discard the document first." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 "503": &a47 description: "`ai.unavailable` — the inputs behind the AI capability could not be read, and the answer failed closed. Nothing was sent. Retry later." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/documents/inbound/{id}/suggest: post: tags: - Documents summary: Suggest filing metadata (drafts only) description: "Optional AI assist on an already-stored document. Runs ONE provider call over the stored original (VISION, empty body) or browser-OCR'd text (TEXT, `application/json` `{ mode: \"text\", text }`, opt-in local OCR) and returns a `{ title, kind, documentDate }` DRAFT for the edit form. Answers under the `documentAi` capability (403 with the capability envelope when it is closed), then rate / budget gated (shares the per-user document-AI bucket with extract/summary/index — default 6/hour, operator-tunable via `DOCUMENT_AI_LIMIT_PER_HOUR`; a slot is only consumed when the request reaches the provider, and the 429 carries `Retry-After`, the `X-RateLimit-Limit` / `-Remaining` / `-Reset` triple, and `meta.retryAt`). WRITES NOTHING — never stages facts, never flips status; the user reviews and saves. 422 (`documents.inbound.providerUnsupported`) with no provider configured. Never interprets or diagnoses; the title is a neutral filing label. With `Prefer: respond-async` (v1.40) the read runs in the background: after the same quick refusals the route answers 202 with a run id, a second request while a suggestion read of this document over the same transport is queued or running answers with that run, and `GET /api/ai-runs/{id}` serves this body as the run's `result`." parameters: - name: id in: path required: true schema: type: string - *a45 requestBody: required: false content: application/json: schema: type: object properties: mode: type: string const: text text: type: string kind: type: string enum: - DOCTOR_REPORT - DISCHARGE_LETTER - LAB_RESULT - IMAGING - PRESCRIPTION - REFERRAL - INSURANCE - VACCINATION - SICK_NOTE - OTHER required: - mode - text responses: "200": description: The filing-metadata suggestion (drafts). content: application/json: schema: $ref: "#/components/schemas/DocumentSuggestEnvelope" "202": *a44 "401": *a1 "403": *a46 "422": *a4 "429": *a5 "503": *a47 /api/documents/inbound/{id}/summary: post: tags: - Documents summary: Summarise or transcribe a document description: "On-demand description of a stored document. `?mode=summary` (default) returns a short plain-language summary of WHAT the document is; it remains transient unless `persist=true` fills the empty stored-summary slot, while `persist=true&replace=true` explicitly replaces an existing summary. `?mode=text` returns transient raw transcribed text. Neither result reaches coach memory, snapshots, structured stores, or the search index. The summary is descriptive only and never a diagnosis. Same VISION (empty body) / TEXT (`application/json` `{ mode: \"text\", text }`, opt-in local OCR) dispatch as extract. Answers under the `documentAi` capability (403 with the capability envelope when it is closed), then rate / budget gated. 422 `documents.inbound.providerUnsupported` with no provider. With `Prefer: respond-async` (v1.40) the read runs in the background: after the same quick refusals the route answers 202 with a run id, and `GET /api/ai-runs/{id}` serves this body as the run's `result`. A second request while a read with the same `mode`, `persist` and `replace` is queued or running answers with that run. With `persist=true` the document's `summaryState` is PENDING while the run is queued or running (unless a summary is already stored) and becomes UNAVAILABLE when the run fails. `mode=text` over posted text calls no model and is answered synchronously either way (no `Preference-Applied`)." parameters: - name: id in: path required: true schema: type: string - name: mode in: query required: false schema: type: string enum: - summary - text default: summary description: "`summary` (plain-language description) or `text` (raw)." - name: persist in: query required: false schema: type: boolean default: false description: "For summary mode only: persist into an empty stored-summary slot." - name: replace in: query required: false schema: type: boolean default: false description: With `persist=true`, explicitly replace an existing stored summary. - *a45 requestBody: required: false content: application/json: schema: type: object properties: mode: type: string const: text text: type: string kind: type: string enum: - DOCTOR_REPORT - DISCHARGE_LETTER - LAB_RESULT - IMAGING - PRESCRIPTION - REFERRAL - INSURANCE - VACCINATION - SICK_NOTE - OTHER required: - mode - text responses: "200": description: The summary and its persistence outcome, or transient extracted text. content: application/json: schema: $ref: "#/components/schemas/DocumentSummaryEnvelope" "202": *a44 "401": *a1 "403": *a46 "422": *a4 "429": *a5 "503": *a47 /api/documents/inbound/{id}/index: post: tags: - Documents summary: Build / refresh the content-search index description: "Populates or refreshes one document's content-search index so search matches INSIDE its body. VISION (empty body) decrypts the stored original and runs one provider transcription (answers under the `documentAi` capability — 403 with the capability envelope when it is closed, including a missing `ai_extraction` / `ai_full` receipt for a provider that leaves the machine — then rate / budget gated); TEXT (`application/json` `{ mode: \"text\", text }`, opt-in local OCR) indexes browser-OCR'd text with no provider egress and stays available with AI off, so search keeps working. Persists ONLY AES-256-GCM ciphertext of the text plus opaque HMAC token hashes — no plaintext body, no plaintext token. Idempotent; re-indexing overwrites in place. A successful index continues into the same lab auto-staging the background worker performs (still-STORED lab-looking document with no facts, both modules on, provider + consent eligible); `labFactsStaged` reports how many facts were staged PENDING for review — confirmation stays the only write into Labs. Without `Prefer: respond-async` that staging runs inside the request and makes at most three reads (six model calls), like the extract route: what does not fit is left out and reported as `extractionIncomplete: true` on the document. With `Prefer: respond-async` (v1.40) the read runs in the background: after the same quick refusals the route answers 202 with a run id, a second request while a read of this document is queued or running answers with that run, and `GET /api/ai-runs/{id}` serves this body as the run's `result`. Text mode honours the preference too, because the lab staging it continues into is model work." parameters: - name: id in: path required: true schema: type: string - *a45 requestBody: required: false content: application/json: schema: type: object properties: mode: type: string const: text text: type: string kind: type: string enum: - DOCTOR_REPORT - DISCHARGE_LETTER - LAB_RESULT - IMAGING - PRESCRIPTION - REFERRAL - INSURANCE - VACCINATION - SICK_NOTE - OTHER required: - mode - text responses: "200": description: The document was indexed. content: application/json: schema: $ref: "#/components/schemas/DocumentIndexEnvelope" "202": *a44 "401": *a1 "403": *a46 "422": *a4 "429": *a5 "503": *a47 /api/documents/inbound/{id}/chat: post: tags: - Documents summary: Chat about a document (streaming reply) description: "v1.27.33 — sends a user turn and streams a grounded prose reply about ONE stored document as Server-Sent Events (`text/event-stream`, not JSON: one `data: \\n\\n` frame per event). Frame `type` is `token` (a chunk of reply text), `done` (`{ type, conversationId, messageId, usage? }`), or `error` (`{ type, code, message }`); HTTP status is 200 even for a provider/refusal outcome (dispatch on the `error` frame). The reply is grounded ONLY in the document's indexed text — NO health snapshot, NO tools, NO other document. The document text is fenced as untrusted DATA (prompt-injection defence); the inbound message + every prior turn are injection-screened and the reply is dose/risk-screened + numerically grounded against the document's own figures. Available only for a content-indexed document (422 `documents.inbound.notIndexed` otherwise). Answers under the `documentAi` capability (403 with the capability envelope when it is closed; `consent.ai.required` for a provider that leaves the machine without an `ai_extraction` / `ai_full` receipt), budget- and rate-limited; no provider is the `documents.chat.provider.none` error frame. Reading the history (GET) stays available whatever the capability says. Omitting `conversationId` starts a new thread. Renders as plain text on the client (no markdown). Auth via cookie or Bearer." parameters: - name: id in: path required: true schema: type: string requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/DocumentChatRequest" responses: "200": description: Server-Sent Events stream of `token` / `done` / `error` frames. content: text/event-stream: schema: type: string description: "SSE frames: `data: \\n\\n`. See the operation description for the per-`type` frame shapes." "401": *a1 "403": *a46 "422": *a4 "429": *a5 "503": *a47 get: tags: - Documents summary: Read a document's chat history description: v1.27.33 — with `conversationId`, returns that one thread's messages (decrypted server-side, oldest-first, document- and owner-scoped); without it, the paginated list of the document's chat threads for the sheet's rail. A foreign / unknown id maps to 404 (never 403). Auth via cookie or Bearer. parameters: - name: id in: path required: true schema: type: string - name: conversationId in: query required: false schema: type: string description: When set, returns that thread's messages. - name: cursor in: query required: false schema: type: string description: List pagination cursor (id of the last item). - name: limit in: query required: false schema: type: integer minimum: 1 maximum: 50 responses: "200": description: Either the conversation detail (with `messages`) or the paginated conversation list for the document. content: application/json: schema: $ref: "#/components/schemas/DocumentChatHistoryEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/documents/inbound/reindex: post: tags: - Documents summary: Index all documents for content search description: Enqueues a background job that content-indexes the caller's not-yet-indexed documents (one provider transcription each, bounded + resumable). Gated on the module, the `documentAi` capability (403 with the capability envelope), a configured vision provider (422 `documents.inbound.providerUnsupported` otherwise), and an `ai_extraction` / `ai_full` consent receipt when that provider leaves the machine (403 `consent.ai.required`). Returns immediately; the work runs off-request on the queue, which re-checks the capability before every read. responses: "200": description: How many live documents the enqueued backfill still has to index (0 when everything is indexed or no job was created). content: application/json: schema: $ref: "#/components/schemas/DocumentReindexEnvelope" "401": *a1 "403": *a46 "422": *a4 "429": *a5 "503": *a47 /api/documents/inbound/{id}/facts/{factId}: patch: tags: - Documents summary: Edit a staged fact before approval description: Corrects OCR / units / dates / codes on a pending fact. Clears `needsReview` (the values become user-asserted). The fact's resource type cannot change. parameters: - name: id in: path required: true schema: type: string - name: factId in: path required: true schema: type: string requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/InboundFactEditRequest" responses: "200": description: The updated fact. content: application/json: schema: $ref: "#/components/schemas/ExtractedFactEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/documents/inbound/{id}/confirm: post: tags: - Documents summary: Confirm the review decisions description: The ONLY write path out of staging. Commits the user's approvals into the structured stores (labs / conditions / medications) and discards rejections. A low-confidence fact that was never edited cannot be approved (fail-closed). Idempotent (Idempotency-Key). Audits as `documents.inbound.confirm`. parameters: - *a6 - name: id in: path required: true schema: type: string requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/InboundConfirmRequest" responses: "200": description: Per-decision outcome. content: application/json: schema: $ref: "#/components/schemas/InboundConfirmEnvelope" "401": *a1 "403": *a9 "409": *a7 "422": *a4 "429": *a5 /api/custom-metrics: get: tags: - Custom metrics summary: List the caller's custom metrics description: Returns every custom metric the caller has defined, name-ordered, each with its latest logged value and total value count. responses: "200": description: Custom-metric catalog. content: application/json: schema: $ref: "#/components/schemas/ListCustomMetricsEnvelope" "401": *a1 "403": *a15 "422": *a4 "429": *a5 post: tags: - Custom metrics summary: Define a custom metric description: Creates a user-scoped custom metric. Re-creating a name that was previously soft-deleted revives that definition. Audits as `customMetric.create`. parameters: - *a6 requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CreateCustomMetricRequest" responses: "201": description: Created custom metric. content: application/json: schema: $ref: "#/components/schemas/CreateCustomMetricResponse" "401": *a1 "403": *a9 "409": &a49 description: A custom metric with this name already exists for the caller. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/custom-metrics/{id}: get: tags: - Custom metrics summary: Fetch a single custom metric description: Returns the custom-metric definition. Cross-user rows surface as 404. parameters: - in: path name: id schema: type: string required: true responses: "200": description: Custom-metric detail. content: application/json: schema: $ref: "#/components/schemas/GetCustomMetricResponse" "401": *a1 "403": *a15 "404": &a48 description: Custom metric not found (or owned by another user). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 patch: tags: - Custom metrics summary: Edit a custom metric description: Partial edit; omitted fields are untouched, an explicit null clears a target bound / decimals / description. Audits as `customMetric.update`. parameters: - in: path name: id schema: type: string required: true requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpdateCustomMetricRequest" responses: "200": description: Updated custom metric. content: application/json: schema: $ref: "#/components/schemas/UpdateCustomMetricResponse" "401": *a1 "403": *a9 "404": *a48 "409": *a49 "422": *a4 "429": *a5 delete: tags: - Custom metrics summary: Delete a custom metric description: Soft-deletes the metric (stamps `deletedAt`); its logged values are retained and re-creating the name revives it. Audits as `customMetric.delete`. parameters: - in: path name: id schema: type: string required: true responses: "200": description: Deletion succeeded. content: application/json: schema: $ref: "#/components/schemas/DeleteCustomMetricResponse" "401": *a1 "403": *a9 "404": *a48 "422": *a4 "429": *a5 /api/custom-metrics/{id}/entries: get: tags: - Custom metrics summary: List a custom metric's logged values description: Offset-paginated value feed for one custom metric (the chart + history read). Cross-user / unknown metric ids surface as 404. parameters: - in: path name: id schema: type: string required: true - in: query name: limit schema: type: integer minimum: 1 maximum: 500 - in: query name: offset schema: type: integer minimum: 0 maximum: 9007199254740991 - in: query name: sortDir schema: type: string enum: - asc - desc responses: "200": description: Logged values. content: application/json: schema: $ref: "#/components/schemas/ListCustomMetricEntriesEnvelope" "401": *a1 "403": *a15 "404": *a48 "422": *a4 "429": *a5 post: tags: - Custom metrics summary: Log a value description: Records a value against the custom metric, snapshotting the metric's unit. Audits as `customMetricEntry.create`. parameters: - in: path name: id schema: type: string required: true - *a6 requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CreateCustomMetricEntryRequest" responses: "201": description: Created value. content: application/json: schema: $ref: "#/components/schemas/CreateCustomMetricEntryResponse" "401": *a1 "403": *a9 "404": *a48 "409": *a7 "422": *a4 "429": *a16 /api/custom-metrics/{id}/entries/{entryId}: patch: tags: - Custom metrics summary: Edit a logged value description: Partial edit of a value; omitted fields untouched, explicit null clears `note`. Audits as `customMetricEntry.update`. parameters: - in: path name: id schema: type: string required: true - in: path name: entryId schema: type: string required: true requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpdateCustomMetricEntryRequest" responses: "200": description: Updated value. content: application/json: schema: $ref: "#/components/schemas/UpdateCustomMetricEntryResponse" "401": *a1 "403": *a9 "404": &a50 description: Custom metric entry not found (or owned by another user). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 delete: tags: - Custom metrics summary: Delete a logged value description: Tombstones the value (soft delete, v1.37.20); the sibling entries/restore endpoint clears the tombstone. Audits as `customMetricEntry.delete`. parameters: - in: path name: id schema: type: string required: true - in: path name: entryId schema: type: string required: true responses: "200": description: Deletion succeeded. content: application/json: schema: $ref: "#/components/schemas/DeleteCustomMetricEntryResponse" "401": *a1 "403": *a9 "404": *a50 "422": *a4 "429": *a5 /api/custom-metrics/{id}/entries/restore: post: tags: - Custom metrics summary: Restore tombstoned values (v1.37.20) description: Clears deletedAt on every owned, currently-tombstoned entry of this metric named in ids (1..200). A foreign, live or mismatched-parent id is a silent no-op, never an existence leak. Backs the delete toast's Undo. Audits as `customMetricEntry.restore`. parameters: - in: path name: id schema: type: string required: true requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/RestoreCustomMetricEntriesRequest" responses: "200": description: Count of entries restored. content: application/json: schema: $ref: "#/components/schemas/RestoreCustomMetricEntriesResponse" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/onboarding/answers: patch: tags: - Onboarding summary: Save one answer of the needs-based setup flow description: >- Persists one step of the setup questionnaire the moment it is answered, so leaving and returning resumes at the same step. The body names the step and carries either its answer or `status: "skipped"`; the two can never ride together. Idempotent. The stored state is computed by a pure function of the row and the body, so sending the same step twice writes the same value and the second write changes nothing a reader can see — including the first-result completion instant, which is kept from the existing row rather than re-taken. There is no ordering contract: steps may be answered, re-answered and passed in any order, which is what makes a back button work. The `units` step is the one answer with a consequence outside this state: it writes the record's `glucoseUnit` and `unitPreference`, which every display surface already reads, and records the answer beside them. Cookie or wildcard Bearer, and the CALLER's own record only: a request made while the session is acting on somebody else's record is refused with 403 `sharing.not_permitted` at every grant level, so a delegate can never write another record's setup. Rate-limited to 120 writes per 10 minutes per account. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/OnboardingAnswerRequest" responses: "200": description: The full setup state after the write. content: application/json: schema: $ref: "#/components/schemas/OnboardingStateEnvelope" "401": *a1 "403": *a9 "413": description: Body exceeds 16 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "415": description: "`Content-Type` is not `application/json`." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": description: The body did not validate. `meta.errorCode` = `onboarding.answers.invalid`, with every issue on the wire. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": description: More than 120 answer writes in 10 minutes. `meta.errorCode` = `onboarding.answers.rateLimited`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/onboarding/restart: post: tags: - Onboarding summary: Ask the setup questions again description: >- "Set up again", from Settings. Puts the nine steps back to `pending`, clears the flow's own completion stamp, and clears the derivation marker so the next `POST /api/onboarding/complete` may derive a module map again. It writes no module state at all: the ordering-versus-removal decision says a re-run never turns off a module somebody turned on by hand, and the protection against the second derivation lives in the merge rather than here. It also keeps the answers, as the prefill for the re-run, keeps a first result that really happened, and leaves the account payload's `onboardingCompletedAt` alone so nobody is pushed back through the first-run redirect. Cookie or wildcard Bearer, own record only — same refusal under a switch as the answers route. Rate-limited to 10 restarts per 10 minutes per account. requestBody: required: false content: application/json: schema: $ref: "#/components/schemas/OnboardingRestartRequest" responses: "200": description: The reset setup state. content: application/json: schema: $ref: "#/components/schemas/OnboardingStateEnvelope" "401": *a1 "403": *a9 "413": description: Body exceeds 4 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "415": description: "`Content-Type` is not `application/json`." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": description: The body carried a field. `meta.errorCode` = `onboarding.restart.invalid`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": description: More than 10 restarts in 10 minutes. `meta.errorCode` = `onboarding.restart.rateLimited`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/onboarding/complete: post: tags: - Onboarding summary: Stamp onboarding as complete, and derive the module map description: >- Marks onboarding finished and saves whatever profile fields came with it, then clears the proxy-readable pending cookie so the next navigation stops redirecting to `/onboarding`. v1.39 — this is the needs-based flow's confirm endpoint. For a record that FINISHED the questions — every one of them answered or deliberately passed — it derives that record's module map from the answers and applies it, ONCE: the derivation marker is what stops a second confirm re-applying the questionnaire over decisions taken in Settings since, and `POST /api/onboarding/restart` clears the marker when the person asks for the questions again. The merge is one-directional — a module the answers name is switched on, a module they do not name is switched off only where the record does not already carry an explicit on and holds no data in that domain. The `cycle` key is not written here at all: it delegates to the cycle profile, which this route sets to `true` when the cycle area was chosen and never to `false`. In the same derivation the dashboard order is seeded from the answers — the tiles of the chosen areas (and the medication tile for a schedule) move to the front and are made visible — but only while the layout column is still unset, so a person who already arranged their tiles is never clobbered. A half-answered flow derives nothing and is not stamped as complete. The response carries `onboarding` beside `completed`; a caller that never entered the needs flow gets the fixed acknowledgement alone — it stamps the completion whatever the answers say, re-stamps on every call rather than refusing a second one, and enforces no rate limit of its own. The legacy half writes no audit row; the needs half writes one (`user.modules.update`) when it derives, because that is the same column the dedicated modules route audits. Cookie or wildcard Bearer; `userId` is never read from the body. `managedRecordId` — "someone I look after". The answers were given for the profile the confirm screen created, so the derivation and the seed land on THAT record through the same record-keyed write the guardian's modules route uses, its setup row is written as finished, and the caller's own record is stamped complete and latched with its map untouched. A managed record the caller does not actively guard, a record that is not a managed profile, or an id that does not exist all answer 404 with nothing stamped; a body naming a record when Q1 was not `someone-else` is a 422. With Q1 = `someone-else` and NO record id, the flow completes and latches without deriving anything onto anybody: there is no record the answers describe yet, and they never describe the guardian's. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/OnboardingCompleteRequest" responses: "200": description: Onboarding stamped complete. `completed` is the fixed acknowledgement — it does not echo the profile fields that were saved. `onboarding` is present only for a record that entered the needs-based flow, and carries the state after the derivation. content: application/json: 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 stamped." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "413": description: Body exceeds 64 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "415": description: "`Content-Type` is not `application/json`." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": description: The body did not validate (`meta.errorCode` = `onboarding.complete.invalid`; single-message, not the multi-issue envelope), or `managedRecordId` was sent for answers that were not given for somebody else's record — Q1 was `me` or `both` (`meta.errorCode` = `onboarding.complete.recordTargetMismatch`; nothing stamped). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 /api/onboarding/disclaimer: post: tags: - Onboarding summary: Acknowledge the one-time medical disclaimer description: "Stamps the user's medical-disclaimer acknowledgment. Idempotent: a repeat acknowledgment of the same version refreshes the timestamp. The body version is a freshness signal so a stale shell cannot record copy it never rendered; the server persists its own canonical version." requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/DisclaimerAckRequest" responses: "200": description: Disclaimer acknowledged. content: application/json: schema: $ref: "#/components/schemas/DisclaimerAckEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/onboarding/tour: post: tags: - Onboarding summary: Update module-tour completion + resume point description: Persists the module-tour state. The client posts a fire-and-forget `progress` checkpoint on each step so a reload resumes at the right module, and a terminal `completed:true` with `outcome` when the tour ends. `completed:false` is a replay reset that also clears the resume point. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/TourUpdateRequest" responses: "200": description: Tour state updated. content: application/json: schema: $ref: "#/components/schemas/TourUpdateEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/nutrients/catalog: get: tags: - Nutrients summary: The nutrient catalogue (v1.43) description: Every nutrient code with its canonical unit, label in the account language, input units and upper intake level. Behind the opt-in `nutrients` module (403 `module.disabled` when off). responses: "200": description: The catalogue. content: application/json: schema: $ref: "#/components/schemas/NutrientCatalogEnvelope" "401": *a1 "403": &a51 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. 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: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/nutrients/batch: post: tags: - Nutrients summary: Ingest micronutrient day totals (v1.28) description: >- Bulk upsert of supplement-style daily totals synced from HealthKit — vitamins, minerals, water, caffeine. Day totals only: compute HKStatisticsCollectionQuery cumulative sums day-anchored in the user's current IANA timezone; `day` is yyyy-MM-dd in that timezone (the same rule as the `stats:` day keys). Never post raw samples. Upsert key is (day, nutrient): a re-post replaces the stored total and reports `updated`. Post the current + previous local day per sync; backfill 30 days on first enable. Max 500 entries; Idempotency-Key supported; rate limit 60/min. Per-entry skips (`unit_mismatch` | `value_out_of_range` | `day_invalid`) are terminal — log and drop, do not retry the entry. `upsert_failed` is not one of them: it is a server-side write failure for the entry's whole write group, nothing was stored, and the entry should be sent again on a later sync; a response carrying it is `Cache-Control: no-store` and is not replayed for its `Idempotency-Key`. Behind the opt-in `nutrients` module (403 `module.disabled` when off). Query HealthKit with exactly the listed unit per code (`ug` = microgram; do not convert to IU): | code | HealthKit identifier | unit | | --- | --- | --- | | vitamin_a | HKQuantityTypeIdentifierDietaryVitaminA | ug | | thiamin | HKQuantityTypeIdentifierDietaryThiamin | mg | | riboflavin | HKQuantityTypeIdentifierDietaryRiboflavin | mg | | niacin | HKQuantityTypeIdentifierDietaryNiacin | mg | | pantothenic_acid | HKQuantityTypeIdentifierDietaryPantothenicAcid | mg | | vitamin_b6 | HKQuantityTypeIdentifierDietaryVitaminB6 | mg | | biotin | HKQuantityTypeIdentifierDietaryBiotin | ug | | folate | HKQuantityTypeIdentifierDietaryFolate | ug | | vitamin_b12 | HKQuantityTypeIdentifierDietaryVitaminB12 | ug | | vitamin_c | HKQuantityTypeIdentifierDietaryVitaminC | mg | | vitamin_d | HKQuantityTypeIdentifierDietaryVitaminD | ug | | vitamin_e | HKQuantityTypeIdentifierDietaryVitaminE | mg | | vitamin_k | HKQuantityTypeIdentifierDietaryVitaminK | ug | | calcium | HKQuantityTypeIdentifierDietaryCalcium | mg | | iron | HKQuantityTypeIdentifierDietaryIron | mg | | magnesium | HKQuantityTypeIdentifierDietaryMagnesium | mg | | phosphorus | HKQuantityTypeIdentifierDietaryPhosphorus | mg | | zinc | HKQuantityTypeIdentifierDietaryZinc | mg | | copper | HKQuantityTypeIdentifierDietaryCopper | mg | | manganese | HKQuantityTypeIdentifierDietaryManganese | mg | | selenium | HKQuantityTypeIdentifierDietarySelenium | ug | | chromium | HKQuantityTypeIdentifierDietaryChromium | ug | | molybdenum | HKQuantityTypeIdentifierDietaryMolybdenum | ug | | iodine | HKQuantityTypeIdentifierDietaryIodine | ug | | water | HKQuantityTypeIdentifierDietaryWater | ml | | caffeine | HKQuantityTypeIdentifierDietaryCaffeine | mg | parameters: - *a6 requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/NutrientBatchRequest" responses: "200": description: Batch processed; per-entry statuses inserted | updated | skipped. content: application/json: schema: $ref: "#/components/schemas/NutrientBatchEnvelope" "401": *a1 "403": 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: $ref: "#/components/schemas/ErrorEnvelope" "409": *a7 "422": *a4 "429": *a5 /api/nutrients: get: tags: - Nutrients summary: Synced-nutrient window summary (v1.28) description: "Per-nutrient summary of the last `days` days (default 14): latest synced day + total and the days-with-data count, in catalog order. Read-only; feeds the settings card. Behind the opt-in `nutrients` module." parameters: - in: query name: days schema: default: 14 type: integer minimum: 1 maximum: 365 responses: "200": description: The window summary. content: application/json: schema: $ref: "#/components/schemas/NutrientOverviewEnvelope" "401": *a1 "403": *a51 "422": *a4 "429": *a5 /api/nutrients/daily: get: tags: - Nutrients summary: One nutrient's day-bucketed series (v1.29) description: Dense per-day series for one catalog code over the last `days` days (default 30) — one entry per calendar day, 0 for a day with no logged data, summed ACROSS SOURCES (a day may carry both an APPLE_HEALTH row and a MANUAL row since migration 0249). Feeds the `/insights/nutrients` hydration + caffeine charts. Also returns the EFSA reference resolved against the caller's profile sex — `null` when the profile has no sex on file (never guessed). Behind the opt-in `nutrients` module. parameters: - in: query name: nutrient schema: $ref: "#/components/schemas/NutrientCode" required: true description: Closed nutrient catalog code — 24 HealthKit Dietary vitamin/mineral types plus water and caffeine, and (v1.43, responses only) `dha` and `epa`, which only a supplement's composition produces. Energy, macros and sodium/potassium are out of scope. Decode unknown values leniently. - in: query name: days schema: default: 30 type: integer minimum: 1 maximum: 90 responses: "200": description: The day-bucketed series + resolved reference. content: application/json: schema: $ref: "#/components/schemas/NutrientDailySeriesEnvelope" "401": *a1 "403": *a51 "422": *a4 "429": *a5 /api/nutrients/water: post: tags: - Nutrients summary: Manual water quick-add (v1.29) description: "Writes ONLY the `source=\"MANUAL\"` row for `(day, \"water\")` — never the `source=\"APPLE_HEALTH\"` row the batch endpoint owns, so a manual entry and an Apple sync coexist instead of one clobbering the other. `mode: \"add\"` increments the manual day total (quick-add chips); `mode: \"set\"` overwrites it (the \"edit today's total\" undo path — there is no per-entry ledger). `day` defaults to the caller's current local day when omitted. Idempotency-Key supported; rate limit 60/min. Behind the opt-in `nutrients` module." parameters: - *a6 requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/NutrientWaterWriteRequest" responses: "200": description: The MANUAL water row after the write. content: application/json: schema: $ref: "#/components/schemas/NutrientWaterWriteEnvelope" "401": *a1 "403": *a51 "409": *a7 "422": *a4 "429": *a5 /api/integrations/healthkit: get: tags: - Integrations summary: Read the HealthKit integration config description: Returns the resolved per-metric HealthKit config — the default metric set merged with the user's stored overrides — plus the last HealthKit sync instant. Auth via cookie or Bearer. responses: "200": description: Resolved HealthKit config. content: application/json: schema: $ref: "#/components/schemas/HealthKitConfigEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 patch: tags: - Integrations summary: Update the HealthKit integration config description: Merges the supplied entries into the stored config by `id` (unknown ids are ignored) and returns the resolved config (defaults merged) so the client always sees a complete metric list. `lastSyncedAt` is null on the echo. Auth via cookie or Bearer. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/HealthKitConfigPatchRequest" responses: "200": description: Updated + resolved HealthKit config. content: application/json: schema: $ref: "#/components/schemas/HealthKitConfigPatchEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/oura/sync: post: tags: - Integrations summary: Trigger an on-demand Oura sync description: "Runs the same sync entry point the hourly cron uses. No body: the window is derived from the newest row already held, not a client-supplied lookback. Rate-limited 5 requests / 60s per user." responses: "200": description: Sync run complete. content: application/json: schema: $ref: "#/components/schemas/OuraSyncEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 "502": description: The sync failed upstream. The classified failure is already recorded on the `oura` ledger; the response body carries no upstream detail. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/polar/sync: post: tags: - Integrations summary: Trigger an on-demand Polar sync description: "Runs both Polar legs (vitals then workouts) through the same entry point the hourly poll uses, each with its own failure recording. No body: there is no full-history arm to call. Rate-limited 5 requests / 60s per user." responses: "200": description: Sync run complete. content: application/json: schema: $ref: "#/components/schemas/PolarSyncEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 "502": description: The sync failed upstream. The classified failure is already recorded on the `polar` ledger; the response body carries no upstream detail. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/strava/sync: post: tags: - Integrations summary: Trigger an on-demand Strava sync description: "Incremental from the stored Strava cursor (`stravaLastActivityAt` minus an overlap) — a manual run picks up wherever the last one stopped. No body and no full-history arm; deep history belongs to the connect-time backfill queue. `failed` is always `false` on a 200: the underlying walk rethrows on the first row error rather than settling partially, so reaching a 200 means every fetched activity was written. Rate-limited 5 requests / 60s per user." responses: "200": description: Sync run complete. content: application/json: schema: $ref: "#/components/schemas/StravaSyncEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 "502": description: The sync failed upstream. The classified failure is already recorded on the `strava` ledger; the response body carries no upstream detail. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/nightscout/sync: post: tags: - Integrations summary: Trigger an on-demand Nightscout sync description: "Pulls the recent SGV window from the configured Nightscout instance. No body: there is no full-history arm to call. The error body is always a fixed message, never the caught error's own — a Nightscout base URL carries its API token as a query parameter, so an upstream message could leak it. Rate-limited 5 requests / 60s per user." responses: "200": description: Sync run complete. content: application/json: 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. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 "502": description: The sync failed upstream. The classified failure is already recorded on the `nightscout` ledger; the response body carries no upstream detail. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/google-health/sync/status: get: tags: - Integrations summary: Read the caller's Google Health sync progress description: "Returns only the authenticated user's own bounded current/most-recent sync run; there is no way to address another run or another connection's progress by query parameter (query parameters are ignored by construction). Every field is re-clamped and re-checked against its closed enum on read, so a malformed or legacy stored blob degrades to safe defaults (e.g. `state: \"failed\"`, `resource: \"unknown\"`) rather than reaching the client verbatim. Auth via cookie or Bearer." responses: "200": description: "The current/most-recent run, or `data: null` when none has ever run." content: application/json: schema: $ref: "#/components/schemas/GoogleHealthSyncStatusEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/nightscout/connect: post: tags: - Integrations summary: Connect (or update) the caller's Nightscout instance description: Validates the URL and token with a live single-entry probe BEFORE storing anything, so a wrong token, an unreachable instance or an unapproved private origin surfaces here rather than on the first cron tick. On success the URL and token are encrypted at rest, the `nightscout` ledger is reset to connected, and a first sync is enqueued best-effort. Re-sending replaces the stored values; there is no partial update. Body capped at 16 KiB, rate-limited 10 requests / 60 s per user. Auth via cookie or Bearer. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/NightscoutConnectRequest" responses: "200": description: The instance answered the probe and was stored. content: application/json: schema: $ref: "#/components/schemas/NightscoutConnectEnvelope" "400": description: Body is not parseable JSON. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 "403": *a9 "413": description: Body exceeds 16 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": description: "The URL failed validation, the origin is private and the operator has not approved it (`meta.errorCode` = `private_origin_not_approved` or `invalid_origin`), Nightscout rejected the token, or the instance could not be reached. Nothing was written. The message is fixed per class and never the upstream error's own: a Nightscout base URL carries its API token as a query parameter." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": description: More than 10 connection attempts in 60 s from this user. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/nightscout/status: get: tags: - Integrations summary: Read the caller's Nightscout connection status description: Returns the connect marker plus the shared `nightscout` ledger snapshot and the server-resolved liveness verdict, so the Settings card and the iOS client paint the same pill from one read. The stored API token is never returned — `hasToken` reports its presence and nothing more. Auth via cookie or Bearer. responses: "200": description: Connection status. content: application/json: schema: $ref: "#/components/schemas/NightscoutStatusEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/nightscout/test: post: tags: - Integrations summary: Probe the configured Nightscout instance description: "Pulls a single SGV entry through the same client the sync uses, to re-validate the stored URL and token without waiting for a sync window. No body. Nothing is written: this neither ingests the entry nor touches the ledger. Rate-limited 5 requests / 60 s per user. Auth via cookie or Bearer." responses: "200": description: The probe reached the instance. content: application/json: schema: $ref: "#/components/schemas/NightscoutTestEnvelope" "401": *a1 "403": *a9 "422": description: No Nightscout instance is configured for this user. `meta.errorCode` = `not_configured`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": description: More than 5 probes in 60 s from this user. `meta.errorCode` = `rate_limited_self` — this is the local limiter, not Nightscout's. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "502": description: "The probe did not complete. `meta.errorCode` names the class: `credentials_rejected` (401/403 from Nightscout), `rate_limited` (429 from Nightscout — distinct from this endpoint's own 429), `upstream_error` (5xx), `timeout` (no answer within 5 s), `url_not_public` (the stored origin resolves to a private host and the operator has not approved it), `connection_failed` (everything else). The message is fixed per class and never the caught error's own: a Nightscout base URL carries its API token as a query parameter, so an upstream message could leak it." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/nightscout/disconnect: post: tags: - Integrations summary: Disconnect the caller's Nightscout instance description: Clears the stored URL, token and private-host approval, and parks the `nightscout` ledger at `disconnected`. Glucose rows already ingested are left in place — this stops future syncs, it does not delete history. No body. Rate-limited 20 requests / 60 s per user. Auth via cookie or Bearer. responses: "200": description: The connection was cleared. content: application/json: 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." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/whoop/credentials: get: tags: - Integrations summary: Check whether WHOOP BYO-key credentials are stored description: Presence only. There is no read path for the stored client id or secret — the response carries a single boolean. Auth via cookie or Bearer. responses: "200": description: Credential presence. content: application/json: schema: $ref: "#/components/schemas/WhoopCredentialsStatusEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 put: tags: - Integrations summary: Store the caller's WHOOP BYO-key credentials description: Replaces both values; there is no partial update. Both are encrypted at rest and never read back. Body capped at 64 KiB. Auth via cookie or Bearer. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/WhoopCredentialsRequest" responses: "200": description: Credentials stored. content: application/json: schema: $ref: "#/components/schemas/WhoopCredentialsUpdateEnvelope" "400": description: Body is not parseable JSON. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 "403": *a9 "413": description: Body exceeds 64 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "415": description: Content-Type is not `application/json`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 delete: tags: - Integrations summary: Delete the caller's WHOOP credentials and connection description: "Deletes the connection row (with its encrypted OAuth tokens), clears both credential columns, audits the teardown and parks the `whoop` ledger at `disconnected`. Idempotent: a missing connection row is a benign no-op, so a repeat call still answers 200. Auth via cookie or Bearer." responses: "200": description: Credentials and connection removed. content: application/json: schema: $ref: "#/components/schemas/WhoopCredentialsDeleteEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/whoop/status: get: tags: - Integrations summary: Read the caller's WHOOP connection status description: Reports the BYO-key configuration marker plus the connection row's last sync, token expiry, granted scope and backfill progress. Read-only — token refresh happens lazily on the sync path, not here. Auth via cookie or Bearer. responses: "200": description: Connection status. content: application/json: schema: $ref: "#/components/schemas/WhoopStatusEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/whoop/disconnect: post: tags: - Integrations summary: Disconnect the caller's WHOOP account description: Deletes the connection row, audits the teardown and parks the `whoop` ledger at `disconnected`. There is no per-user unsubscribe to make — WHOOP webhook subscriptions are registered once per developer app, unlike Withings which subscribes per category. The BYO-key credentials are deliberately LEFT IN PLACE so a reconnect does not force the user to re-paste them; use the credentials DELETE to remove those too. No body. Auth via cookie or Bearer. responses: "200": description: The connection was removed. content: application/json: 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: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/withings/status: get: tags: - Integrations summary: Read the caller's Withings connection status description: "Reports the per-user credential marker plus the connection row's last sync, token expiry and granted scope. Not side-effect free: a token within 60 s of expiry is refreshed and the new pair re-persisted before the response is built, so the reported `tokenExpiresAt` may be newer than the stored one was at request time, and a permanently revoked grant parks the ledger at `error_reauth` during this read. Auth via cookie or Bearer." responses: "200": description: Connection status. content: application/json: schema: $ref: "#/components/schemas/WithingsStatusEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/withings/disconnect: post: tags: - Integrations summary: Disconnect the caller's Withings account description: Best-effort unsubscribes every webhook category, deletes the connection row, audits the teardown and parks the `withings` ledger at `disconnected`. A failing unsubscribe (expired token, category never subscribed) does not stop the teardown. Measurements already ingested are left in place. No body. Auth via cookie or Bearer. responses: "200": description: The connection was removed. content: application/json: 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." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/withings/credentials: get: tags: - Integrations summary: Check whether Withings BYO-key credentials are stored description: Presence only. Auth via cookie or Bearer. responses: "200": description: Credential presence. content: application/json: schema: $ref: "#/components/schemas/WithingsCredentialsStatusEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 put: tags: - Integrations summary: Store the caller's Withings BYO-key credentials description: Replaces both values; there is no partial update. Auth via cookie or Bearer. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/WithingsCredentialsRequest" responses: "200": description: Credentials stored. content: application/json: schema: $ref: "#/components/schemas/WithingsCredentialsUpdateEnvelope" "400": &a52 description: Body is not parseable JSON. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 "403": *a9 "413": &a53 description: Body exceeds 64 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "415": &a54 description: Content-Type is not `application/json`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": &a55 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: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 delete: tags: - Integrations summary: Delete the caller's Withings credentials and connection description: Deletes the connection row (with its encrypted OAuth tokens), clears both credential columns, audits the teardown and parks the `withings` ledger at `disconnected`. Idempotent — a missing connection row is a benign no-op, so a repeat call still answers 200. Auth via cookie or Bearer. responses: "200": description: Credentials and connection removed. content: application/json: schema: $ref: "#/components/schemas/WithingsCredentialsDeleteEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/withings/sync: post: tags: - Integrations summary: Trigger a Withings sync description: "Incremental by default; `{ fullSync: true }` walks full history. Two limiters apply, matching the Fitbit / Google Health siblings: 5 requests / 60 s for any run, and additionally 1 per hour for a full one. Auth via cookie or Bearer." requestBody: required: false content: application/json: schema: $ref: "#/components/schemas/SyncTriggerRequest" responses: "200": description: Sync run complete. content: application/json: schema: $ref: "#/components/schemas/WithingsSyncTriggerEnvelope" "400": description: Body is not parseable JSON. An ABSENT body is not this case — omit the body entirely for an incremental run. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 "403": *a9 "413": description: Body exceeds 64 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": description: "The body was present and failed the schema — `fullSync` was not a boolean. Nothing was synced. Note that this is a behaviour change: such a body used to be read as `fullSync: false` and answered 200." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": description: "Either limiter tripped: more than 5 syncs in 60 s, or a second full sync within the hour. `meta.errorCode` = `rate_limited_self` for both; the message distinguishes them." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/whoop/sync: post: tags: - Integrations summary: Trigger a WHOOP sync description: "Incremental by default; `{ fullSync: true }` walks full history across the four WHOOP resources. Two limiters apply, matching the Fitbit / Google Health siblings: 5 requests / 60 s for any run, and additionally 1 per hour for a full one. Auth via cookie or Bearer." requestBody: required: false content: application/json: schema: $ref: "#/components/schemas/SyncTriggerRequest" responses: "200": description: Sync run complete. content: application/json: schema: $ref: "#/components/schemas/WhoopSyncTriggerEnvelope" "400": description: Body is not parseable JSON. An ABSENT body is not this case — omit the body entirely for an incremental run. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 "403": *a9 "413": description: Body exceeds 64 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": description: "The body was present and failed the schema — `fullSync` was not a boolean. Nothing was synced. Note that this is a behaviour change: such a body used to be read as `fullSync: false` and answered 200." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": description: "Either limiter tripped: more than 5 syncs in 60 s, or a second full sync within the hour. `meta.errorCode` = `rate_limited_self` for both; the message distinguishes them." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/whoop/connect/ticket: post: tags: - Integrations summary: Mint a one-time WHOOP connect ticket description: "Bearer-capable by design: a native client with no browser session mints a ticket here, then opens `GET /api/whoop/connect?ticket=` in an in-app web session to run the WHOOP OAuth handshake. THE RESPONSE CARRIES THE RAW TICKET — only its hash is stored, so it cannot be retrieved again; it is short-lived and single-use. No body. Rate-limited 10 requests / 60 s per user. Auth via cookie or Bearer." responses: "200": description: Ticket minted. The `ticket` field is the only copy of the credential. content: application/json: schema: $ref: "#/components/schemas/WhoopConnectTicketEnvelope" "400": description: No WHOOP client id and secret are stored for this user, so a ticket could not complete the handshake. Store the credentials first. Note that this is 400, not the 422 the probe endpoints answer for the same condition. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": description: More than 10 ticket mints in 60 s from this user. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/fitbit/credentials: get: tags: - Integrations summary: Check whether Fitbit BYO-key credentials are stored description: Presence only. Auth via cookie or Bearer. responses: "200": description: Credential presence. content: application/json: schema: $ref: "#/components/schemas/FitbitCredentialsStatusEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 put: tags: - Integrations summary: Store the caller's Fitbit BYO-key credentials description: Replaces both values; there is no partial update. Auth via cookie or Bearer. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/FitbitCredentialsRequest" responses: "200": description: Credentials stored. content: application/json: schema: $ref: "#/components/schemas/FitbitCredentialsUpdateEnvelope" "400": *a52 "401": *a1 "403": *a9 "413": *a53 "415": *a54 "422": *a55 "429": *a5 delete: tags: - Integrations summary: Delete the caller's Fitbit credentials and connection description: Deletes the connection row, clears both credential columns, audits the teardown and parks the `fitbit` ledger at `disconnected`. Idempotent — a missing connection row is a benign no-op, so a repeat call still answers 200. Auth via cookie or Bearer. responses: "200": description: Credentials and connection removed. content: application/json: schema: $ref: "#/components/schemas/FitbitCredentialsDeleteEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/fitbit/status: get: tags: - Integrations summary: Read the caller's Fitbit connection status description: Reports the BYO-key marker plus the connection row's last sync, token expiry, granted scope and backfill progress. Pure read — no refresh, no ledger write. Auth via cookie or Bearer. responses: "200": description: Connection status. content: application/json: schema: $ref: "#/components/schemas/FitbitStatusEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/fitbit/disconnect: post: tags: - Integrations summary: Disconnect the caller's Fitbit account description: Deletes the connection row, audits the teardown and parks the `fitbit` ledger at `disconnected`. There is nothing to unsubscribe upstream — the integration polls. The BYO-key credentials are deliberately LEFT IN PLACE so a reconnect does not force the user to re-paste them; use the credentials DELETE to remove those too. No body. Auth via cookie or Bearer. responses: "200": description: The connection was removed. content: application/json: 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: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/fitbit/sync: post: tags: - Integrations summary: Trigger a Fitbit sync description: "Incremental by default; `{ fullSync: true }` walks full history across four paginated resources. Two limiters apply: 5 requests / 60 s for any run, and additionally 1 per hour for a full one, because the classic Web API budget is 150 requests per hour per user. Auth via cookie or Bearer." requestBody: required: false content: application/json: schema: $ref: "#/components/schemas/SyncTriggerRequest" responses: "200": description: Sync run complete. content: application/json: schema: $ref: "#/components/schemas/FitbitSyncTriggerEnvelope" "400": description: Body is not parseable JSON. An ABSENT body is not this case — omit the body entirely for an incremental run. content: application/json: 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. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "413": description: Body exceeds 64 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": description: "The body was present and failed the schema — `fullSync` was not a boolean. Nothing was synced. Note that this is a behaviour change: such a body used to be read as `fullSync: false` and answered 200." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": description: "Either limiter tripped: more than 5 syncs in 60 s, or a second full sync within the hour. `meta.errorCode` = `rate_limited_self` for both; the message distinguishes them." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/google-health/credentials: get: tags: - Integrations summary: Check whether Google Health BYO-key credentials are stored description: Presence only. Auth via cookie or Bearer. responses: "200": description: Credential presence. content: application/json: schema: $ref: "#/components/schemas/GoogleHealthCredentialsStatusEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 put: tags: - Integrations summary: Store the caller's Google Health BYO-key credentials description: Replaces both values; there is no partial update. Auth via cookie or Bearer. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/GoogleHealthCredentialsRequest" responses: "200": description: Credentials stored. content: application/json: schema: $ref: "#/components/schemas/GoogleHealthCredentialsUpdateEnvelope" "400": *a52 "401": *a1 "403": *a9 "413": *a53 "415": *a54 "422": *a55 "429": *a5 delete: tags: - Integrations summary: Delete the caller's Google Health credentials and connection description: Deletes the connection row, clears both credential columns, audits the teardown and parks the `google-health` ledger at `disconnected`. Idempotent — a missing connection row is a benign no-op, so a repeat call still answers 200. Auth via cookie or Bearer. responses: "200": description: Credentials and connection removed. content: application/json: schema: $ref: "#/components/schemas/GoogleHealthCredentialsDeleteEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/google-health/status: get: tags: - Integrations summary: Read the caller's Google Health connection status description: Reports the BYO-key marker plus the connection row's last sync, token expiry, granted scope, backfill progress and the `needsReauth` soft-disconnect flag. Pure read — no refresh, no ledger write. Auth via cookie or Bearer. responses: "200": description: Connection status. content: application/json: schema: $ref: "#/components/schemas/GoogleHealthStatusEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/google-health/disconnect: post: tags: - Integrations summary: Disconnect the caller's Google Health account description: Deletes the connection row, audits the teardown and parks the `google-health` ledger at `disconnected`. Deleting the encrypted token pair is the effective revocation; there is no subscription to tear down. The BYO-key credentials are deliberately LEFT IN PLACE. No body. Auth via cookie or Bearer. responses: "200": description: The connection was removed. content: application/json: 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." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/google-health/sync: post: tags: - Integrations summary: Trigger a Google Health sync description: 'Incremental by default; `{ fullSync: true }` walks full history across every data type, each capped at 1000 pages. Two limiters apply: 5 requests / 60 s for any run, and additionally 1 per hour for a full one. Unlike every sibling sync, this one can answer 502: a run that failed AND wrote nothing is an error, while a run that failed after writing some resources is a 200 with `outcome: "partial"` — the honest half of the result is not thrown away. Auth via cookie or Bearer.' requestBody: required: false content: application/json: schema: $ref: "#/components/schemas/SyncTriggerRequest" responses: "200": description: Sync run complete, wholly or partly. Carries the per-collection breakdown. content: application/json: schema: $ref: "#/components/schemas/GoogleHealthSyncTriggerEnvelope" "400": description: Body is not parseable JSON. An ABSENT body is not this case — omit the body entirely for an incremental run. content: application/json: 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` reports the running one. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "413": description: Body exceeds 64 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": description: "The body was present and failed the schema — `fullSync` was not a boolean. Nothing was synced. Note that this is a behaviour change: such a body used to be read as `fullSync: false` and answered 200." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": description: "Either limiter tripped: more than 5 syncs in 60 s, or a second full sync within the hour. `meta.errorCode` = `rate_limited_self`." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "502": description: The run failed and wrote nothing. A run that wrote something before failing answers 200 instead. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/oura/credentials: get: tags: - Integrations summary: Check whether Oura BYO-key credentials are stored description: "Presence only. `false` is not a misconfiguration: with no BYO pair the integration falls back to the operator's shared env app. Auth via cookie or Bearer." responses: "200": description: Credential presence. content: application/json: schema: $ref: "#/components/schemas/OuraCredentialsStatusEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 put: tags: - Integrations summary: Store the caller's Oura BYO-key credentials description: Replaces both values; there is no partial update. Auth via cookie or Bearer. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/OuraCredentialsRequest" responses: "200": description: Credentials stored. content: application/json: schema: $ref: "#/components/schemas/OuraCredentialsUpdateEnvelope" "400": *a52 "401": *a1 "403": *a9 "413": *a53 "415": *a54 "422": *a55 "429": *a5 delete: tags: - Integrations summary: Delete the caller's Oura credentials and connection description: Clears the stored access and refresh tokens as well as the BYO pair — a token minted against a deleted app is orphaned, so the grant goes with the keys. Always answers 200, and always audits the teardown and parks the `oura` ledger at `disconnected`, whether or not a token was present. Auth via cookie or Bearer. responses: "200": description: Credentials and any connection removed. content: application/json: schema: $ref: "#/components/schemas/OuraCredentialsDeleteEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/oura/status: get: tags: - Integrations summary: Read the caller's Oura connection status description: Reports whether usable credentials resolve at all, whether the user brought their own, and the ledger snapshot plus liveness verdict when a token is stored. Tokens and the client secret are never returned. Pure read. Auth via cookie or Bearer. responses: "200": description: Connection status. content: application/json: schema: $ref: "#/components/schemas/OuraStatusEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/oura/disconnect: post: tags: - Integrations summary: Disconnect the caller's Oura account description: Clears the stored access and refresh tokens, audits the teardown and parks the `oura` ledger at `disconnected`. The BYO-key credentials are left in place. Imported readings stay; a reconnect resumes sync. No body. Rate-limited 20 requests / 60 s per user. Auth via cookie or Bearer. responses: "200": description: The connection was cleared. content: application/json: 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: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/oura/test: post: tags: - Integrations summary: Probe the caller's Oura grant description: Fetches the personal-info record, the cheapest authenticated Oura call, to confirm the stored grant still works. No body, nothing written. Rate-limited 5 requests / 60 s per user. Auth via cookie or Bearer. responses: "200": description: The probe reached Oura. content: application/json: schema: $ref: "#/components/schemas/OuraTestEnvelope" "401": *a1 "403": *a9 "422": &a56 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": &a57 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": &a58 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` (everything else). The message is fixed per class; the upstream body never reaches the client." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/polar/credentials: get: tags: - Integrations summary: Check whether Polar BYO-key credentials are stored description: "Presence only. `false` is not a misconfiguration: with no BYO pair the integration falls back to the operator's shared env app. Auth via cookie or Bearer." responses: "200": description: Credential presence. content: application/json: schema: $ref: "#/components/schemas/PolarCredentialsStatusEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 put: tags: - Integrations summary: Store the caller's Polar BYO-key credentials description: Replaces both values; there is no partial update. Audits the write — the only credentials PUT in this group that does. Auth via cookie or Bearer. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/PolarCredentialsRequest" responses: "200": description: Credentials stored. content: application/json: schema: $ref: "#/components/schemas/PolarCredentialsUpdateEnvelope" "400": *a52 "401": *a1 "403": *a9 "413": *a53 "415": *a54 "422": *a55 "429": *a5 delete: tags: - Integrations summary: Delete the caller's Polar credentials and connection description: Clears the stored access token and member id as well as the BYO pair — a token minted under a deleted AccessLink app is orphaned, so the grant goes with the keys. Always answers 200, and always audits the teardown and parks the `polar` ledger at `disconnected`, whether or not a token was present. Auth via cookie or Bearer. responses: "200": description: Credentials and any connection removed. content: application/json: schema: $ref: "#/components/schemas/PolarCredentialsDeleteEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/polar/status: get: tags: - Integrations summary: Read the caller's Polar connection status description: Reports whether usable credentials resolve at all, whether the user brought their own, and the ledger snapshot plus liveness verdict when a token is stored. The access token, member id and client secret are never returned. Pure read. Auth via cookie or Bearer. responses: "200": description: Connection status. content: application/json: schema: $ref: "#/components/schemas/PolarStatusEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/polar/disconnect: post: tags: - Integrations summary: Disconnect the caller's Polar account description: Clears the stored token and member id, audits the teardown and parks the `polar` ledger at `disconnected`. The BYO-key credentials are left in place. Imported readings stay. No body. Rate-limited 20 requests / 60 s per user. Auth via cookie or Bearer. responses: "200": description: The connection was cleared. content: application/json: 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: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/polar/test: post: tags: - Integrations summary: Probe the caller's Polar grant description: Fetches the registered AccessLink user record, the cheapest authenticated Polar call, to confirm the stored grant still works. No body, nothing written. Rate-limited 5 requests / 60 s per user. Auth via cookie or Bearer. responses: "200": description: The probe reached Polar. content: application/json: schema: $ref: "#/components/schemas/PolarTestEnvelope" "401": *a1 "403": *a9 "422": *a56 "429": *a57 "502": *a58 /api/strava/credentials: get: tags: - Integrations summary: Check whether Strava BYO-key credentials are stored description: "Presence only. `false` is not a misconfiguration: with no BYO pair the integration falls back to the operator's shared env app. Auth via cookie or Bearer." responses: "200": description: Credential presence. content: application/json: schema: $ref: "#/components/schemas/StravaCredentialsStatusEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 put: tags: - Integrations summary: Store the caller's Strava BYO-key credentials description: Replaces both values; there is no partial update. Auth via cookie or Bearer. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/StravaCredentialsRequest" responses: "200": description: Credentials stored. content: application/json: schema: $ref: "#/components/schemas/StravaCredentialsUpdateEnvelope" "400": *a52 "401": *a1 "403": *a9 "413": *a53 "415": *a54 "422": *a55 "429": *a5 delete: tags: - Integrations summary: Delete the caller's Strava credentials and connection description: Clears the stored access token, refresh token and athlete id as well as the BYO pair. Always answers 200, and always audits the teardown and parks the `strava` ledger at `disconnected`, whether or not a token was present. Note that this path does NOT deauthorize at Strava — only `POST /api/strava/disconnect` does, so removing the credentials here leaves the app authorised on the user's Strava account. Auth via cookie or Bearer. responses: "200": description: Credentials and any connection removed. content: application/json: schema: $ref: "#/components/schemas/StravaCredentialsDeleteEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/strava/status: get: tags: - Integrations summary: Read the caller's Strava connection status description: Reports whether usable credentials resolve at all, whether the user brought their own, and the ledger snapshot plus liveness verdict when a token is stored. Tokens and the client secret are never returned. Pure read. Auth via cookie or Bearer. responses: "200": description: Connection status. content: application/json: schema: $ref: "#/components/schemas/StravaStatusEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/strava/disconnect: post: tags: - Integrations summary: Disconnect the caller's Strava account description: Best-effort deauthorizes at Strava, then clears the stored tokens and athlete id, audits the teardown and parks the `strava` ledger at `disconnected`. A Strava-side outage does not block the local teardown, so a 200 here does not prove the app was deauthorised upstream. Imported workouts stay. No body. Rate-limited 20 requests / 60 s per user. Auth via cookie or Bearer. responses: "200": description: The connection was cleared locally. content: application/json: 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: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/strava/test: post: tags: - Integrations summary: Probe the caller's Strava grant description: Fetches the authenticated athlete, the cheapest authenticated Strava call, to confirm the stored grant still works. No body, nothing written. Rate-limited 5 requests / 60 s per user. Auth via cookie or Bearer. responses: "200": description: The probe reached Strava. content: application/json: schema: $ref: "#/components/schemas/StravaTestEnvelope" "401": *a1 "403": *a9 "422": *a56 "429": *a57 "502": *a58 /api/integrations/status: get: tags: - Integrations summary: Read every integration's status in one call description: "The consolidated envelope behind the integrations settings surface: all nine providers, always, in a fixed order, each with the ledger core, the server-resolved liveness verdict and its own extras. Prefer this over nine per-provider round-trips. Pure read. Auth via cookie or Bearer." responses: "200": description: All nine provider entries plus the alert threshold. content: application/json: schema: $ref: "#/components/schemas/IntegrationStatusEnvelopeResponse" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/integrations/fitbit/resume: post: tags: - Integrations summary: Clear a parked Fitbit integration description: A provider whose persistent-failure streak has run for more than 24 h is parked, and the sync entry point short-circuits until somebody clears it. This clears it; the next cron tick attempts again. No body, no upstream call. Rate-limited 5 requests / 60 s per user. Auth via cookie or Bearer. responses: "200": description: Park cleared, or nothing to clear. content: application/json: schema: $ref: "#/components/schemas/FitbitResumeEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a57 /api/integrations/fitbit/test: post: tags: - Integrations summary: Probe the caller's Fitbit connection description: Resolves a valid access token (refreshing if needed) and fetches the profile endpoint. Returns the connection's `lastSyncedAt` alongside the probe result — the shape difference from the `/api//test` family. No body, nothing written beyond whatever the token refresh persists. Rate-limited 5 requests / 60 s per user. Auth via cookie or Bearer. responses: "200": description: The probe reached Fitbit. content: application/json: 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`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a56 "429": *a57 "502": *a58 /api/integrations/google-health/resume: post: tags: - Integrations summary: Clear a parked Google Health integration description: Identical contract to the Fitbit / WHOOP / Withings resume endpoints — same body (none), same response, same limiter. Auth via cookie or Bearer. responses: "200": description: Park cleared, or nothing to clear. content: application/json: schema: $ref: "#/components/schemas/GoogleHealthResumeEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a57 /api/integrations/google-health/test: post: tags: - Integrations summary: Probe the caller's Google Health connection description: 'Two endpoints in one, selected by the body. With no body (or any body other than the one below) it behaves like its Fitbit / WHOOP / Withings siblings: fetch the profile endpoint and answer `{ ok, lastSyncedAt, latencyMs }`. With `{ "probe": "structure" }` it instead walks one page or window per Google Health data type and answers a per-type diagnostic map — a completely different response shape under the same status code, so a client MUST branch on what it sent. The structure map reduces every leaf to its type name and never carries a value, a timestamp or an identifier, so the output is safe to paste into a public support thread. Rate-limited 5 requests / 60 s per user. Auth via cookie or Bearer.' requestBody: required: false content: application/json: schema: $ref: "#/components/schemas/GoogleHealthTestRequest" responses: "200": description: Either the plain probe result or the structure map, depending on the body sent. content: application/json: schema: $ref: "#/components/schemas/GoogleHealthIntegrationTestEnvelope" "401": *a1 "403": *a9 "413": description: Body exceeds 64 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a56 "429": *a57 "502": *a58 /api/integrations/whoop/resume: post: tags: - Integrations summary: Clear a parked WHOOP integration description: Identical contract to the Fitbit / Google Health / Withings resume endpoints — same body (none), same response, same limiter. Auth via cookie or Bearer. responses: "200": description: Park cleared, or nothing to clear. content: application/json: schema: $ref: "#/components/schemas/WhoopResumeEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a57 /api/integrations/whoop/test: post: tags: - Integrations summary: Probe the caller's WHOOP connection description: Resolves a valid access token (refreshing if needed) and fetches the basic-profile endpoint. Returns the connection's `lastSyncedAt` alongside the probe result. No body. Rate-limited 5 requests / 60 s per user. Auth via cookie or Bearer. responses: "200": description: The probe reached WHOOP. content: application/json: schema: $ref: "#/components/schemas/WhoopIntegrationTestEnvelope" "401": *a1 "403": *a9 "422": *a56 "429": *a57 "502": *a58 /api/integrations/withings/resume: post: tags: - Integrations summary: Clear a parked Withings integration description: Identical contract to the Fitbit / Google Health / WHOOP resume endpoints — same body (none), same response, same limiter. For Withings the park is typically reached through a run of contract-mismatch errors rather than an expired grant. Auth via cookie or Bearer. responses: "200": description: Park cleared, or nothing to clear. content: application/json: schema: $ref: "#/components/schemas/WithingsResumeEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a57 /api/integrations/withings/test: post: tags: - Integrations summary: Probe the caller's Withings connection description: Resolves a valid access token and asks for one recent measure. Withings signals API-level failure with HTTP 200 and a status code in the JSON body, so this probe inspects the body as well as the status — a 200 from Withings can still be a `credentials_rejected` 502 from here. No body. Rate-limited 5 requests / 60 s per user. Auth via cookie or Bearer. responses: "200": description: The probe reached Withings and the body reported OK. content: application/json: schema: $ref: "#/components/schemas/WithingsIntegrationTestEnvelope" "401": *a1 "403": *a9 "422": *a56 "429": *a57 "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: `upstream_invalid_json`, when Withings answered 200 with a body that would not parse. Withings' in-body status codes are mapped onto the same shared set, so a body-level rejection is indistinguishable from an HTTP-level one by errorCode alone." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/daily/digest: get: tags: - Insights summary: The unified daily digest description: "Assembles the day's read from already-cached data: the nightly briefing lifted read-only from the insights cache, the dashboard-snapshot health score / meds-today / sleep freshness, plus deterministic integration-status, Vorsorge and visit reads for the 'worth a look' rail. The rail's `preventive_care` item covers every check-up due today or overdue and names up to three of them; `upcoming_visit` items cover every planned visit on the local today (also once it has started, until the day ends) in one item, and the next day with a visit inside the next 48 hours in a second. A due check-up and today's visits always keep a place within the three-item cap. No provider call, no warm-on-mount. No module gate and no AI gate: the digest is data, and the `insights` module is the AI analysis opt-out. Its AI parts follow their capabilities, published in `ai`. Cookie or Bearer auth." responses: "200": description: The daily digest. content: application/json: schema: $ref: "#/components/schemas/DailyDigestEnvelope" "401": *a1 "403": *a15 "422": *a4 "429": *a5 /api/daily/digest/dismiss: post: tags: - Insights summary: Dismiss a Today rail item description: "\"Dismiss / mark seen\" for the Today rail's OBSERVATIONAL PriorityItem kinds only — milestone, ecg_new_recording, tension_window. The ACTIONABLE kinds (dose_window, sync_issue, preventive_care, coach_checkin) are structurally unreachable: itemKey must be namespaced under one of the three dismissible kinds or the request 422s before any lookup runs. Persisted server-side so the dismissal survives reload / a second device; an upsert, so a repeat dismiss of the same instance is a no-op. No module gate. Cookie or Bearer auth." requestBody: required: true content: application/json: schema: type: object properties: itemKey: type: string minLength: 1 maxLength: 200 required: - itemKey additionalProperties: false responses: "200": description: The item is dismissed (idempotent). content: application/json: schema: $ref: "#/components/schemas/DismissPriorityItemEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/dashboard/chart-overlay-prefs: put: tags: - Dashboard summary: Save one chart's overlay preferences description: "Writes the overlay toggles for a single chart into the same `dashboardWidgetsJson` blob `GET /api/dashboard/widgets` reads, without the client having to round-trip the whole layout — which is what it exists for: a chart wrapper knows the chart it is flipping and nothing else, and making it re-send the entire widget array would be both wasteful and racy across several charts at once. The read-modify-write runs inside a SERIALIZABLE transaction, so two tabs toggling different charts cannot clobber one another. Note the granularity: partial across charts, TOTAL within one — the named slot is replaced, so a body carrying only the toggle being changed clears the other two. This route accepts NO optimistic-concurrency token, unlike its `/api/dashboard/widgets` sibling; the transaction is what protects it instead. A malformed body is refused with the multi-issue 422 and an audit breadcrumb (`dashboard.chart-overlay.validation-failed`, deduplicated to one row per account per minute so a looping client cannot flood the ledger). Body capped at 64 KiB. Cookie or Bearer auth; the caller is always resolved as themselves, so these are never another record's preferences — the same choice the sibling PUT and DELETE make." requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/ChartOverlayPrefsPutBody" responses: "200": description: Saved. The response confirms the write and does NOT echo the resolved layout — re-read the widget layout if the new state is needed. content: application/json: schema: $ref: "#/components/schemas/ChartOverlayPrefsSavedEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/dashboard/summary: get: tags: - Dashboard summary: Native-client dashboard aggregator description: "Assembles the greeting, the logging streak, today's medication tally, the per-metric tiles and the sleep-rhythm DTO in one round-trip from bounded SQL aggregates. Distinct from `GET /api/dashboard/snapshot`, which serves the web first paint from the shared builder: this route normalises every metric to an iOS-friendly `kind` and carries no briefing, no health score and no target bands. Module handling is OMIT, not refuse — a disabled module drops its metric card and leaves the rest of the payload intact. Delegable at MANAGE level over the whole record: the payload is computed across every domain, and there is no provider anywhere on the path. Cookie or Bearer auth." responses: "200": description: The dashboard summary. content: application/json: schema: $ref: "#/components/schemas/DashboardSummaryEnvelope" "401": *a1 "403": *a15 "422": *a4 "429": *a5 /api/dashboard/widgets: get: tags: - Dashboard summary: Read the calling user's dashboard widget layout description: Returns the resolved effective layout (defaults merged in if the user has not customised it), the optimistic-concurrency `updatedAt` token, and `unavailableWidgetIds` — any widget whose toggle cannot do anything for this account, which a settings UI should not offer. responses: "200": description: The resolved layout (custom or default) plus its token. content: application/json: schema: $ref: "#/components/schemas/DashboardLayout" "401": *a1 "403": *a15 "422": *a4 "429": *a5 put: tags: - Dashboard summary: Replace the calling user's dashboard widget layout description: "Persists the layout with preserve-when-absent semantics and returns the normalised layout plus the advanced `updatedAt` token. Optimistic concurrency (v1.32.16): send `baseUpdatedAt` (the token from a prior read) and the write 409s if the stored row changed since — so a committed Save can never be silently reverted by a later request that started from an older snapshot; omit it for the legacy unconditional write. Invalid bodies return the multi-issue 422 envelope." requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/DashboardLayoutPutBody" responses: "200": description: Layout saved; the normalised layout plus the advanced token is echoed back. content: application/json: 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. `meta.errorCode` = `dashboard_layout_conflict`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a19 "429": *a5 delete: tags: - Dashboard summary: Reset the calling user's dashboard widget layout description: Resets the tile layout and Today-highlight visibility. It preserves the stored comparison baseline, chart overlays, selected score rings, hero ring order, and hero primary content. Idempotent. responses: "200": description: Web-controlled layout settings reset; preserved dashboard preferences are returned with the defaults. content: application/json: schema: $ref: "#/components/schemas/DashboardLayoutReset" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/managed-profiles/{id}/handover: get: tags: - Account sharing summary: Whether a handover link is open description: "The profile's open handover link, if any, and whether this instance allows creating one. Never returns the link: only its hash is stored. Cookie-only, no step-up." parameters: - in: path name: id schema: type: string required: true responses: "200": description: The status. content: application/json: 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`)." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 post: tags: - Account sharing summary: Create a handover link for a managed profile description: "Mints the one-time link that lets the person a managed profile describes take it over as their own account. No data moves: claiming sets credentials on the profile's own record. Claiming applies each guardian's proposal at once; the new owner then confirms or changes it on first sign-in, and until they do the proposal holds. Creating a new link withdraws an open one. Pending invitations on the profile are withdrawn when it is claimed. Cookie-only and step-up gated, like every act on a managed profile; ten an hour per caller. Refused on an instance that allows single sign-on only." parameters: - in: path name: id schema: type: string required: true requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CreateManagedProfileHandoverRequest" responses: "201": description: The link, shown once. content: application/json: schema: $ref: "#/components/schemas/ManagedProfileHandoverEnvelope" "401": *a1 "403": 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: $ref: "#/components/schemas/ErrorEnvelope" "404": description: "No such managed profile, or the caller is not one of its Guardians (`meta.errorCode: managed_profile.not_found`)." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": description: "The body is invalid, or a proposal names a grant that is not an active guardian of this profile (`meta.errorCode: managed_profile.handover.unknown_guardian`)." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 delete: tags: - Account sharing summary: Withdraw the open handover link description: Withdraws the profile's open handover link, if there is one; the link then answers like any unknown one. Cookie-only; no step-up, because withdrawing only takes something away. parameters: - in: path name: id schema: type: string required: true responses: "200": description: Withdrawn, or there was no open link. content: application/json: 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`)." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/account/handover-decision: get: tags: - Account sharing summary: The handover decision waiting for a new owner description: "After claiming a managed profile, the new owner decides each former guardian's access. This answers that decision, or `pending: null` when none is waiting. Refused while acting on another account." responses: "200": description: The waiting decision, or null. content: application/json: schema: $ref: "#/components/schemas/PendingHandoverDecisionEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 post: tags: - Account sharing summary: Decide each former guardian's access description: Records the new owner's final decision per former guardian. A changed level ends the guardian's grant and writes a new one; `manage` can restore access a guardian held before the handover. After this, access changes through the ordinary grant endpoints. Refused while acting on another account. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/HandoverDecisionRequest" responses: "200": description: Decided. content: application/json: schema: $ref: "#/components/schemas/HandoverDecisionEnvelope" "401": *a1 "403": *a9 "409": description: "No decision is waiting (`meta.errorCode: managed_profile.handover.no_pending`)." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": description: "The body is invalid, or a decision names somebody who was not a guardian at the claim (`meta.errorCode: managed_profile.handover.unknown_guardian`)." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 /api/auth/me: get: tags: - Auth summary: The signed-in account, and what sharing lets it do description: "Keeps answering while the browser is acting on another record — the switcher, the banner naming whose record is open, and the way back out all read it, so a refusal here would strand a switched session. It is therefore an ACTOR surface: it serves the caller's own rows — apart from `modules` and `cycleTrackingEnabled`, which answer for the record the session is inside, masked to what the grant opens — and a request that attaches `X-HealthLog-Account` is refused with 403 `sharing.not_permitted` rather than quietly answered. A client that wants a record's data sends the selector on the read that needs it, never on this call." responses: "200": description: The account payload. content: application/json: schema: $ref: "#/components/schemas/AccountPayloadEnvelope" "401": *a1 "403": 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: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/account/activity: get: tags: - Account sharing summary: Who opened this record, and when description: "Every audit row filed under this account that somebody else performed, newest first — one row per delegate per day with a count, rather than one per request. `retentionDays` rides the response because audit history is purged on a schedule the operator configures: a view that showed the rows without the window would imply a completeness it does not have, and clients must state the number the server sends rather than assume the 365-day default. Whether the list stopped at its row ceiling is `meta.truncated`; the `truncated` field in `data` carries the same value and is deprecated. Not delegable: a grant is not an introduction to the other people in the household." responses: "200": description: The activity, and how far back it can see. content: application/json: schema: $ref: "#/components/schemas/RecordActivityEnvelope" "401": *a1 "403": &a59 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: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/account/grants: get: tags: - Account sharing summary: List grants in both directions description: Every grant this account has given or received, ended ones included. Refused while acting on another account. responses: "200": description: Grants given and received. content: application/json: schema: $ref: "#/components/schemas/AccountGrantListEnvelope" "401": *a1 "403": *a59 "422": *a4 "429": *a5 post: tags: - Account sharing summary: Invite an account to this record description: "Offers access to somebody already registered on this instance, at READ, WRITE or MANAGE and over the whole record or named sections; the grant confers nothing until they accept it. An identifier that names no account answers 404 — a deliberate disclosure to an authenticated caller on a household instance, rate-limited to 10 invitations an hour, and the alternative (a silent pending row for a mistyped username) is worse. Refused while acting on another account, so a delegate can neither invite nor re-delegate. Offering MANAGE is step-up gated (`requireFreshMfaIfEnrolled`, cookie-only): an enrolled account without a fresh factor gets 401 `auth.stepup.required`, and a Bearer caller gets 403 `sharing.invite.manage_browser_only` before anything else happens, because the gate it would hit resolves through the session cookie and would otherwise answer 'not authenticated' to a caller it had just authenticated. Every invitation, at any level, also needs a fresh proof: a browser session must have signed in or used `POST /api/auth/reproof` within the last five minutes (with the second factor, on an account that has one), or it gets 401 `auth.reproof.required` with `meta.methods`; a Bearer caller presents an `X-Step-Up` elevation from `POST /api/auth/step-up` (a second-factor one on an account with a second factor) and gets 401 `auth.stepup.required` without it. `DELETE /api/auth/me/sessions` withdraws the invitations nobody has accepted yet." requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/AccountGrantInvite" responses: "201": description: The pending grant. content: application/json: schema: $ref: "#/components/schemas/AccountGrantEnvelope" "401": description: "No recent proof. A browser session that has not signed in or re-proved within five minutes gets `meta.errorCode: auth.reproof.required` with `meta.methods`; re-prove at POST /api/auth/reproof and retry. A Bearer caller without a valid `X-Step-Up` elevation gets `auth.stepup.required`, as does offering MANAGE from an MFA-enrolled browser session without a recent second-factor proof." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "403": 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: $ref: "#/components/schemas/ErrorEnvelope" "404": description: "No account on this instance carries that identifier (`meta.errorCode: sharing.invite.no_account`)." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "409": description: "That account already holds a live grant on this record (`meta.errorCode: sharing.invite.duplicate`). Re-inviting somebody whose access was revoked succeeds and mints a new row; the old one stays as history." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": description: "Validation failed, the invitation named the caller's own account (`meta.errorCode: sharing.invite.self`), or the scope cannot mean anything (`meta.errorCode: sharing.invite.invalid_scope` from the domain module; the request-shape refusal for an empty array, an unknown section or a scope beside MANAGE arrives as the ordinary multi-issue envelope naming the `scope` field)." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": description: Invitation rate limit exceeded. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/account/grants/{id}/accept: post: tags: - Account sharing summary: Accept an invitation description: "The delegate's own act, and the consent record: it stamps the acceptance time and the accepting IP on the grant row. One-shot and delegate-only — the invited account comes from the session, so an invitation addressed to somebody else answers 404 rather than confirming it exists." parameters: - in: path name: id schema: type: string required: true responses: "200": description: The now-active grant. content: application/json: schema: $ref: "#/components/schemas/AcceptedAccountGrantEnvelope" "401": *a1 "403": *a59 "404": description: No such invitation for this account. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "409": description: Already accepted. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "410": description: The invitation lapsed or was withdrawn. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/account/grants/{id}: delete: tags: - Account sharing summary: Withdraw access (owner) description: "Ends a grant on the caller's own record. No step-up and no confirmation step — reducing access must never be harder than granting it. Enforcement is the delegate's next request, not their next login, and the same transaction puts every browser session of theirs that was inside the record back into their own account. Nothing is deleted: the row survives with `revokedAt` and `revokedBy: GRANTOR`." parameters: - in: path name: id schema: type: string required: true responses: "200": description: The ended grant, and what the session cleanup did. content: application/json: schema: $ref: "#/components/schemas/RevokedAccountGrantEnvelope" "401": *a1 "403": *a59 "404": description: No such grant on this account's record. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "409": description: "Two refusals share this status. `meta.errorCode: sharing.revoke.already_ended` — that access had already ended, and nothing changed. `meta.errorCode: managed_profile.guardian.required` — the grant is the last active Guardian grant on a managed profile, and ending it would leave a record with nobody able to open it. The floor is real and this is one of the four routes that enforce it; the way past it is to invite another Guardian and wait for them to accept, or to delete the profile." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/account/grants/{id}/renounce: post: tags: - Account sharing summary: Hand access back (delegate) description: "The same transition as the owner's revoke, attributed the other way (`revokedBy: GRANTEE`) so the record can tell a withdrawal from a handover. Same session cleanup. Refused while acting on the record being renounced — switch back first (one request), which keeps grant management non-delegable without exceptions." parameters: - in: path name: id schema: type: string required: true responses: "200": description: The ended grant, and what the session cleanup did. content: application/json: schema: $ref: "#/components/schemas/RenouncedAccountGrantEnvelope" "401": *a1 "403": *a59 "404": description: No such grant held by this account. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "409": description: "Two refusals share this status. `meta.errorCode: sharing.renounce.already_ended` — that access had already ended, and nothing changed. `meta.errorCode: managed_profile.guardian.required` — the caller is the last active Guardian of a managed profile, so handing this access back would strand the record. Invite somebody and wait for them to accept, or delete the profile; a Guardian may not simply walk away from a record that has no self." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/managed-profiles: post: tags: - Account sharing summary: Create a record somebody looks after description: "The one way a managed profile comes into existence. It has no password, no passkey and no second factor of its own, and it never gains one: an account with no self is reachable only through its Guardians, which is what makes the step-up below a boundary rather than a formality. The creator's own Guardian grant is minted in the same transaction, so the record is switchable immediately and the caller is never left holding a profile nobody administers. Cookie transport only — `requireFreshMfa` resolves through the session cookie, so a Bearer caller is refused as unauthenticated; a native client sends the person to a browser rather than surfacing that as an error." requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CreateManagedProfileRequest" responses: "201": description: The new record. content: application/json: schema: $ref: "#/components/schemas/ManagedProfileEnvelope" "401": description: "No fresh second-factor proof (`meta.errorCode: auth.stepup.required`, with `meta.methods` naming the proofs that clear it at POST /api/auth/reproof), or none enrolled at all (`meta.errorCode: auth.stepup.mfa_not_enrolled`) — this gate is unconditional, so an account with neither a second factor nor a passkey cannot create a managed profile until it enrols one. A passkey counts: an account whose only strong credential is a passkey is offered `passkey` in `meta.methods`. The ordinary unauthenticated 401 arrives here too, including for a Bearer caller, whose transport this gate does not read." content: 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 default; a 429 has one meaning here, so the status alone is enough to branch on). Nothing was created." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/managed-profiles/{id}: get: tags: - Account sharing summary: Read a managed record's identity description: "What the record is right now — the five fields its creation took. The read an edit form fills itself from, and the reason it exists: `accountAccess.accounts` on the account payload carries the record's name and nothing else, so a form offered from the Guardian's own panel had no way to show the timezone, the language or the date of birth it was about to overwrite. Cookie transport only, an ACTOR surface (the profile is named in the path and the caller acts as themselves, so it answers without switching into the record), and NOT step-up gated: this is a read, it discloses to a Guardian only what they already administer, and the gate belongs to the acts." parameters: - in: path name: id schema: type: string required: true responses: "200": description: The record as it stands. content: application/json: 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." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 patch: tags: - Account sharing summary: Change a managed record's identity description: "Edit the record after it exists — the name that was a placeholder, the timezone that moved with the household, the date of birth somebody finally has, the sex that decides whether the cycle module is on. Any active Guardian may do it, from a cookie session with a fresh second factor: the same gate creation and deletion carry, because the three acts mint, change and end an account that can never prove anything about itself. Ten an hour per caller, the ceiling creation carries. Answers with the whole record, so a client re-renders from the server's version rather than from what it sent." parameters: - in: path name: id schema: type: string required: true requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpdateManagedProfileRequest" responses: "200": description: The record as it now stands. content: application/json: schema: $ref: "#/components/schemas/ManagedProfileEnvelope" "401": description: No fresh second-factor proof, or none enrolled. Same unconditional gate as creation and deletion. content: 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." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": description: "More than ten edits from one caller within the hour — the ceiling creation carries. `meta.errorCode: http.rate_limited`, the status default. Nothing was changed, and the refusal lands before the record named in the path is looked at." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" delete: tags: - Account sharing summary: Delete a managed record description: >- Ends the record and everything in it. This is the documented way OUT of the last-Guardian floor, which is why it carries no last-Guardian refusal of its own: a Guardian may not strand a record with nobody looking after it, but they may end the record. Any active Guardian may do it, from a cookie session with a fresh second factor. With a deletion grace window (`ACCOUNT_DELETION_GRACE_DAYS`, 14 days by default) the record is locked rather than erased: it leaves every Guardian's view at once (reads of it answer 404), nothing acts for it, and the purge job erases it at `scheduledPurgeAt`. Until then an admin can undo the deletion. Without a window it is erased now and `scheduledPurgeAt` is null. parameters: - in: path name: id schema: type: string required: true responses: "200": description: The record is gone from the Guardian's view; it is erased at `scheduledPurgeAt`, or already was when that is null. content: application/json: schema: $ref: "#/components/schemas/DeletedManagedProfileEnvelope" "401": description: No fresh second-factor proof, or none enrolled. Same unconditional gate as creation. content: 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 an ordinary record whose owner granted somebody MANAGE is not a managed profile, so it answers here too." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/managed-profiles/{id}/guardians: get: tags: - Account sharing summary: Who looks after a managed profile description: "The live Guardians of one managed profile — every unexpired, unrevoked MANAGE grant on it, PENDING and ACTIVE alike. Cookie transport only, by the same structural argument as the rest of this family: it resolves through the session and never falls through to the Bearer branch. A fresh second factor is NOT required, unlike every act on the same profile: this is a read, and it discloses nothing about a party the caller cannot already read on their own sharing panel. An ACTOR surface — the profile is named in the path and the caller acts as themselves, so it answers from the Guardian's own account rather than only while switched into the record. Anyone who is not an active Guardian of THIS profile gets the same 404 as an unknown id, including a READ, WRITE or MANAGE delegate of an ordinary record: the refusal is not an enumeration oracle. Count the ACTIVE rows to know whether removing a Guardian is possible — a PENDING invitation does not satisfy the last-Guardian floor." parameters: - in: path name: id schema: type: string required: true responses: "200": description: The profile's live Guardians, oldest invitation first. content: application/json: 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." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 post: tags: - Account sharing summary: Invite another Guardian description: "Offers somebody else a share in looking after this profile, at MANAGE over the whole record. Cookie-only and step-up gated, like every act in this family — the read beside it is not. **Resolve refusals on `meta.errorCode`, not on the status**, because this route answers 404 twice and the two need different next steps: `sharing.invite.no_account` means no account carries that identifier, while `managed_profile.not_found` means the caller is not a Guardian of that profile at all. A client branching on the status would tell somebody to check a spelling when the real answer is that the record is no longer theirs to administer." parameters: - in: path name: id schema: type: string required: true requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/InviteManagedProfileGuardianRequest" responses: "201": description: The pending Guardian, in the shape the roster publishes. content: application/json: schema: $ref: "#/components/schemas/ManagedProfileGuardianEnvelope" "401": description: No fresh second-factor proof, or none enrolled. Same unconditional gate as creation. content: 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 that `POST /api/account/grants` makes, with the same code, so this family does not answer one way here and another way one page over. `meta.errorCode: managed_profile.not_found`: no such managed profile, or the caller is not one of its Guardians." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "409": description: "That account already holds a live grant on this record (`meta.errorCode: managed_profile.guardian.duplicate`). Inviting YOURSELF lands here rather than on a refusal of its own: a Guardian grant's grantor is the profile, never the caller, so naming yourself names an account that already has access — which is both true and the useful sentence." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": description: "Validation failed (the multi-issue envelope; a bare `YYYY-MM-DD` expiry arrives here), or the invitee is itself a managed profile (`meta.errorCode: managed_profile.guardian.managed_invitee`) — a record with no self cannot look after another one." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": description: "More than ten invitations from one caller within the hour (`meta.errorCode: http.rate_limited`, the status default)." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/managed-profiles/{id}/guardians/{grantId}: delete: tags: - Account sharing summary: End another Guardian's access description: "Ends one Guardian grant on this profile — a pending invitation or an accepted one. Another person's, never the caller's own: the service refuses a target grant whose grantee is the caller, and that refusal is the same 404 an unknown profile gets, so a self-removal control would be a button that always fails with a sentence describing something else. A Guardian steps away through `POST /api/account/grants/{id}/renounce` instead, which carries the last-Guardian refusal properly. Count the ACTIVE rows on the roster BEFORE offering this: the floor is stated ahead of the act rather than discovered as a refusal, and on this route it cannot arrive as one — see the 409." parameters: - in: path name: id schema: type: string required: true - in: path name: grantId schema: type: string required: true responses: "200": description: What ended, and when. content: application/json: schema: $ref: "#/components/schemas/EndedManagedProfileGuardianEnvelope" "401": description: No fresh second-factor proof, or none enrolled. Same unconditional gate as creation. content: 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 OWN. The last is the one to design around — a client must not offer this control on the caller's own row, because the refusal it earns describes a missing profile rather than what actually happened." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "409": description: "The record would be left with nobody looking after it (`meta.errorCode: managed_profile.guardian.required`). **Not reachable through this route today**, and published anyway rather than silently: the caller must already be one active Guardian and the target is never the caller, so an active target is always a second one — and two is never the one the floor refuses. The routes that DO emit this code are `DELETE /api/account/grants/{id}` (the owner withdrawing the last Guardian grant), `POST /api/account/grants/{id}/renounce` (a Guardian handing their own access back), Guardian account deletion and a data wipe — a client resolves the code the same way wherever it arrives, which is why this arm stays. It is documented here so that a change making it reachable through this route is a change somebody notices." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/account/switch: post: tags: - Account sharing summary: Act on a granted record, or return to your own description: "Cookie transport only; a Bearer caller gets 400 (`meta.errorCode: sharing.switch.wrong_transport`) and should send the `X-HealthLog-Account` header on the requests it wants scoped instead. The grant is validated here so the client gets an honest refusal immediately, but the stamp authorises nothing on its own — every following request re-checks the grant. `accountId: null` clears the switch and always works, including from inside a switched session: this endpoint is the way out, and it is deliberately not fenced for that reason — a session whose context cannot be proved must still be able to leave. Supply `expectedEpoch` to make the write conditional: two tabs switching at once resolve to one monotonic outcome, and the loser is refused with 409 `sharing.session.changed` rather than silently overwriting the winner. Reconcile through GET /api/auth/me and retry once against the reconciled epoch." requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/AccountSwitchRequest" responses: "200": description: The account this session is now acting on, or null. content: application/json: schema: $ref: "#/components/schemas/AccountSwitchEnvelope" "400": description: Sent over the Bearer transport, which carries its selector per request instead. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 "403": *a59 "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 — so do not leave the record and do not clear the session. Re-read GET /api/auth/me with a network-only request, adopt the `recordSession` it returns, and retry. The body deliberately carries no epoch or scope: a client may adopt a context from GET /api/auth/me and POST /api/account/switch only, and a refusal is not a third source of truth." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": description: Validation failed. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 /api/notifications/vapid: get: security: [] tags: - Notifications summary: Read the instance's VAPID public key description: What a browser needs before it can create a Push subscription. Anonymous — the key is public by construction and discloses nothing about any account. Takes no parameters. responses: "200": description: The VAPID public key. content: application/json: schema: $ref: "#/components/schemas/VapidEnvelope" "503": description: The operator has configured no VAPID keys, so Web Push cannot be offered on this instance at all. Hide the subscribe control rather than retrying — this does not resolve on its own. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/notifications/web-push: post: tags: - Notifications summary: Register a Web Push subscription description: >- Stores a browser Push subscription for the caller and, on the first one, creates the account's Web Push notification channel enabled. Upsert by endpoint: re-posting the same endpoint refreshes its keys rather than duplicating the row, so a client can send this on every page load without checking first. The subscription keys are encrypted at rest. The endpoint is validated as HTTPS and as a public host before anything is written — the push library dials it later through its own fetch, outside the shared egress wrapper, so input time is where that has to be caught. Body capped at 64 KB. A rejected subscription answers the standard multi-issue 422, so a client can tell a non-HTTPS endpoint from an internal-host one from a missing key. The issue list carries `path`, `code` and `message` and never the rejected value — neither the endpoint nor the subscription keys are echoed back. An operator can switch Web Push off for the whole instance. While that switch is off this answers 403 before it reads the body, and no subscription and no channel row are created. Read the switch from `GET /api/settings/global-services` and hide the subscribe control rather than retrying. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/WebPushSubscribeRequest" responses: "200": description: Subscription stored. content: application/json: schema: $ref: "#/components/schemas/WebPushSubscribeEnvelope" "401": *a1 "403": 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: $ref: "#/components/schemas/ErrorEnvelope" "413": description: Request body exceeds 65536 bytes. Nothing was stored. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 delete: tags: - Notifications summary: Forget a Web Push subscription description: >- Removes the caller's subscription for the given endpoint. Idempotent and silent about the outcome: an endpoint that was never stored, and one belonging to another account, both answer the same success — the response says nothing about how many rows matched, so it cannot be used to probe whether an endpoint exists. The Web Push channel row is left in place; this removes a destination, not the channel. Body capped at 64 KB; a malformed `endpoint` answers the standard multi-issue 422, which does not echo the value. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/WebPushUnsubscribeRequest" responses: "200": description: The subscription is gone, or was never there. Indistinguishable by design. content: application/json: schema: $ref: "#/components/schemas/WebPushUnsubscribeEnvelope" "401": *a1 "403": *a9 "413": description: Request body exceeds 65536 bytes. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/notifications/web-push/test: post: tags: - Notifications summary: Send a test push to the newest Web Push subscription description: >- Fires one notification to the caller's MOST RECENT subscription only — a person with a phone, a tablet and a desktop registered should not get three buzzes per click. Takes no body. Rate-limited 5 per minute per user. Read `ok`, not the status. A push that the service rejects still answers 200, with `ok: false` and the service's own status in `perEndpoint`. Only the endpoint's HOST is reported: the full endpoint is the routing secret for that subscription, and push services embed it in their own error messages, so it is stripped before anything is logged or returned. responses: "200": description: The test ran. `ok` says whether the push was accepted; a false here is a real failure reported inside a success. content: application/json: 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`)." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": description: More than 5 tests in a minute (`meta.errorCode` = `rate_limited_self`). The code distinguishes the caller's own test budget from a shared limit. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/notifications/apns/test: post: tags: - Notifications summary: Send a test push to the account's iOS devices description: >- Fires one self-test through APNs to the caller's registered iOS devices. Takes no body. Rate-limited 5 per minute per user. Available to any authenticated user for their own account — the admin test endpoint is a different surface that fans out across every channel at once. Sent as a medication reminder rather than a generic notification, deliberately: only that event type takes the time-sensitive, high-priority branch, and a test delivered at the default level can be summarised into Notification Center instead of appearing on the lock screen. Testing at the default level would leave a person unable to tell whether their real dose reminders will surface. The visible text still reads as a test so nobody mistakes it for a due dose, and it is rendered in the account's own interface language. Read `ok`, not the status: a failed send answers 200 with `ok: false` and a `reason`. responses: "200": description: The test ran. `ok` says whether APNs accepted it; `reason` says why not. content: application/json: 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`). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/notifications/action/{token}: post: tags: - Notifications summary: Log a quick answer tapped inside a notification description: "Called by a notification app (ntfy `http` action), never by a signed-in client: the signed path token is the only credential. The token is bound to one user and one answer, expires six hours after the reminder was sent, and is single-use across all answers offered on that reminder. Rate-limited per client address before the token is read. Writes a mood entry with source `NOTIFICATION`; refused with 403 when the mood module is off." parameters: - in: path name: token schema: type: string description: Signed action token from the notification's URL. required: true description: Signed action token from the notification's URL. responses: "200": description: The answer was logged. content: application/json: schema: $ref: "#/components/schemas/NotificationActionResponse" "401": description: Invalid token (forged, altered, or unknown user). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "403": description: The mood module is turned off for this account. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "409": description: This reminder was already answered. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "410": description: The answer expired. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/notifications/preferences: get: tags: - Notifications summary: Read the caller's notification channels and preferences description: "Returns every channel the caller has, its stored per-event overrides, and the full event vocabulary. Not a pure read: a user whose Telegram config still lives only on the legacy `User` columns has it migrated into a channel row as a side effect of this call, and that migration failing is swallowed as a warning rather than surfaced. Auth via cookie or Bearer." responses: "200": description: Channels, stored overrides and event vocabulary. content: application/json: schema: $ref: "#/components/schemas/GetNotificationPreferences" "401": *a1 "403": *a9 "422": *a4 "429": *a5 put: tags: - Notifications summary: Set one per-channel notification preference description: Upserts a single (channel, event) override and echoes the stored row. One preference per call — there is no batch arm. Body capped at 64 KiB. Auth via cookie or Bearer. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/NotificationPreferenceRequest" responses: "200": description: The stored preference. content: application/json: schema: $ref: "#/components/schemas/PutNotificationPreferenceResponse" "401": *a1 "403": *a9 "404": description: No such channel for this caller. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "413": description: Body exceeds 64 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": description: Body is not parseable JSON, or failed validation. Note that unlike the routes wrapped in `safeJson`, a malformed body here is 422 rather than 400, and no Content-Type check runs. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 /api/notifications/status: get: tags: - Notifications summary: Read per-channel delivery health description: What the notification settings surface paints into its Active / Auto-disabled / Sending-paused badges, plus a per-category last-delivered map. Auth via cookie or Bearer. responses: "200": description: Delivery health per channel and per category. content: application/json: schema: $ref: "#/components/schemas/GetNotificationStatus" "401": *a1 "403": *a9 "422": *a4 "429": *a5 post: tags: - Notifications summary: Re-enable an auto-disabled channel description: Clears `disabledReason`, `consecutiveFailures` and `nextRetryAt`, sets `enabled` true, and audits the change. Accepted on any of the caller's channels, not only auto-disabled ones — re-enabling an already-active channel is a no-op that still answers 200. Follow up with a test send if the client wants confirmation the transport works again. Body capped at 64 KiB. Auth via cookie or Bearer. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/ReEnableNotificationChannelRequest" responses: "200": description: The channel was re-enabled. content: application/json: schema: $ref: "#/components/schemas/ReEnableNotificationChannelResponse" "401": *a1 "403": *a9 "404": description: No such channel for this caller. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "413": description: Body exceeds 64 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": description: Body is not parseable JSON, or failed validation. As above, a malformed body here is 422 rather than 400. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 /api/mcp/connections: get: tags: - MCP summary: List the caller's live MCP connector connections description: Returns the caller's non-revoked OAuth-bridge connections, newest first. `data` is the array itself, not nested under a named key. Auth via cookie or Bearer. responses: "200": description: Live connections. content: application/json: schema: $ref: "#/components/schemas/McpConnectionListEnvelope" "401": *a1 "403": &a60 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: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/mcp/connections/{id}: delete: tags: - MCP summary: Revoke an MCP connector connection description: Stamps the connection revoked so every future refresh fails, and revokes every access token it ever issued. Ownership is enforced inside the revoke, so a connection id alone cannot reach somebody else's connection — an id the caller does not own is indistinguishable from one that does not exist. Auth via cookie or Bearer. parameters: - name: id in: path required: true schema: type: string description: Connection id from the list endpoint. responses: "200": description: The connection was revoked. content: application/json: schema: $ref: "#/components/schemas/McpConnectionRevokeEnvelope" "401": *a1 "403": *a60 "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. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/mcp/tokens: get: tags: - MCP summary: List the caller's MCP connector tokens description: Returns the caller's manually-minted `health:read` tokens, newest first, revoked ones included. Access tokens issued by the OAuth bridge are excluded — they are transient 60-minute rows that would flood the list, and they are surfaced and revoked as connections instead. `data` is the array itself, not nested under a named key. Auth via cookie or Bearer. responses: "200": description: Connector tokens. content: application/json: schema: $ref: "#/components/schemas/McpConnectorTokenListEnvelope" "401": *a1 "403": *a60 "422": *a4 "429": *a5 post: tags: - MCP summary: Mint an MCP connector token description: "Mints a `health:read` (optionally read+write) Bearer for the manual / stdio path and audits the mint. THE RESPONSE CARRIES THE RAW TOKEN — this is the only place it ever exists, and it cannot be retrieved again. Body capped at 16 KiB. Auth via cookie or Bearer. A browser session needs a fresh proof as well as a live one: a sign-in or `POST /api/auth/reproof` within the last five minutes (with a second factor, on an account that has one). Without it the answer is 401 `auth.reproof.required`, with `meta.methods` naming the proofs the account can give. A Bearer caller is not asked for more than its token yet." requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CreateMcpTokenRequest" responses: "201": description: Token minted. The `token` field is the only copy of the secret. content: application/json: schema: $ref: "#/components/schemas/CreateMcpTokenEnvelope" "400": description: Body is not parseable JSON. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 "403": *a60 "413": description: Body exceeds 16 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "415": description: Content-Type is not `application/json`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": description: Validation failed. The envelope carries every offending issue, not just the first. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 /api/mcp/tokens/{id}: delete: tags: - MCP summary: Revoke an MCP connector token description: Marks the token revoked and audits it. The lookup requires the token to be the caller's own AND to carry `health:read`, so this endpoint can never revoke a differently-scoped credential by id alone — a native-client or ingest token is a 404 here. Auth via cookie or Bearer. parameters: - name: id in: path required: true schema: type: string description: Token id from the list endpoint. responses: "200": description: The token was revoked. content: application/json: schema: $ref: "#/components/schemas/McpConnectorTokenRevokeEnvelope" "401": *a1 "403": *a60 "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. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/record-settings: get: tags: - Account sharing summary: Read the active managed record's identity description: "The record the session is currently acting on, named explicitly. This is not `/api/auth/me` with a different scope: that endpoint keeps reporting the actor throughout, and this one reports the record, so a client rendering a managed surface has an unambiguous source for each. Cookie session or Bearer, but only while acting on a managed record the caller guards." responses: "200": description: The active managed record. content: application/json: schema: $ref: "#/components/schemas/RecordSettingsEnvelope" "401": *a1 "403": *a15 "422": *a4 "429": *a5 /api/record-settings/integrations: get: tags: - Account sharing summary: Read the managed record's integration connection states description: Whether each of the record's integrations is connected, when it last succeeded, and its server-resolved health. No credential projection. Connecting and disconnecting an OAuth provider are the two POST routes below this path. Cookie session or Bearer, while acting on a managed record the caller guards. responses: "200": description: Connection state per provider. content: application/json: schema: $ref: "#/components/schemas/RecordSettingsIntegrationsEnvelope" "401": *a1 "403": *a15 "422": *a4 "429": *a5 /api/record-settings/integrations/{provider}/connect: post: tags: - Account sharing summary: Start connecting an OAuth provider for the managed record description: "Mints the OAuth state for the record the session is acting on, sets it as an httpOnly cookie, and answers with the provider's consent URL; the client then sends the browser there. A fetch rather than a navigation, so the record-session fence applies. The state names the managed record: the provider's callback lands the tokens on it after checking the browser still belongs to a Guardian holding a live MANAGE grant on that record. When the record has no provider app of its own and the deployment has none, the Guardian's own app is copied onto the record (audited). Fitbit is not offered: its classic API is retired. Cookie session, while acting on a managed record the caller guards." parameters: - &a61 name: provider in: path required: true schema: type: string enum: - withings - whoop - oura - polar - strava - google-health description: The OAuth provider. An unrecognised value is a 404, answered before authentication. responses: "200": description: The consent URL to send the browser to. content: application/json: schema: $ref: "#/components/schemas/ManagedConnectStartEnvelope" "401": *a1 "403": *a15 "404": description: The path named a provider outside the closed set. Answered before the caller is authenticated. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "409": description: Neither the record, the Guardian, nor the deployment has an OAuth app for this provider. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/record-settings/integrations/{provider}/disconnect: post: tags: - Account sharing summary: Disconnect an OAuth provider from the managed record description: "Runs the same disconnect the owner of a record runs: the grant is dropped, the provider told where it can (Withings webhooks, Strava deauthorisation), and the integration parked at `disconnected`. Imported data stays. Audited against the record with the Guardian as the actor. Cookie session or Bearer, while acting on a managed record the caller guards." parameters: - *a61 responses: "200": description: The provider is disconnected. content: application/json: schema: $ref: "#/components/schemas/ManagedDisconnectEnvelope" "401": *a1 "403": *a15 "404": description: The path named a provider outside the closed set, or the record has no connection to it. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/record-settings/{family}: get: tags: - Account sharing summary: Read one configuration family of the managed record 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: - &a62 name: family in: path required: true schema: type: string enum: - profile - modules - notifications - thresholds - coach - insights description: Which configuration family to read or write. An unrecognised value is a 404 — and note that the family check runs BEFORE authentication, so an unknown family answers 404 to an unauthenticated caller where a known one would answer 401. responses: "200": description: The family's settings. content: application/json: schema: $ref: "#/components/schemas/GetRecordSettingsFamilyEnvelope" "401": *a1 "403": *a15 "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. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 patch: tags: - Account sharing summary: Update one configuration family of the managed record description: Applies the family's patch and echoes the post-write state, so a client never needs a follow-up read. Five of the six families MERGE — an omitted key keeps its stored value; `insights` replaces the layout instead. The write is audited against the record with the acting Guardian recorded as the actor, and the changed key names ride the audit row. Body capped at 64 KiB. Cookie session or Bearer, while acting on a managed record the caller guards. parameters: - *a62 requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/RecordSettingsPatchRequest" responses: "200": description: The family's settings after the write. content: application/json: schema: $ref: "#/components/schemas/PatchRecordSettingsFamilyEnvelope" "400": description: Body is not parseable JSON. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 "403": *a15 "404": description: The path named a family outside the closed set. Answered before authentication. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "413": description: Body exceeds 64 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "415": description: Content-Type is not `application/json`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": description: Validation failed against the family's strict schema, with every offending issue in the envelope rather than only the first. An unknown key lands here rather than being ignored — that is what makes the field allowlist enforceable. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 /api/health: get: security: [] tags: - Meta summary: Liveness / readiness probe description: >- Reports whether this process can serve. Public — it is on the proxy's allowlist and needs no credential, which is what lets `docker compose` and an external monitor use it. It is also the ONLY path a worker-only process answers; every other route on such a process is refused. The body is a BARE object, not the `{ data, error }` envelope every other route returns. Do not run it through the envelope decoder. The status code carries the verdict as well as the body: 200 for `ok`, 503 for `degraded`. Probe on the code; the body is for a human reading the response. An ADMIN cookie session gets the component breakdown (`database`, `worker`, `timestamp`); everybody else gets `{ status }` alone. `Cache-Control: no-store, no-cache, must-revalidate` on both arms. responses: "200": description: 'Healthy. `{ status: "ok" }`, plus the component breakdown when the caller is an admin.' content: application/json: schema: anyOf: - $ref: "#/components/schemas/HealthAdminResponse" - $ref: "#/components/schemas/HealthPublicResponse" "503": description: "Degraded — the database did not answer within two seconds, or a role this process runs is not up. Same body shape as the 200, with `status: \"degraded\"`, and `reason: \"starting\"` while the background role is still coming up after a start. A restart loop reports this rather than failing to answer, so a monitor can tell 'starting' from 'gone'." content: application/json: schema: anyOf: - $ref: "#/components/schemas/HealthAdminResponse" - $ref: "#/components/schemas/HealthPublicResponse" /api/personal-records: get: tags: - Awards summary: List detected personal bests description: >- The record's personal bests, newest achievement first. Read-only — rows come from the detection worker and no endpoint creates or edits one. Both query parameters are validated and a bad value is a 422. They were forgiving until this release, and in one direction that was worse than merely quiet: dropping an unrecognised `metricType` WIDENED the read to every metric, so a typo returned more rows than the caller asked for. The `limit` ceiling refuses rather than clamps for the same reason. Delegable on a `measurements` grant. parameters: - name: metricType in: query required: false schema: type: string description: A `MeasurementType` to filter to. A value outside the enum is refused with 422 — it is not dropped, because dropping it would widen the read rather than narrow it. - name: limit in: query required: false schema: type: integer minimum: 1 maximum: 500 default: 100 description: Page cap, default 100, ceiling 500. A non-integer, a zero, a negative value or anything above 500 is refused with 422 rather than corrected — serving a different number of rows than were asked for is another difference nobody is told about. responses: "200": description: The record's personal bests, newest first. content: application/json: schema: $ref: "#/components/schemas/ListPersonalRecordsEnvelope" "401": *a1 "403": *a15 "422": description: "`metricType` or `limit` carried an unacceptable value. `meta.errorCode` = `personal_records.invalid_query`, with the offending path under `details.issues`." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 /api/gamification/achievements: get: tags: - Awards summary: Read the badge grid description: >- Every badge with its progress, plus the summary tally. Two response shapes on one path: `?format=ios` returns a flat array of pre-translated entries, anything else returns the web object (`summary` / `achievements` / `metrics`) with i18n keys. Everything is derived from the record's own history, so a delegate sees what the owner earned. Badge categories whose owning module the account has disabled are skipped from evaluation entirely — they do not appear as locked, they do not appear at all. Served through a stale-while-revalidate cache with a 10-minute stale window, so a freshly crossed threshold can take a poll or two to surface. The cache holds the web shape; the iOS translation runs after the read, which is why switching locales does not need a separate cache entry. Read-only: since v1.35.3 the unlock rows are written by the `achievement-unlock-sweep` job rather than by this GET, and the payload is identical whether or not a row exists yet. Delegable on a whole-record grant — the grid spans every module that carries a badge category, so a per-section grant is not enough. parameters: - name: format in: query required: false schema: type: string enum: - ios description: "`ios` selects the flat, pre-translated array. Any other value (or none) returns the web object." responses: "200": description: The badge grid. `AchievementsResponse` by default, an array of `AchievementIosEntry` under `?format=ios`. content: application/json: schema: $ref: "#/components/schemas/AchievementsEnvelope" "401": *a1 "403": *a17 "422": *a4 "429": *a5 /api/environment/preferences: patch: tags: - Environment summary: Change the environment switches description: "Turns the air-quality part of the module on or off for this account (on by default). Off means the nightly fetch skips the air-quality feed, and the overview, the Coach, MCP and the correlations leave the air-quality values out; days already stored keep theirs. Turning it on queues a refresh, and the nightly gap fill catches up on older days. Module-gated. Strict body: an unknown key is a 422." requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/EnvironmentPreferencesRequest" responses: "200": description: The stored switches. content: application/json: schema: $ref: "#/components/schemas/EnvironmentPreferencesEnvelope" "401": *a1 "403": *a11 "422": *a4 "429": *a5 /api/environment: get: tags: - Environment summary: Read the environmental-context overview description: The account's home location, its travel overrides, how many daily observations are stored, and whether the background fetch has been failing. Module-gated. Not delegable — this is the caller's own configuration. responses: "200": description: The module overview. content: application/json: schema: $ref: "#/components/schemas/EnvironmentOverviewEnvelope" "401": *a1 "403": *a11 "422": *a4 "429": *a5 /api/environment/home: put: tags: - Environment summary: Set the coarse home location description: >- Stores the picked city and stamps the effective-from instant. Coordinates are rounded to 1 decimal (about 11 km) server-side regardless of what was sent. Re-stamping is the part worth knowing: EVERY write moves `since` to now, including one that changes only the label. Days before the new `since` stop resolving against this home, so a correction to an existing home un-attributes the history that home used to cover. On success a lookback fetch is enqueued so recent days populate without waiting for the nightly tick. That enqueue is best-effort — it no-ops cleanly when no worker is bound and the response is a 200 either way. Body cap 4 KiB. Module-gated. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/EnvironmentHomeRequest" responses: "200": description: The stored home, echoed back with its coarse values. content: application/json: schema: $ref: "#/components/schemas/EnvironmentHomeEnvelope" "401": *a1 "403": *a11 "413": description: Body exceeds 4 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "415": description: "`Content-Type` is not `application/json`." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": description: Validation failed; `meta.errorCode` = `environment.invalid`, with every issue listed. An out-of-range coordinate, an empty or over-long label, an unknown key, or a `timezone` the runtime IANA set does not recognise all land here. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 /api/environment/backfill: post: tags: - Environment summary: Enqueue a historical environment fetch description: >- Asks the worker to fetch and store past days rather than waiting for the rolling nightly lookback. Returns 202 — the work has been QUEUED, not done; poll `GET /api/environment` and watch `context.days`. An omitted bound defaults to the conservative `[homeSince .. today]` range. An explicit start earlier than `homeSince` is accepted and then resolves to a skip inside the worker: the span cannot fabricate weather for the pre-home past, so a wide range can legitimately return `enqueued: true` and add no days at all. The span is capped at 730 days, and the worker re-checks the cap independently. Body cap 4 KiB. Module-gated, and it draws on the shared analytics-read rate bucket. Every backfill draws on the instance-wide Open-Meteo budget, so one account is held to three a rolling hour (429 `environment.backfill_rate_limited`), at most one queued backfill per twenty-minute slot (409 `environment.backfill_pending`), and, inside the worker, its own share of the daily request budget; days the share does not reach are filled by the nightly run. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/EnvironmentBackfillRequest" responses: "202": description: Accepted and queued. `enqueued` is false when no worker is bound to take it — the request still succeeded, nothing will run. content: application/json: schema: $ref: "#/components/schemas/EnvironmentBackfillEnvelope" "401": *a1 "403": *a11 "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 (`environment.backfill_pending`). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "413": description: Body exceeds 4 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "415": description: "`Content-Type` is not `application/json`." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": description: Validation failed. `meta.errorCode` = `environment.invalid` for a malformed body, an unknown key or a start after the end; `environment.range_too_large` when the resolved span exceeds the day cap. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 /api/environment/geocode: get: tags: - Environment summary: Search a place for the home-location picker description: >- Forward-geocodes free text to at most five coarse matches. The lookup runs server-side on purpose: the browser never talks to the upstream host, so the CSP needs no third-party entry. A failed or empty upstream answer is `results: []`, not an error — the picker shows 'no match' rather than a broken state, and the caller cannot distinguish 'nothing matched' from 'the feed was unreachable'. Module-gated, and it draws on the shared analytics-read rate bucket so a runaway autocomplete loop is capped. parameters: - in: query name: q schema: type: string minLength: 1 maxLength: 120 required: true responses: "200": description: Matches, possibly empty. content: application/json: schema: $ref: "#/components/schemas/EnvironmentGeocodeEnvelope" "401": *a1 "403": *a11 "422": description: "`q` was missing, empty after trimming, or longer than 120 characters. `meta.errorCode` = `environment.invalid`." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 /api/environment/travel: post: tags: - Environment summary: Add a travel override description: >- Declares that the account was somewhere other than home for an inclusive date range; the days it covers resolve against this location instead. Coordinates are rounded to 1 decimal (about 11 km) server-side. Adding one enqueues a refresh over exactly the declared window so its days re-resolve. Nothing checks for overlap with an existing override — two ranges covering the same day are accepted, and the worker picks between them. Body cap 4 KiB. Module-gated. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/EnvironmentTravelRequest" responses: "201": description: The stored override. content: application/json: schema: $ref: "#/components/schemas/CreateEnvironmentTravelEnvelope" "401": *a1 "403": *a11 "413": description: Body exceeds 4 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "415": description: "`Content-Type` is not `application/json`." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": description: Validation failed; `meta.errorCode` = `environment.invalid`. Covers an out-of-range coordinate, a malformed date, an unknown key, and `startDate` after `endDate`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 /api/environment/travel/{id}: delete: tags: - Environment summary: Remove a travel override description: >- Owner-scoped: the delete matches on the id AND the caller's user id, so one account can never remove another's override — a cross-account id is a 404, same as one that never existed. Hard delete, not a tombstone, and NOT idempotent in the usual sense: a second delete of the same id answers 404 rather than 200. Removing an override does NOT re-resolve the days it used to cover; they keep whatever weather was stored for them until a backfill is asked for. Module-gated. parameters: - in: path name: id schema: type: string description: Travel-override id. required: true description: Travel-override id. responses: "200": description: Removed. content: application/json: schema: $ref: "#/components/schemas/DeleteEnvironmentTravelEnvelope" "401": *a1 "403": *a11 "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. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/ingest/medication: post: tags: - Medications summary: Record a dose from an external integration description: >- Logs a medication intake from outside the app — a chat bridge, a home-automation rule, a button on a shelf. **Auth is Bearer only.** A cookie session does not authenticate here: the route reads the `Authorization` header itself, hashes it and looks the token up, so a browser with a valid login and no token gets 401. The token must carry either the `*` wildcard or the `medication:ingest` scope — AND, once the named medication resolves, either `*` or that medication's own `medication:{id}:ingest` scope. The second check runs after the lookup. A token scoped to one medication gets the same 403 for a name that does not exist as for one outside its scope, so it cannot be used to learn which medications exist; only a `*` token gets 404 for an unknown name. **The operator can switch this off.** With the global API kill switch disabled every request is refused with 403 before the token is even read. **Replay is by `idempotencyKey`, and the status says which happened.** A key already stored for this token's owner and the same medication returns the existing row with **200**; a fresh dose returns **201**; a key already used for another medication returns **409** without its row. Replay is answered only after both scope checks. Nothing else distinguishes them — same body shape, same fields — so branch on the status code. **A dose is attributed to its scheduled slot, not stamped ad-hoc.** When the take falls inside a schedule's window band it converges onto that slot's canonical row (closing the pending reminder rather than leaving it open beside a duplicate ad-hoc take); otherwise it lands as a standalone row with `scheduledFor = takenAt`. Only a genuine pending-to-taken transition consumes inventory, so a re-post that converges onto an already-taken slot does not decrement stock twice. Rate-limited to 60 requests per minute **per token**. A household running a chat bridge, a button and a home-automation rule gets three independent allowances rather than three shares of one, which is what the previous per-IP bucket gave them. A request that presents no valid token falls back to a per-IP bucket with the same ceiling, so an unauthenticated flood is still capped. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/ExternalMedicationIntakeRequest" responses: "200": description: "Replay: a row with this `idempotencyKey` already exists for the token's owner and is returned unchanged. Nothing was written and no inventory was consumed." content: application/json: schema: $ref: "#/components/schemas/ExternalMedicationIntakeReplayEnvelope" "201": description: The dose was recorded. Body is the created row. content: application/json: schema: $ref: "#/components/schemas/ExternalMedicationIntakeCreatedEnvelope" "401": description: "No `Authorization: Bearer …` header, or a token that is unknown, revoked or past its expiry. The three cases are not distinguished on the wire, and all four are charged to the per-IP bucket rather than to any token." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "403": description: Refused. Either the operator has the global API switch off (the message says so), or the token lacks `medication:ingest` / `*`, or it lacks the resolved medication's own `medication:{id}:ingest` / `*` scope. A token without `*` also gets this answer for a name that matches no active medication. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "404": description: "Only for a `*` token: no ACTIVE medication of the token owner's matches `medicationName`. An inactive medication of the right name is also a 404 — the name is only searched among active ones." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "409": description: The `idempotencyKey` is already stored for a dose of another medication. Nothing was written and that dose is not returned. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "413": description: Body exceeds 64 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "415": description: "`Content-Type` is not `application/json`." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": description: "Validation failed, with every issue listed rather than the first — an integration operator should see the whole shape at once. An empty or unstable `idempotencyKey` fails here: the column is unique table-wide, so an empty key would resolve to whichever unrelated row stored it first. Also 422 with `meta.errorCode` = `medication.intake.notTracked` (v1.42.0) when the named medication is kept as a record only (`trackIntake: false`); checked after the token's scope, so a token without this medication's scope learns nothing about it." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": description: "More than 60 requests in a minute for this token — or, when no valid token was presented, from this client IP. `meta.errorCode` = `ingest.rate_limited`, plus the standard rate-limit headers: `Retry-After` (whole seconds, at least 1), `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/admin/backups/upload: post: tags: - Admin summary: Store an uploaded backup file as a new copy description: "Reads a backup file, checks it, and stores it encrypted as a new copy beside the weekly one; it does NOT restore anything. Restoring is the separate `POST /api/admin/backups/{id}/restore`, so an operator can review what was uploaded first. The file goes up as the raw request body, plain JSON or gzip-compressed JSON (sniffed from the bytes, not the content type), up to 512 MB as sent; or, for a file up to 10 MB, as `multipart/form-data` with one `file` field. It is read as a stream, checked as it arrives, and stored in the same pass; a file that fails any check leaves nothing behind. It is refused when it is not valid JSON, fails the backup schema, was written by a newer schema version than this server reads, needs an encryption key this server does not hold (`meta.errorCode` = `backup.key.missing`, `meta.keyIds` naming them), or belongs to an account that does not exist here. Three uploads a minute per administrator. Admin session cookie required; Bearer tokens cannot reach admin endpoints. A browser session needs a fresh proof as well as a live one: a sign-in or `POST /api/auth/reproof` within the last five minutes (with a second factor, on an account that has one). Without it the answer is 401 `auth.reproof.required`, with `meta.methods` naming the proofs the account can give." requestBody: required: true content: application/json: schema: type: object propertyNames: type: string additionalProperties: {} description: The backup document, as `GET /api/admin/backups/{id}/download` or the account export wrote it. application/gzip: schema: type: string format: binary multipart/form-data: schema: type: object properties: file: type: string format: binary description: The backup file, plain or gzip-compressed, up to 10 MB. required: - file responses: "201": description: The file was stored as a new copy. content: application/json: schema: $ref: "#/components/schemas/AdminBackupUploadEnvelope" "400": description: A `multipart/form-data` body that could not be parsed as a form. Nothing was stored. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": description: "No session (`auth.missing`), or a session without a recent proof (`auth.reproof.required`, with `meta.methods`). A Bearer token lands here too: the admin gate reads only the session cookie." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "403": *a25 "413": description: "Too large: over 512 MB as sent (10 MB in a form), a gzip file that expands past what any backup this server wrote does, or larger than the stored-copy limit once encrypted (`meta.errorCode` = `backup.upload.too_large`, the numbers and the setting in the message). Nothing was stored." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": description: "The file was refused and nothing was stored: no file in the request, not valid JSON or not readable as gzip, failed the backup schema (`meta.issues`), an unsupported schema version, a key this server does not hold (`meta.errorCode` = `backup.key.missing`, `meta.keyIds`), or an owner account that does not exist here." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": description: More than three uploads in a minute by this administrator. Nothing was stored. The `Retry-After` and `X-RateLimit-*` headers describe the bucket. headers: *a8 content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/admin/backups/{id}/download: get: tags: - Admin summary: Download one stored backup as JSON description: "Decrypts one stored copy and returns the backup document itself, with `Content-Disposition: attachment` and `Cache-Control: no-store`. The response is the raw document, not the standard envelope — it is the artefact an operator keeps. The ciphertext stays in the database. Admin session cookie required; Bearer tokens cannot reach admin endpoints. A browser session needs a fresh proof as well as a live one: a sign-in or `POST /api/auth/reproof` within the last five minutes (with a second factor, on an account that has one). Without it the answer is 401 `auth.reproof.required`, with `meta.methods` naming the proofs the account can give." parameters: - in: path name: id schema: type: string description: "`DataBackup.id`." required: true description: "`DataBackup.id`." responses: "200": description: The decrypted backup document. content: application/json: schema: $ref: "#/components/schemas/AdminBackupDocument" "403": &a63 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": &a64 description: No stored copy with this id. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "409": description: The stored copy was replaced by a newer one while it was being read. `meta.errorCode` = `backup_changed`. Nothing was changed; start again to use the new copy. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": description: "The stored copy could not be opened: either the key that wrote it is no longer in `ENCRYPTION_KEYS` (a rotation that dropped the legacy entry too early), or the stored bytes are not the ones that were written (a stored piece missing, moved, altered, taken from another copy, or the copy cut short), or the row holds both a single value and pieces, which only an older release writing to it after an upgrade produces. Every piece is checked before any of the copy is read. `meta.errorCode` = `backup.payload.undecryptable`. Nothing was changed." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "500": description: The copy decrypted and then failed schema validation — a document this instance wrote and cannot parse. This one genuinely is a server fault, and is reported as one. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/admin/backups/{id}/restore: post: tags: - Admin summary: Queue the restore of one stored backup over its owner's record description: "Queues a restore that replaces the snapshot owner's data tables from the stored copy, in one transaction, under the ids the file carries, and answers 202 with the job's id. Replacing is not merging: every row the account gained after the snapshot was taken is deleted with the rest of its class. The target is the account the snapshot was taken for, never the admin running it. The request refuses a missing confirmation, an unknown backup and a copy that cannot be decrypted; the file's own checks (schema, declared owner, manifest, documents) run in the job before anything is deleted, and a refusal there ends the job as `failed` with the same reason. One restore per account at a time. Admin session cookie required; Bearer tokens cannot reach admin endpoints. A browser session needs a fresh proof as well as a live one: a sign-in or `POST /api/auth/reproof` within the last five minutes (with a second factor, on an account that has one). Without it the answer is 401 `auth.reproof.required`, with `meta.methods` naming the proofs the account can give." parameters: - in: path name: id schema: type: string description: "`DataBackup.id`." required: true description: "`DataBackup.id`." - *a6 requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/AdminBackupRestoreRequest" responses: "202": description: The restore is queued. Its progress and outcome are at `statusUrl`. content: application/json: schema: $ref: "#/components/schemas/AdminBackupRestoreQueuedResponse" "400": description: Body was not JSON. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "403": *a63 "404": *a64 "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 (`meta.errorCode` = `backup_changed`), or a request under the same `Idempotency-Key` is still in flight. Nothing was changed. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "413": description: Body exceeds 64 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": description: "The request was refused before anything was queued: `confirm` missing, or the copy undecryptable (`meta.errorCode` = `backup.payload.undecryptable`)." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "503": description: The restore could not be handed to the background worker. Nothing was changed. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/admin/backups/restores: get: tags: - Admin summary: List the restores the backups console shows description: Every queued or running restore, and those created in the last day, newest first, at most 20. What the console reads when it opens, so a reload during a restore picks its progress up again. Admin session cookie required; Bearer tokens cannot reach admin endpoints. responses: "200": description: The restore jobs. content: application/json: schema: $ref: "#/components/schemas/AdminBackupRestoreJobListResponse" "403": *a63 /api/admin/backups/restores/{jobId}: get: tags: - Admin summary: Read one restore job description: The status, phase and counts of one restore, and its report or its failure once it finished. Admin session cookie required; Bearer tokens cannot reach admin endpoints. parameters: - in: path name: jobId schema: type: string description: The `jobId` the restore request answered with. required: true description: The `jobId` the restore request answered with. responses: "200": description: The restore job. content: application/json: schema: $ref: "#/components/schemas/AdminBackupRestoreJobResponse" "403": *a63 "404": description: No restore job with this id. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/documents/sources: get: tags: - Documents summary: "Document archives: availability and connections" description: "Whether the operator enabled the document picker (`available`, true when `DOCUMENT_SOURCE_ORIGINS` lists at least one origin), the listed origins, and the caller's own saved connections to Paperless-ngx or Papra. A connection never carries its token: `hasToken` is always true for a saved one, and no route returns the token in whole or in part. `originAllowed` is false once the operator removed the connection's origin from the list. No 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: &a65 - cookieAuth: [] responses: "200": description: The picker's state for the caller. content: application/json: schema: $ref: "#/components/schemas/DocumentSourcesStatusEnvelope" "401": *a1 "403": &a66 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: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/documents/sources/{system}: put: tags: - Documents summary: Save a connection to a document archive description: "Saves or replaces the caller's connection to Paperless-ngx (`paperless`: base address and API token) or Papra (`papra`: base address, organization id, API key with `documents:read` and `tags:read`). The base address may carry a path; its origin must be listed in `DOCUMENT_SOURCE_ORIGINS`. The connection is tested live before anything is stored. Leaving out `token` keeps the stored one, but only when the new address is on the same origin as the saved one: the first save and a move to another origin must carry it (422 `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: *a65 parameters: - &a67 name: system in: path required: true description: "`paperless` (Paperless-ngx) or `papra` (Papra)." schema: type: string enum: - paperless - papra requestBody: required: true content: application/json: schema: type: object properties: baseUrl: type: string minLength: 1 maxLength: 2048 organizationId: type: string pattern: ^org_[a-z0-9]{24}$ token: type: string minLength: 1 maxLength: 512 pattern: ^[\x21-\x7e]+$ required: - baseUrl responses: "200": description: Saved and verified. The connection, without its token. content: application/json: schema: $ref: "#/components/schemas/DocumentSourceConnectionEnvelope" "401": *a1 "403": *a66 "404": &a68 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 one the key's user is not a member of (Papra 403 `user.not_in_organization`) and one Papra cannot parse (400)." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": description: "`documents.sources.originNotAllowed`, `documents.sources.invalidAddress`, `documents.sources.organizationRequired` (Papra), `documents.sources.tokenRequired` (first save without a token), or body validation." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 "502": &a69 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 answered with a redirect, which is never followed), `documents.sources.authRefused` (401/403 from the source), `documents.sources.permissionMissing` (Papra: the key cannot read tags; needs `tags:read`), `documents.sources.versionTooOld` (Paperless-ngx older than 2.16 / API version 9), `documents.sources.badResponse` (not the documented JSON, or larger than 1 MiB)." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" delete: tags: - Documents summary: Remove a connection to a document archive 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: *a65 parameters: - *a67 responses: "200": description: Removed, or there was nothing to remove. content: application/json: schema: $ref: "#/components/schemas/DocumentSourceDisconnectEnvelope" "401": *a1 "403": *a66 "422": *a4 "429": *a5 /api/documents/sources/{system}/test: post: tags: - Documents summary: Test a saved connection 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: *a65 parameters: - *a67 responses: "200": description: The source answered. content: application/json: schema: $ref: "#/components/schemas/DocumentSourceTestEnvelope" "401": *a1 "403": *a66 "404": *a68 "422": &a70 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." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 "502": *a69 /api/documents/sources/{system}/tags: get: tags: - Documents 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: *a65 parameters: - *a67 responses: "200": description: The tags. content: application/json: schema: $ref: "#/components/schemas/DocumentSourceTagsEnvelope" "401": *a1 "403": *a66 "404": *a68 "422": *a70 "429": *a5 "502": *a69 /api/documents/sources/{system}/search: get: tags: - Documents summary: Search a connected archive by name, tag and date description: "Searches the source 25 documents at a time. `q` matches the document's name (Paperless-ngx title, Papra name); `tag` narrows to one of the source's tags by id; `from` / `to` to a date range, inclusive (Paperless-ngx filters on `created__gte` / `created__lte`; Papra's range is applied to each page after it arrives). When a filter applied after arrival empties a page, the route reads on, up to five source pages, and `page` in the answer is the last source page read; ask for `page + 1` next. Each result carries what the vault already holds 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: *a65 parameters: - *a67 - name: q in: query required: false schema: type: string maxLength: 200 - name: tag in: query required: false schema: type: string maxLength: 64 - name: from in: query required: false schema: type: string format: date - name: to in: query required: false schema: type: string format: date - name: page in: query required: false schema: type: integer minimum: 1 maximum: 500 default: 1 responses: "200": description: One page of results. content: application/json: schema: $ref: "#/components/schemas/DocumentSourceSearchEnvelope" "401": *a1 "403": *a66 "404": *a68 "422": *a70 "429": *a5 "502": *a69 /api/documents/sources/{system}/import: post: tags: - Documents summary: Import one picked document description: "`sourceId` must be an id the system issues (Paperless-ngx: digits; Papra: letters, digits, `_`, `-`), else 422 before anything is fetched. Fetches one document from the source and stores it in the vault through the same path as an upload, with `sourceSystem` / `sourceId` / `sourceInstance` (the connection's origin) provenance; the same id from another instance is another document. One import per key runs at a time; a concurrent second one waits and then answers `duplicate`. The key check counts toward the lookup allowance, the fetch toward the search allowance, and only a file that arrived toward the upload allowance. A source key the vault already holds is answered without a download: `duplicate` for a live document (same id), `deleted` for one the person deleted in HealthLog, which is not stored again unless the request sends `revive: true`: then a document still in Recently deleted is restored (`restored`), and one already removed for good is fetched and stored again, its key leaving the import ledger. A token upload (`POST /api/documents/inbound`) has no such switch, so an importer never brings a deleted document back. Bytes already in the vault under another key are `duplicate` too, and the key is remembered for that document. `link` files the document against the record the picker was opened from (a condition, a visit or a vaccination, which must be the caller's own and live; checked before anything is 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: *a65 parameters: - *a67 requestBody: required: true content: application/json: schema: type: object properties: sourceId: type: string minLength: 1 maxLength: 128 pattern: ^[\x21-\x7e]+$ kind: type: string enum: - DOCTOR_REPORT - DISCHARGE_LETTER - LAB_RESULT - IMAGING - PRESCRIPTION - REFERRAL - INSURANCE - VACCINATION - SICK_NOTE - OTHER link: type: object properties: kind: type: string enum: - conditionEpisode - encounter - vaccination id: type: string minLength: 1 maxLength: 64 required: - kind - id revive: type: boolean required: - sourceId responses: "200": description: Already held (`duplicate`), deleted in HealthLog (`deleted`), or brought back from Recently deleted (`restored`). content: application/json: schema: $ref: "#/components/schemas/DocumentSourceImportHeldEnvelope" "201": description: Imported. content: application/json: schema: $ref: "#/components/schemas/DocumentSourceImportEnvelope" "401": *a1 "403": *a66 "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. `documents.sources.notFound`: the source has no such document, or (Papra) no such organization, including one the key's user is not a member of (Papra 403 `user.not_in_organization`) and one Papra cannot parse (400). `documents.sources.linkTargetNotFound`: the record in `link` is not the caller's own live conditionEpisode / encounter / vaccination." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "409": description: "`documents.inbound.sourceAliasLimit`: these bytes are already stored under too many source ids." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "413": description: "`documents.inbound.fileTooLarge` (with `maxFileBytes`) or `documents.inbound.quotaExceeded` (with `quotaBytes` and `usedBytes`)." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "415": description: "`documents.inbound.fileType`: the file is not a type the vault accepts." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a70 "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`)." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "502": *a69 /api/symptoms/definitions: get: tags: - Symptoms summary: List your own symptoms (v1.40) description: The record's active symptom definitions with their 30-day summary; `includeHidden=true` adds the hidden ones. Rides the illness module. parameters: - in: query name: includeHidden schema: type: string enum: - "true" - "false" responses: "200": description: The definitions. content: application/json: schema: $ref: "#/components/schemas/ListSymptomDefinitionsEnvelope" "401": *a1 "403": &a71 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: `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`). 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: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 post: tags: - Symptoms summary: Define a symptom (v1.40) description: Creates one definition. Idempotent under `Idempotency-Key`. Audits as `symptoms.definition.create` without the label. Rate-limited to 30 a minute. parameters: - *a6 requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CreateSymptomDefinitionRequest" responses: "201": description: Definition created. content: application/json: schema: $ref: "#/components/schemas/CreateSymptomDefinitionEnvelope" "401": *a1 "403": *a71 "409": *a7 "422": *a4 "429": *a5 /api/symptoms/definitions/{id}: patch: tags: - Symptoms summary: Edit, hide or show a symptom (v1.40) description: Partial edit. Audits as `symptoms.definition.update`, naming a label change without quoting it. parameters: - in: path name: id schema: type: string required: true requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpdateSymptomDefinitionRequest" responses: "200": description: Definition updated. content: application/json: schema: $ref: "#/components/schemas/UpdateSymptomDefinitionEnvelope" "401": *a1 "403": *a71 "404": &a72 description: "`symptoms.definition.notFound`: no definition with this id on the record." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 delete: tags: - Symptoms summary: Hide or delete a symptom (v1.40) description: Hides the definition and keeps its history. `?purge=true` deletes it and every occurrence of it. parameters: - in: path name: id schema: type: string required: true - in: query name: purge schema: type: string enum: - "true" - "false" responses: "200": description: Hidden or deleted. content: application/json: schema: $ref: "#/components/schemas/DeleteSymptomDefinitionEnvelope" "401": *a1 "403": *a71 "404": *a72 "422": *a4 "429": *a5 /api/symptoms/events: get: tags: - Symptoms summary: List symptom occurrences (v1.40) description: Occurrences of the record's symptoms, newest first, narrowed by the optional query. parameters: - in: query name: from schema: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - in: query name: to schema: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - in: query name: definitionId schema: type: string minLength: 1 maxLength: 40 - in: query name: episodeId schema: type: string minLength: 1 maxLength: 40 - in: query name: limit schema: default: 200 type: integer minimum: 1 maximum: 500 responses: "200": description: A page of occurrences. content: application/json: schema: $ref: "#/components/schemas/ListSymptomEventsEnvelope" "401": *a1 "403": *a71 "422": *a4 "429": *a5 post: tags: - Symptoms summary: Log a symptom occurrence (v1.40) description: Creates one occurrence. Idempotent under `Idempotency-Key`. A hidden definition can still be logged against; another record's definition 404s (`symptoms.definition.notFound`). Audits as `symptoms.event.create` without the note. parameters: - *a6 requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CreateSymptomEventRequest" responses: "201": description: Occurrence logged. content: application/json: schema: $ref: "#/components/schemas/CreateSymptomEventEnvelope" "401": *a1 "403": *a71 "404": *a72 "409": *a7 "422": *a4 "429": *a5 /api/symptoms/events/{id}: patch: tags: - Symptoms summary: Edit a symptom occurrence (v1.40) description: Partial edit. Audits as `symptoms.event.update`, naming a note change without quoting it. parameters: - in: path name: id schema: type: string required: true requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpdateSymptomEventRequest" responses: "200": description: Occurrence updated. content: application/json: schema: $ref: "#/components/schemas/UpdateSymptomEventEnvelope" "401": *a1 "403": *a71 "404": &a73 description: "`symptoms.event.notFound`: no occurrence with this id on the record." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 delete: tags: - Symptoms summary: Delete a symptom occurrence (v1.40) description: Removes the occurrence. Audits as `symptoms.event.delete`. parameters: - in: path name: id schema: type: string required: true responses: "200": description: Deleted. content: application/json: schema: $ref: "#/components/schemas/DeleteSymptomEventEnvelope" "401": *a1 "403": *a71 "404": *a73 "422": *a4 "429": *a5 /api/day/index: get: tags: - Records summary: Days with entries description: Which local days in a window hold anything, by section, and which carry a notable observation. At most 366 days per call. Days are cut in the record's own time zone. parameters: - in: query name: from schema: type: string description: A local calendar date, `YYYY-MM-DD`. required: true description: A local calendar date, `YYYY-MM-DD`. - in: query name: to schema: type: string description: A local calendar date, `YYYY-MM-DD`. required: true description: A local calendar date, `YYYY-MM-DD`. responses: "200": description: The days in the window that hold entries. content: application/json: schema: $ref: "#/components/schemas/DayIndexEnvelope" "401": *a1 "403": *a15 "422": *a4 "429": *a5 /api/day/notable: get: tags: - Records summary: Since the last visit description: "What stood out and what changed in a window, for the preparation of the next visit: deterministic observations (a value highest or lowest for at least three months, a first reading, a regular reading that stopped for two weeks or more) and context changes (dose changes, medication and course starts and ends, pauses, illness onsets and recoveries, vaccinations, procedures, lab days). Without `from` the window is the one the next planned visit prepares (its `preparation.from` on `GET /api/encounters`, `anchor: nextVisit`); with nothing booked it starts at the last completed visit the caller may see (else 90 days back); without `to` it ends today; at most 1096 days. Keys and the record's own names only, no judgement. Days are cut in the record's own time zone. Sections the caller's grant does not cover are left out." parameters: - in: query name: from schema: type: string description: A local calendar date, `YYYY-MM-DD`. - in: query name: to schema: type: string description: A local calendar date, `YYYY-MM-DD`. responses: "200": description: The window, its observations and its changes. content: application/json: schema: $ref: "#/components/schemas/DayNotableWindowEnvelope" "401": *a1 "403": *a15 "422": *a4 "429": *a5 /api/day/{date}: get: tags: - Records summary: One day across the record description: "Everything on one local calendar day: what ran through it (medications, an illness, a cycle phase, a trip), the readings in its window with the person's usual range, what happened on it, and deterministic notable observations as keys with parameters. Sections of a switched-off module are left out; sections the caller's grant does not cover are named in `sections`. Life events and the environment are the owner's only: for a delegate at any level they are named `not_shared`. Read live from the record, never from the UTC rollups. A date that is not a real calendar date answers 422." parameters: - in: path name: date schema: type: string description: A local calendar date, `YYYY-MM-DD`. required: true description: A local calendar date, `YYYY-MM-DD`. responses: "200": description: The day. content: application/json: schema: $ref: "#/components/schemas/DayEnvelope" "401": *a1 "403": *a15 "422": *a4 "429": *a5 /api/timeline: get: tags: - Records summary: The record over the years description: Lanes of conditions, allergies, medications, vaccinations, visits, lab days, documents and life events, the standing items without a start, and monthly (or finer, by zoom) value series. Empty lanes are not sent; lanes of a switched-off module never are. The `life` lane (life events and travel periods) is the owner's only and is never sent to a delegate. Requires the opt-in `timeline` module. parameters: - in: query name: zoom schema: default: all description: "`range` is a chosen stretch: it requires `from` and `to`, at most 15 years apart, and its value series average quarters, months, weeks or days by its length." type: string enum: - all - year - quarter - range description: "`range` is a chosen stretch: it requires `from` and `to`, at most 15 years apart, and its value series average quarters, months, weeks or days by its length." - in: query name: from schema: type: string description: A local calendar date, `YYYY-MM-DD`. - in: query name: to schema: type: string description: A local calendar date, `YYYY-MM-DD`. - in: query name: values schema: description: Comma-separated value series to include, as `MeasurementType` names or `MOOD`. A name given twice counts once, so the list is bounded by the names that exist; an unknown name answers 422. type: string description: Comma-separated value series to include, as `MeasurementType` names or `MOOD`. A name given twice counts once, so the list is bounded by the names that exist; an unknown name answers 422. responses: "200": description: The timeline for the requested zoom and window. content: application/json: schema: $ref: "#/components/schemas/TimelineEnvelope" "401": *a1 "403": &a74 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` 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`). 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: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/timeline/readiness: get: tags: - Records summary: What the timeline can show description: "One row per lane: carries, thin or empty, a count, a detail as a message key with parameters, and the gaps with one in-app link each. The verdict is one of two words, never a score. The `life` lane is reported to the owner only. Requires the opt-in `timeline` module." responses: "200": description: The readiness inventory. content: application/json: schema: $ref: "#/components/schemas/TimelineReadinessEnvelope" "401": *a1 "403": *a74 "422": *a4 "429": *a5 /api/life-events: get: tags: - Records summary: List life events description: "The account's own life events, oldest first; soft-deleted ones are excluded. Title and note are decrypted for the caller. Owner-only: no delegate reads them at any share level. Life events are never sent to a model." responses: "200": description: The life events. content: application/json: schema: $ref: "#/components/schemas/LifeEventListEnvelope" "401": *a1 "403": &a75 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: 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` = `sharing.not_permitted`). This operation resolves the CALLER and never a named record, so under a switch it refuses instead of quietly answering with the caller's own — leave the shared record first. No grant at any level opens it. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 post: tags: - Records summary: Add a life event description: "A dated anchor in the person's life. `precision` says how much of the date is known and applies to both ends: at MONTH a date is the first of its month, at YEAR the first of its year. `endDate`, when given, is not before `startDate`. Title (1-120 characters) and note (up to 2000) are encrypted at rest. Owner-only, like every life-event route." parameters: - *a6 requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CreateLifeEventRequest" responses: "201": description: Created. content: application/json: schema: $ref: "#/components/schemas/CreateLifeEventEnvelope" "401": *a1 "403": *a75 "409": *a7 "422": *a4 "429": *a16 /api/life-events/{id}: patch: tags: - Records summary: Edit a life event description: A partial edit; an omitted key leaves the column untouched. The merged event must still satisfy the create rules (dates aligned to the precision, end not before start). parameters: - in: path name: id schema: type: string required: true requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/UpdateLifeEventRequest" responses: "200": description: Updated. content: application/json: schema: $ref: "#/components/schemas/UpdateLifeEventEnvelope" "401": *a1 "403": *a75 "404": &a76 description: No such life event on this account. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 delete: tags: - Records summary: Delete a life event description: Soft-deletes one life event. parameters: - in: path name: id schema: type: string required: true responses: "200": description: Deleted. content: application/json: schema: $ref: "#/components/schemas/DeleteLifeEventEnvelope" "401": *a1 "403": *a75 "404": *a76 "422": *a4 "429": *a5 /api/search: post: tags: - Records summary: Search the record description: "One search over the record: visits and procedures, conditions, documents (title, file name and indexed text), vaccinations, lab results (analyte names and notes), medications, symptoms, workouts by sport, and the notes on mood entries, readings and illness days. Hits are grouped by kind, the best group first, and each carries the in-app path that opens the record. Matching ignores case and accents and reads German umlauts typed as ae, oe, ue or ss. A source whose module the record has switched off is not read. The search reads each source newest first under a per-source row cap and a time budget; `truncated` says when that cut it short. The query travels in the JSON body, never in the URL, and is never logged. Rate limited per acting account (20 a minute), and one search at a time: a newer search by the same account stops the one before it, which then answers 409 `search.superseded`. A grant scoped to some sections of the record does not open it, like the timeline." requestBody: required: true content: application/json: schema: type: object properties: q: type: string minLength: 2 maxLength: 100 description: What to look for. Matched without regard to case or accents, and German umlauts may be typed as ae, oe, ue or ss. limit: default: 5 description: Most hits per group. type: integer minimum: 1 maximum: 20 required: - q responses: "200": description: The hits, grouped by kind. content: application/json: schema: $ref: "#/components/schemas/SearchEnvelope" "401": *a1 "403": *a15 "422": *a4 "429": *a5 /api/auth/me/ai-usage: put: tags: - Profile summary: Set the AI usage level (v1.43) description: "Own account only: a cookie session or a full-scope token. Answers the resolved `AiUsageState`. 422 `ai.usage.invalid` for a malformed body; 422 `ai.usage.aboveCeiling` with `meta.maxLevel` for a level above the operator's ceiling; 429 `ai.usage.rateLimited` after 30 changes in an hour." requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/AiUsagePutRequest" responses: "200": description: The resolved AI usage state after the change. content: application/json: 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: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 /api/medications/{id}/ingredients: put: tags: - Medications summary: Replace a supplement's composition (v1.43) description: "Stores the composition per unit in canonical units and re-counts every taken dose of this medication into the day's micronutrients: `recompute.queued` is true when the worker has the recompute (the rows follow within moments), false when it ran before this answer. Needs the `nutrients` module (403 `module.disabled`, `meta.module: \"nutrients\"`). Delegable at MANAGE on the `medications` domain. 404 `medications.notFound`; 422 `medications.ingredients.invalid` (body, duplicate code, or unknown code with `meta.index`); 422 `medications.ingredients.unitMismatch` with `meta.index` and `meta.allowedUnits`; 429 above 30 a minute per caller." parameters: - in: path name: id schema: type: string required: true - *a6 requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/MedicationIngredientsPutRequest" responses: "200": description: The stored composition. content: application/json: schema: $ref: "#/components/schemas/PutMedicationIngredientsResponse" "401": *a1 "403": 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"`. 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: $ref: "#/components/schemas/ErrorEnvelope" "404": description: "`medications.notFound`." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "409": *a7 "422": *a4 "429": *a5 /api/medications/{id}/ingredients/extract: post: tags: - Medications summary: Read a supplement label (v1.43) description: "Multipart `file` (JPEG, PNG, WebP or PDF, at most 10 MB; the bytes decide the type, not the header). HEIC is refused with `medications.ingredients.extract.fileType`: send the photo as JPEG. 202 with an AI run; poll `GET /api/ai-runs/{id}` (kind `SUPPLEMENT_LABEL_EXTRACT`, result `SupplementLabelExtractResult`). Amounts in the proposal are per single unit: a label printed per serving of several units is divided down, and `servingNote` says what was printed. Needs `ai.capabilities.supplementLabel.available` (refused like every AI capability, reason `usage_level` included) and the `nutrients` module. The account's own session only: provider work is never admitted inside somebody else's record. 404 `medications.notFound`; 422 `medications.ingredients.extract.fileType`; 413 `medications.ingredients.extract.fileTooLarge`; 429 `medications.ingredients.extract.rateLimited` (six an hour) or `medications.ingredients.extract.budgetExceeded` (the day's AI budget). A run that fails carries `medications.ingredients.extract.failed`." parameters: - in: path name: id schema: type: string required: true requestBody: required: true content: multipart/form-data: schema: type: object properties: file: type: string format: binary required: - file responses: "202": *a44 "401": *a1 "403": 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: $ref: "#/components/schemas/ErrorEnvelope" "404": description: "`medications.notFound`." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "413": description: "`medications.ingredients.extract.fileTooLarge`." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": description: "`medications.ingredients.extract.fileType`: the upload is not a JPEG, PNG, WebP or PDF, or carries no `file` field. No configured AI provider can serve the `supplementLabel` capability: `meta.errorCode` = `ai.provider.none`, `meta.reason` = `no_provider`." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 "503": *a26 /api/anamnesis/life-circumstances: get: tags: - Anamnesis summary: Life circumstances (v1.43) description: The life-circumstance sections of the record. Delegates read it under the `profile` grant, like the facts. responses: "200": description: The life circumstances. content: application/json: schema: $ref: "#/components/schemas/LifeCircumstancesEnvelope" "401": *a1 "403": *a15 "422": *a4 "429": *a5 /api/anamnesis/life-circumstances/ai-sections: patch: tags: - Anamnesis summary: Switch life-circumstance sections for the AI (v1.43) description: Sections not named keep their state. Own account only. 422 `anamnesis.lifeSections.invalid`; 409 `about_me_conflict` for a stale `baseUpdatedAt`. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/LifeAiSectionsPatchRequest" responses: "200": description: The switches after the change. content: application/json: schema: $ref: "#/components/schemas/LifeAiSectionsEnvelope" "401": *a1 "403": *a9 "409": description: "`about_me_conflict`." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/anamnesis/life-circumstances/confirm: post: tags: - Anamnesis summary: Confirm a life-circumstance section is still true (v1.43) description: Stores the confirmation instant only, never a new revision. Own account only. 422 `anamnesis.lifeConfirm.invalid`. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/LifeConfirmRequest" responses: "200": description: The confirmed section. content: application/json: schema: $ref: "#/components/schemas/LifeConfirmEnvelope" "401": *a1 "403": *a9 "422": *a4 "429": *a5 /api/anamnesis/audit-c: post: tags: - Anamnesis summary: Record an AUDIT-C (v1.43) description: Scores the three items on the server. The entry is stored in the language of the wording the read route offers this account (`auditC.instrument.locale`), and the cut-off follows that wording; `locale`, when sent, must name that same wording. A replay with the same `externalId` answers 200 with the stored result. 422 `anamnesis.auditC.invalid` (also for another wording than the one offered, and for a `takenAt` more than five minutes ahead of the server clock); 429 `anamnesis.auditC.rateLimited` after 30 in an hour. parameters: - *a6 requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/AuditCRequest" responses: "200": description: A replay of an entry already stored. content: application/json: schema: $ref: "#/components/schemas/AuditCReplayEnvelope" "201": description: The stored and scored entry. content: application/json: schema: $ref: "#/components/schemas/AuditCCreatedEnvelope" "401": *a1 "403": *a15 "409": *a7 "422": *a4 "429": *a5 /api/anamnesis/audit-c/{id}: delete: tags: - Anamnesis summary: Remove an AUDIT-C entry (v1.43) description: Soft delete. 404 `anamnesis.auditC.notFound`. parameters: - in: path name: id schema: type: string required: true responses: "200": description: The removed entry. content: application/json: schema: $ref: "#/components/schemas/AuditCRemovedEnvelope" "401": *a1 "403": *a15 "404": description: "`anamnesis.auditC.notFound`." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/dawarich/connect: post: tags: - Integrations summary: Connect Dawarich (v1.43) description: Checks the key against the instance, stores the connection sealed and starts a 30-day backfill. 422 `dawarich.invalid`, `private_origin_not_approved`, `dawarich.credentials_rejected` or `dawarich.unreachable`; 429 `dawarich.rate_limited` (ten a minute). requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/DawarichConnectRequest" responses: "200": description: Connected. content: application/json: schema: $ref: "#/components/schemas/DawarichConnectEnvelope" "401": *a1 "403": *a11 "422": *a4 "429": *a5 /api/dawarich/status: get: tags: - Integrations summary: Dawarich connection status (v1.43) responses: "200": description: The connection. content: application/json: schema: $ref: "#/components/schemas/DawarichStatusEnvelope" "401": *a1 "403": *a11 "422": *a4 "429": *a5 /api/dawarich/places: get: tags: - Integrations summary: Places on the Dawarich instance (v1.43) description: Read live from Dawarich to pick the home place (at most 200, filtered by `q`); never stored. 409 `dawarich.not_connected`; 502 `dawarich.unreachable`. parameters: - in: query name: q schema: type: string maxLength: 80 responses: "200": description: The places. content: application/json: schema: $ref: "#/components/schemas/DawarichPlacesEnvelope" "401": *a1 "403": *a11 "409": description: "`dawarich.not_connected`." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 "502": description: "`dawarich.unreachable`." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/dawarich/settings: patch: tags: - Integrations summary: Dawarich home place and country (v1.43) description: 422 `dawarich.invalid`; 422 `dawarich.home_place_unknown` for a place id the instance does not have; 409 `dawarich.not_connected`; 502 `dawarich.unreachable` when the place cannot be checked. A new home country re-judges the stored travel days at once; a new home place fetches the stored days again in the background (up to a year). requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/DawarichSettings" responses: "200": description: The settings after the change. content: application/json: schema: $ref: "#/components/schemas/DawarichSettingsEnvelope" "401": *a1 "403": *a11 "409": description: "`dawarich.not_connected`." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 "502": description: "`dawarich.unreachable`." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/dawarich/test: post: tags: - Integrations summary: Probe the Dawarich connection (v1.43) description: Answers the shared integration probe shape. A failed probe is a 502 whose `errorCode` is one of the shared probe classes (`credentials_rejected`, `rate_limited`, `upstream_error`, `timeout`, `url_not_public`, `connection_failed`); 422 `not_configured` without a connection; 429 `rate_limited_self` past five probes a minute. responses: "200": description: The probe reached the instance. content: application/json: schema: $ref: "#/components/schemas/DawarichProbeEnvelope" "401": *a1 "403": *a11 "422": *a4 "429": *a5 "502": description: A probe class; see the description. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/dawarich/sync: post: tags: - Integrations summary: Fetch Dawarich daily values again (v1.43) description: From `fromDate` (inside the 365 days ending yesterday; without it the nightly window of seven days). `enqueued` is false when a run for this account is already queued or no worker is bound. 409 `dawarich.not_connected`; 422 `dawarich.invalid`. requestBody: required: false content: application/json: schema: type: object properties: fromDate: type: string pattern: ^\d{4}-\d{2}-\d{2}$ additionalProperties: false responses: "202": description: Queued. content: application/json: schema: $ref: "#/components/schemas/DawarichSyncEnvelope" "401": *a1 "403": *a11 "409": description: "`dawarich.not_connected`." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/dawarich/disconnect: post: tags: - Integrations summary: Disconnect Dawarich (v1.43) description: "Forgets the connection; with `deleteData: true` the stored days go too. No body is `{ deleteData: false }`. Disconnecting twice is harmless." requestBody: required: false content: application/json: schema: type: object properties: deleteData: default: false type: boolean additionalProperties: false responses: "200": description: Disconnected. content: application/json: schema: $ref: "#/components/schemas/DawarichDisconnectEnvelope" "401": *a1 "403": *a11 "422": *a4 "429": *a5 /api/environment/travel/suggestions: get: tags: - Environment summary: Suggested trips from Dawarich travel days (v1.43) description: Runs of at least two consecutive travel days in one country, inside the last year, that no stored trip overlaps and the person has not dismissed; newest first. Empty without a Dawarich connection. responses: "200": description: The suggestions. content: application/json: schema: $ref: "#/components/schemas/TravelSuggestionsEnvelope" "401": *a1 "403": *a11 "422": *a4 "429": *a5 /api/environment/travel/suggestions/{id}/dismiss: post: tags: - Environment summary: Dismiss a suggested trip (v1.43) description: 404 `environment.travel_suggestion.not_found`. parameters: - in: path name: id schema: type: string required: true responses: "200": description: Dismissed. content: application/json: schema: $ref: "#/components/schemas/TravelSuggestionDismissEnvelope" "401": *a1 "403": *a11 "404": description: "`environment.travel_suggestion.not_found`." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/medications/{id}/links: post: tags: - Links summary: File records against a medication (v1.43) description: Idempotent; at most 100 ids. An id that names nothing this account owns comes back in `unknown` instead of failing. 404 when the medication is not found; 422 `medication.link.invalid` for a malformed body. Own account only, as the other link routes. parameters: - in: path name: id schema: type: string required: true requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/MedicationLinkRequest" responses: "200": description: How many links changed, and which ids were unknown. content: application/json: schema: $ref: "#/components/schemas/MedicationLinkResponse" "401": *a1 "403": *a9 "404": description: The medication was not found. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 delete: tags: - Links summary: Unfile records from a medication (v1.43) description: Idempotent; at most 100 ids. An id that names nothing this account owns comes back in `unknown` instead of failing. 404 when the medication is not found; 422 `medication.link.invalid` for a malformed body. Own account only, as the other link routes. parameters: - in: path name: id schema: type: string required: true requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/MedicationLinkRequest" responses: "200": description: How many links changed, and which ids were unknown. content: application/json: schema: $ref: "#/components/schemas/MedicationUnlinkResponse" "401": *a1 "403": *a9 "404": description: The medication was not found. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/labs/{id}/links: post: tags: - Links summary: File records against a lab result (v1.43) description: Idempotent; at most 100 ids. An id that names nothing this account owns comes back in `unknown` instead of failing. 404 when the lab result is not found; 422 `labs.link.invalid` for a malformed body. Own account only, as the other link routes. parameters: - in: path name: id schema: type: string required: true requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/LabResultLinkRequest" responses: "200": description: How many links changed, and which ids were unknown. content: application/json: schema: $ref: "#/components/schemas/LabResultLinkResponse" "401": *a1 "403": *a9 "404": description: The lab result was not found. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 delete: tags: - Links summary: Unfile records from a lab result (v1.43) description: Idempotent; at most 100 ids. An id that names nothing this account owns comes back in `unknown` instead of failing. 404 when the lab result is not found; 422 `labs.link.invalid` for a malformed body. Own account only, as the other link routes. parameters: - in: path name: id schema: type: string required: true requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/LabResultLinkRequest" responses: "200": description: How many links changed, and which ids were unknown. content: application/json: schema: $ref: "#/components/schemas/LabResultUnlinkResponse" "401": *a1 "403": *a9 "404": description: The lab result was not found. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/bugreport: post: security: [] deprecated: true tags: - Retired summary: POST /api/bugreport — removed in v1.23.0 description: >- Removed in v1.23.0. Every request to this path answers 410 Gone, whatever the method and whatever credential it carries. There is no replacement; the capability is gone rather than moved. The in-app bug reporter forwarded a description to a GitHub issue on the operator's behalf. It was removed with its tables; report through the project's issue tracker instead. Retiring a path is deliberate, so the answer says so rather than leaving a client to read a 404 as a broken deployment and retry forever. Treat 410 as terminal: drop the call, degrade the surface that depended on it, and do not offer a retry. responses: "410": description: The path was removed in v1.23.0 and will not answer again. `meta.errorCode` = `route.retired`, `meta.removedIn` carries the version, and `meta.replacedBy` carries the replacement path or null. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/bugreport/status: get: security: [] deprecated: true tags: - Retired summary: GET /api/bugreport/status — removed in v1.23.0 description: >- Removed in v1.23.0. Every request to this path answers 410 Gone, whatever the method and whatever credential it carries. There is no replacement; the capability is gone rather than moved. Told a client whether the in-app bug reporter was configured. Removed with the reporter itself. Retiring a path is deliberate, so the answer says so rather than leaving a client to read a 404 as a broken deployment and retry forever. Treat 410 as terminal: drop the call, degrade the surface that depended on it, and do not offer a retry. responses: "410": description: The path was removed in v1.23.0 and will not answer again. `meta.errorCode` = `route.retired`, `meta.removedIn` carries the version, and `meta.replacedBy` carries the replacement path or null. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/auth/me/doctor-report-prefs: get: security: [] deprecated: true tags: - Retired summary: GET /api/auth/me/doctor-report-prefs — removed in v1.32.39 description: >- Removed in v1.32.39. Every request to this path answers 410 Gone, whatever the method and whatever credential it carries. Use `/api/auth/me/report-selection` instead. The doctor report grew a per-leaf selection, and the old preference blob could not express it. The replacement stores the same intent against the current leaf catalogue. Retiring a path is deliberate, so the answer says so rather than leaving a client to read a 404 as a broken deployment and retry forever. Treat 410 as terminal: drop the call, degrade the surface that depended on it, and do not offer a retry. responses: "410": description: The path was removed in v1.32.39 and will not answer again. `meta.errorCode` = `route.retired`, `meta.removedIn` carries the version, and `meta.replacedBy` carries the replacement path or null. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" put: security: [] deprecated: true tags: - Retired summary: PUT /api/auth/me/doctor-report-prefs — removed in v1.32.39 description: >- Removed in v1.32.39. Every request to this path answers 410 Gone, whatever the method and whatever credential it carries. Use `/api/auth/me/report-selection` instead. The doctor report grew a per-leaf selection, and the old preference blob could not express it. The replacement stores the same intent against the current leaf catalogue. Retiring a path is deliberate, so the answer says so rather than leaving a client to read a 404 as a broken deployment and retry forever. Treat 410 as terminal: drop the call, degrade the surface that depended on it, and do not offer a retry. responses: "410": description: The path was removed in v1.32.39 and will not answer again. `meta.errorCode` = `route.retired`, `meta.removedIn` carries the version, and `meta.replacedBy` carries the replacement path or null. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/fhir/$everything: get: security: [] deprecated: true tags: - Retired summary: GET /api/fhir/$everything — removed in v1.32.39 description: >- Removed in v1.32.39. Every request to this path answers 410 Gone, whatever the method and whatever credential it carries. Use `/api/fhir/Patient/$everything` instead. The whole-record operation moved under the resource type it operates on, which is where FHIR R4 defines it and where the capability statement points. Retiring a path is deliberate, so the answer says so rather than leaving a client to read a 404 as a broken deployment and retry forever. Treat 410 as terminal: drop the call, degrade the surface that depended on it, and do not offer a retry. responses: "410": description: The path was removed in v1.32.39 and will not answer again. `meta.errorCode` = `route.retired`, `meta.removedIn` carries the version, and `meta.replacedBy` carries the replacement path or null. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/auth/check-user: post: security: [] deprecated: true tags: - Retired summary: POST /api/auth/check-user — removed in v1.38.20 description: >- Removed in v1.38.20. Every request to this path answers 410 Gone, whatever the method and whatever credential it carries. There is no replacement; the capability is gone rather than moved. Took an email address or a username and answered, to any caller and with no credential at all, whether an account existed here and whether it carried a passkey or a password. It was built to decide which field a sign-in screen shows first, which is not worth telling the world who has an account. Send the credential to POST /api/auth/login or run the passkey ceremony and read the answer; both refuse identically whether or not the account exists. Retiring a path is deliberate, so the answer says so rather than leaving a client to read a 404 as a broken deployment and retry forever. Treat 410 as terminal: drop the call, degrade the surface that depended on it, and do not offer a retry. responses: "410": description: The path was removed in v1.38.20 and will not answer again. `meta.errorCode` = `route.retired`, `meta.removedIn` carries the version, and `meta.replacedBy` carries the replacement path or null. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/auth/me/research-mode: get: security: [] deprecated: true tags: - Retired summary: GET /api/auth/me/research-mode — removed in v1.37.2 description: >- Removed in v1.37.2. Every request to this path answers 410 Gone, whatever the method and whatever credential it carries. There is no replacement; the capability is gone rather than moved. Research Mode was an opt-in for the estimated drug-level curve. The chart had stopped consulting the flag several releases earlier and painted for every account regardless, so the switch governed nothing. The curve is simply part of the medication page now and needs no preference read. Retiring a path is deliberate, so the answer says so rather than leaving a client to read a 404 as a broken deployment and retry forever. Treat 410 as terminal: drop the call, degrade the surface that depended on it, and do not offer a retry. responses: "410": description: The path was removed in v1.37.2 and will not answer again. `meta.errorCode` = `route.retired`, `meta.removedIn` carries the version, and `meta.replacedBy` carries the replacement path or null. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" post: security: [] deprecated: true tags: - Retired summary: POST /api/auth/me/research-mode — removed in v1.37.2 description: >- Removed in v1.37.2. Every request to this path answers 410 Gone, whatever the method and whatever credential it carries. There is no replacement; the capability is gone rather than moved. Research Mode was an opt-in for the estimated drug-level curve. The chart had stopped consulting the flag several releases earlier and painted for every account regardless, so the switch governed nothing. The curve is simply part of the medication page now and needs no preference read. Retiring a path is deliberate, so the answer says so rather than leaving a client to read a 404 as a broken deployment and retry forever. Treat 410 as terminal: drop the call, degrade the surface that depended on it, and do not offer a retry. responses: "410": description: The path was removed in v1.37.2 and will not answer again. `meta.errorCode` = `route.retired`, `meta.removedIn` carries the version, and `meta.replacedBy` carries the replacement path or null. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" delete: security: [] deprecated: true tags: - Retired summary: DELETE /api/auth/me/research-mode — removed in v1.37.2 description: >- Removed in v1.37.2. Every request to this path answers 410 Gone, whatever the method and whatever credential it carries. There is no replacement; the capability is gone rather than moved. Research Mode was an opt-in for the estimated drug-level curve. The chart had stopped consulting the flag several releases earlier and painted for every account regardless, so the switch governed nothing. The curve is simply part of the medication page now and needs no preference read. Retiring a path is deliberate, so the answer says so rather than leaving a client to read a 404 as a broken deployment and retry forever. Treat 410 as terminal: drop the call, degrade the surface that depended on it, and do not offer a retry. responses: "410": description: The path was removed in v1.37.2 and will not answer again. `meta.errorCode` = `route.retired`, `meta.removedIn` carries the version, and `meta.replacedBy` carries the replacement path or null. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" /api/settings/preview-features: get: security: [] deprecated: true tags: - Retired summary: GET /api/settings/preview-features — removed in v1.46.0 description: >- Removed in v1.46.0. Every request to this path answers 410 Gone, whatever the method and whatever credential it carries. There is no replacement; the capability is gone rather than moved. Stored per-account opt-ins to preview features, of which there was one, the topic overview pages. Those pages were dropped before they shipped, so the switch governed nothing; the route and its column are gone, and no preview is gated per account. Retiring a path is deliberate, so the answer says so rather than leaving a client to read a 404 as a broken deployment and retry forever. Treat 410 as terminal: drop the call, degrade the surface that depended on it, and do not offer a retry. responses: "410": description: The path was removed in v1.46.0 and will not answer again. `meta.errorCode` = `route.retired`, `meta.removedIn` carries the version, and `meta.replacedBy` carries the replacement path or null. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" patch: security: [] deprecated: true tags: - Retired summary: PATCH /api/settings/preview-features — removed in v1.46.0 description: >- Removed in v1.46.0. Every request to this path answers 410 Gone, whatever the method and whatever credential it carries. There is no replacement; the capability is gone rather than moved. Stored per-account opt-ins to preview features, of which there was one, the topic overview pages. Those pages were dropped before they shipped, so the switch governed nothing; the route and its column are gone, and no preview is gated per account. Retiring a path is deliberate, so the answer says so rather than leaving a client to read a 404 as a broken deployment and retry forever. Treat 410 as terminal: drop the call, degrade the surface that depended on it, and do not offer a retry. responses: "410": description: The path was removed in v1.46.0 and will not answer again. `meta.errorCode` = `route.retired`, `meta.removedIn` carries the version, and `meta.replacedBy` carries the replacement path or null. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" components: schemas: CreateCycleCustomSymptomRequest: type: object properties: label: type: string minLength: 1 maxLength: 40 icon: type: - string - "null" categoryKey: type: string const: custom required: - label description: Create-a-custom-symptom body. `label` is trimmed and must be 1..40 characters. `icon` is optional and must name one of the twenty allow-listed Lucide icons (unknown names 422 rather than falling back). `categoryKey` is reserved for a future per-symptom category choice and today accepts only the literal `custom`. The minted key, the sort order and the owning record all come from the server. UpdateCycleCustomSymptomRequest: type: object properties: label: type: string minLength: 1 maxLength: 40 icon: type: - string - "null" isActive: type: boolean description: "Partial custom-symptom edit; at least one of `label` / `icon` / `isActive` is required. `isActive: false` hides the symptom while every day log that references it stays intact; `isActive: true` brings it back, and the per-record cap is re-checked on the way in so hiding and re-enabling cannot be used to exceed it." CycleDayLogInput: type: object properties: date: type: string pattern: ^\d{4}-\d{2}-\d{2}$ flow: type: string enum: - NONE - SPOTTING - LIGHT - MEDIUM - HEAVY intermenstrualBleeding: type: boolean basalBodyTempC: type: number minimum: 30 maximum: 45 temperatureExcluded: type: boolean ovulationTest: type: string enum: - NEGATIVE - POSITIVE_LH_SURGE - ESTROGEN_SURGE - INDETERMINATE cervicalMucus: type: string enum: - DRY - STICKY - CREAMY - WATERY - EGG_WHITE cervixPosition: type: string enum: - LOW - HIGH cervixFirmness: type: string enum: - FIRM - SOFT cervixOpening: type: string enum: - CLOSED - OPEN sexualActivity: type: boolean protectedSex: type: - boolean - "null" pregnancyTest: type: string enum: - NEGATIVE - POSITIVE - INDETERMINATE progesteroneTest: type: string enum: - NEGATIVE - POSITIVE - INDETERMINATE contraceptive: type: string enum: - NONE - UNSPECIFIED - IMPLANT - INJECTION - IUD - INTRAVAGINAL_RING - ORAL - PATCH - EMERGENCY symptoms: maxItems: 40 type: array items: type: object properties: key: type: string minLength: 1 maxLength: 80 severity: anyOf: - type: integer minimum: 1 maximum: 4 - type: "null" required: - key note: type: string maxLength: 500 loggedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ source: default: MANUAL type: string enum: - MANUAL - APPLE_HEALTH externalId: type: string minLength: 1 maxLength: 120 required: - date - loggedAt description: "One day's cycle capture. `note` is encrypted at rest; every other field is queryable plaintext. UPSERT key: `(userId, source, externalId)` when externalId present, else `(userId, date)`. Shared by the single POST, the bulk drain, and the period shortcut." CycleDayLogPatchRequest: type: object properties: flow: anyOf: - type: string enum: - NONE - SPOTTING - LIGHT - MEDIUM - HEAVY - type: "null" intermenstrualBleeding: type: boolean basalBodyTempC: anyOf: - type: number minimum: 30 maximum: 45 - type: "null" temperatureExcluded: type: boolean ovulationTest: anyOf: - type: string enum: - NEGATIVE - POSITIVE_LH_SURGE - ESTROGEN_SURGE - INDETERMINATE - type: "null" cervicalMucus: anyOf: - type: string enum: - DRY - STICKY - CREAMY - WATERY - EGG_WHITE - type: "null" cervixPosition: anyOf: - type: string enum: - LOW - HIGH - type: "null" cervixFirmness: anyOf: - type: string enum: - FIRM - SOFT - type: "null" cervixOpening: anyOf: - type: string enum: - CLOSED - OPEN - type: "null" sexualActivity: type: boolean protectedSex: type: - boolean - "null" pregnancyTest: anyOf: - type: string enum: - NEGATIVE - POSITIVE - INDETERMINATE - type: "null" progesteroneTest: anyOf: - type: string enum: - NEGATIVE - POSITIVE - INDETERMINATE - type: "null" contraceptive: anyOf: - type: string enum: - NONE - UNSPECIFIED - IMPLANT - INJECTION - IUD - INTRAVAGINAL_RING - ORAL - PATCH - EMERGENCY - type: "null" symptoms: maxItems: 40 type: array items: type: object properties: key: type: string minLength: 1 maxLength: 80 severity: anyOf: - type: integer minimum: 1 maximum: 4 - type: "null" required: - key note: anyOf: - type: string maxLength: 500 - type: "null" loggedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ description: Partial day-log edit. Every field optional; `note` re-encrypts (explicit null clears it). `date` / `source` / `externalId` are immutable on update. CycleDayLogBulkRequest: type: object properties: entries: minItems: 1 maxItems: 500 type: array items: type: object properties: date: type: string pattern: ^\d{4}-\d{2}-\d{2}$ flow: type: string enum: - NONE - SPOTTING - LIGHT - MEDIUM - HEAVY intermenstrualBleeding: type: boolean basalBodyTempC: type: number minimum: 30 maximum: 45 temperatureExcluded: type: boolean ovulationTest: type: string enum: - NEGATIVE - POSITIVE_LH_SURGE - ESTROGEN_SURGE - INDETERMINATE cervicalMucus: type: string enum: - DRY - STICKY - CREAMY - WATERY - EGG_WHITE cervixPosition: type: string enum: - LOW - HIGH cervixFirmness: type: string enum: - FIRM - SOFT cervixOpening: type: string enum: - CLOSED - OPEN sexualActivity: type: boolean protectedSex: type: - boolean - "null" pregnancyTest: type: string enum: - NEGATIVE - POSITIVE - INDETERMINATE progesteroneTest: type: string enum: - NEGATIVE - POSITIVE - INDETERMINATE contraceptive: type: string enum: - NONE - UNSPECIFIED - IMPLANT - INJECTION - IUD - INTRAVAGINAL_RING - ORAL - PATCH - EMERGENCY symptoms: maxItems: 40 type: array items: type: object properties: key: type: string minLength: 1 maxLength: 80 severity: anyOf: - type: integer minimum: 1 maximum: 4 - type: "null" required: - key note: type: string maxLength: 500 loggedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ source: default: MANUAL type: string enum: - MANUAL - APPLE_HEALTH externalId: type: string minLength: 1 maxLength: 120 required: - date - loggedAt required: - entries description: Outbox / HealthKit drain. Up to 500 entries per call; wrapped in `withIdempotency`; rate-limited 60/min. Each entry upserts per the day-log key. CyclePeriodRequest: type: object properties: action: type: string enum: - start - end date: type: string pattern: ^\d{4}-\d{2}-\d{2}$ externalId: type: string minLength: 1 maxLength: 120 loggedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - action - date - loggedAt description: One-tap period boundary. `start` opens a new cycle (closing the prior), `end` stamps the current cycle's periodEndDate; both write a boundary day-log. CyclePrefsRequest: type: object properties: enabled: type: boolean goal: type: string enum: - GENERAL_HEALTH - AVOID_PREGNANCY - TRYING_TO_CONCEIVE - PERIMENOPAUSE - OFF secondarySymptom: type: string enum: - MUCUS - CERVIX rawChartMode: type: boolean predictionEnabled: type: boolean discreetNotifications: type: boolean sensitiveCategoryEncryption: type: boolean typicalCycleLength: anyOf: - type: integer minimum: 15 maximum: 60 - type: "null" typicalPeriodLength: anyOf: - type: integer minimum: 1 maximum: 15 - type: "null" lutealPhaseLength: anyOf: - type: integer minimum: 10 maximum: 16 - type: "null" description: Partial cycle-preferences deep-merge. `enabled` flips the feature gate (`cycleTrackingEnabled`). Omitted fields are left untouched. HealthRecordExportRequest: type: object properties: format: type: string enum: - pdf - fhir - package range: type: object properties: startDate: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ endDate: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ days: type: integer minimum: 1 maximum: 365 additionalProperties: false selection: type: object properties: v: type: number const: 2 leaves: maxItems: 106 type: array items: type: string minLength: 1 maxLength: 64 required: - v - leaves additionalProperties: false locale: type: string enum: - de - en - fr - es - it - pl - ko - nl - pt-BR practiceName: type: string maxLength: 120 includeCharts: type: boolean germanAtc: type: boolean required: - format - selection additionalProperties: false description: "v1.7.0 — health-record / doctor-handover export selection. `format` picks PDF, FHIR R4 document Bundle, or a combined zip package. `selection` is REQUIRED and carries the leaf inclusion list — membership is inclusion, absence is exclusion, and there is no server-side default; the retired grouped `sections` shape is a 422. No `userId` field — the user is always narrowed from the session/Bearer. The route is strict: unknown keys 422." CreateShareLinkRequest: type: object properties: label: type: string minLength: 1 maxLength: 120 rangeStart: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ rangeEnd: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" selection: type: object properties: v: type: number const: 2 leaves: maxItems: 106 type: array items: type: string minLength: 1 maxLength: 64 required: - v - leaves additionalProperties: false documentIds: maxItems: 50 type: array items: type: string minLength: 1 maxLength: 64 documentOnly: type: boolean expiresAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - label - rangeStart - expiresAt additionalProperties: false description: "v1.11.0 — owner request to mint a clinician share link to their own health record. `expiresAt` is required (absolute ISO instant) and capped at 90 days. `rangeStart`/`rangeEnd` freeze the reporting window (rangeEnd null = rolling). `selection` freezes which record leaves the link may serve, and omitting it means an empty scope rather than a default one. The `INSURANCE` leaf is refused outright (422, naming it): the share view has never decrypted the insurance number, and refusing it here makes that structural. `documentIds` freezes the documents the link carries; `documentOnly` mints a share that serves those documents and no record scope at all. A share link serves the rendered page, the documents frozen onto it, and the same record as a PDF or FHIR Bundle download under the token (`/c/{token}/report.pdf`, `/c/{token}/fhir`) — all behind the same gate and the same frozen selection. A share token is not a Bearer credential and reaches no other route. Strict: unknown keys 422." EncryptedExportRequest: type: object properties: passphrase: type: string minLength: 12 maxLength: 1024 description: User-chosen passphrase (>= 12 chars). Derives the archive key via Argon2id; never stored, never logged, no server-side recovery. required: - passphrase ProfileClaimSsoRequest: type: object properties: token: type: string minLength: 1 maxLength: 80 username: type: string minLength: 3 maxLength: 30 pattern: ^[a-zA-Z0-9_-]+$ required: - token - username additionalProperties: false description: "`token` is the `hlp_` token from the handover link, in the body; `username` follows the registration rules and may not start with `managed-`." ProfileClaimPreviewRequest: type: object properties: token: type: string minLength: 1 maxLength: 80 required: - token additionalProperties: false description: "`token` is the `hlp_` token from the handover link. Sent in the body, never in a URL." ProfileClaimRequest: type: object properties: token: type: string minLength: 1 maxLength: 80 username: type: string minLength: 3 maxLength: 30 pattern: ^[a-zA-Z0-9_-]+$ email: type: string maxLength: 254 format: email pattern: ^(?:[A-Za-z0-9_'+\-]+\.)*[A-Za-z0-9_'+\-]*[A-Za-z0-9_+-]@(?:[A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$ password: type: string minLength: 12 maxLength: 1024 required: - token - username - email - password additionalProperties: false description: "`token` is the `hlp_` token from the handover link, in the body. `username` follows the registration rules and may not start with `managed-`; `email` is required, because the account needs a way back in; `password` follows the registration password policy." LoginPasswordRequest: type: object properties: email: type: string minLength: 1 password: type: string minLength: 1 clientTransport: $ref: "#/components/schemas/ClientTransport" required: - email - password description: "Email-or-username login. The native-client flow returns a paired access + refresh token when X-Client-Type: native or the iOS UA prefix is present." ClientTransport: type: object properties: protocol: type: string enum: - http - https description: The scheme of the page the request comes from. host: type: string maxLength: 255 description: "`location.host` of that page: hostname and optional port." required: - protocol - host description: Sent by the web client only. When the server issues `Secure` session cookies and this says the page is on plain `http://` (and not a loopback host), the sign-in is refused with 409 `auth.session.insecure_transport` before any credential, ticket or code is checked or used, because the browser would drop the session cookie. MfaVerifyRequest: type: object properties: mfaTicket: type: string minLength: 1 description: The opaque single-use ticket from the login response. method: default: totp type: string enum: - totp - recovery code: type: string minLength: 6 maxLength: 32 rememberDevice: description: Opt in to trusting this device for 30 days — subsequent logins skip the second factor (the password is still required). type: boolean clientTransport: $ref: "#/components/schemas/ClientTransport" required: - mfaTicket - code description: Complete a second-factor login challenge and receive the same token bundle the password path issues. OidcNativeTokenRequest: type: object properties: code: type: string pattern: ^hlh_[A-Za-z0-9_-]{43}$ description: One-time handoff code from the healthlog://oidc-callback?code= redirect. codeVerifier: type: string pattern: ^[A-Za-z0-9\-._~]{43,128}$ description: The app's PKCE code_verifier (RFC 7636). required: - code - codeVerifier description: Exchange a one-time native OIDC handoff code (+ its PKCE verifier) for the standard native token bundle. NativeHandoffTokenRequest: type: object properties: code: type: string pattern: ^hlh_[A-Za-z0-9_-]{43}$ description: One-time handoff code from the healthlog://login-callback?code= redirect. codeVerifier: type: string pattern: ^[A-Za-z0-9\-._~]{43,128}$ description: The app's PKCE code_verifier (RFC 7636). required: - code - codeVerifier description: Exchange a one-time web-handoff code (+ its PKCE verifier) for the standard native token bundle. StepUpOptionsRequest: type: object properties: method: type: string enum: - passkey - webauthn description: "`passkey` begins an assertion against the account's primary passkeys; `webauthn` against its registered second-factor security keys." required: - method StepUpMintRequest: oneOf: - type: object properties: method: type: string const: password password: type: string minLength: 1 maxLength: 512 required: - method - password - type: object properties: method: type: string const: totp code: type: string pattern: ^\d{6}$ required: - method - code - type: object properties: method: type: string const: webauthn challengeId: type: string minLength: 1 credential: type: object properties: id: type: string rawId: type: string type: type: string const: public-key response: type: object propertyNames: type: string additionalProperties: {} authenticatorAttachment: type: string enum: - platform - cross-platform clientExtensionResults: type: object propertyNames: type: string additionalProperties: {} required: - id - rawId - type - response description: SimpleWebAuthn ceremony response payload. required: - method - challengeId - credential - type: object properties: method: type: string const: passkey challengeId: type: string minLength: 1 credential: type: object properties: id: type: string rawId: type: string type: type: string const: public-key response: type: object propertyNames: type: string additionalProperties: {} authenticatorAttachment: type: string enum: - platform - cross-platform clientExtensionResults: type: object propertyNames: type: string additionalProperties: {} required: - id - rawId - type - response description: SimpleWebAuthn ceremony response payload. required: - method - challengeId - credential description: Re-prove a factor to mint a single-use, token-bound step-up elevation. `password` reaches the same routes a plain cookie session reaches; `totp`, `webauthn`, and `passkey` additionally satisfy the fresh-factor routes (disable, recovery-code rotation, security-key removal), matching exactly the ceremonies for which the web stamps a session second-factor-verified. type: object MfaDisableRequest: type: object properties: code: type: string minLength: 6 maxLength: 32 method: default: totp type: string enum: - totp - recovery required: - code description: Disable the second factor. Requires fresh step-up plus a current TOTP or recovery code. MfaWebauthnRegisterVerifyRequest: type: object properties: challengeId: type: string minLength: 1 credential: type: object properties: id: type: string rawId: type: string type: type: string const: public-key response: type: object propertyNames: type: string additionalProperties: {} authenticatorAttachment: type: string enum: - platform - cross-platform clientExtensionResults: type: object propertyNames: type: string additionalProperties: {} required: - id - rawId - type - response description: SimpleWebAuthn ceremony response payload. name: type: string minLength: 1 maxLength: 64 required: - challengeId - credential description: Finish registering a WebAuthn security key as a second factor. MfaWebauthnRenameRequest: type: object properties: name: type: string minLength: 1 maxLength: 64 required: - name MfaWebauthnLoginOptionsRequest: type: object properties: mfaTicket: type: string minLength: 1 description: The opaque single-use ticket from the login response. required: - mfaTicket MfaWebauthnLoginVerifyRequest: type: object properties: mfaTicket: type: string minLength: 1 challengeId: type: string minLength: 1 credential: type: object properties: id: type: string rawId: type: string type: type: string const: public-key response: type: object propertyNames: type: string additionalProperties: {} authenticatorAttachment: type: string enum: - platform - cross-platform clientExtensionResults: type: object propertyNames: type: string additionalProperties: {} required: - id - rawId - type - response description: SimpleWebAuthn ceremony response payload. rememberDevice: description: Opt in to trusting this device for 30 days — subsequent logins skip the second factor (the password is still required). type: boolean clientTransport: $ref: "#/components/schemas/ClientTransport" required: - mfaTicket - challengeId - credential description: Complete a second-factor login challenge with a WebAuthn security key and receive the same token bundle the password path issues. PasskeyLoginVerifyRequest: type: object properties: challengeId: type: string minLength: 1 description: Server-issued WebAuthn challenge id. credential: type: object properties: id: type: string rawId: type: string type: type: string const: public-key response: type: object propertyNames: type: string additionalProperties: {} authenticatorAttachment: type: string enum: - platform - cross-platform clientExtensionResults: type: object propertyNames: type: string additionalProperties: {} required: - id - rawId - type - response description: SimpleWebAuthn-style assertion response payload. required: - challengeId - credential description: Passkey assertion verification. Same native-client token issuance as the password path. RefreshTokenRequest: type: object properties: refreshToken: type: string minLength: 1 description: Caller-presented refresh token (`hlr_<64hex>`). revoke: description: When true, revoke the supplied token instead of rotating. type: boolean required: - refreshToken description: Exchange a one-time-use refresh token for a fresh access + refresh pair. PasskeyLoginOptionsRequest: type: object properties: clientTransport: $ref: "#/components/schemas/ClientTransport" description: Optional. The web client sends its page transport so a sign-in that could not keep its cookie is refused before a challenge is issued. Any other caller may send no body at all. CreateMeasurementTokenRequest: type: object properties: name: type: string minLength: 1 maxLength: 100 expiresInDays: type: integer minimum: 1 maximum: 365 mirrorToAppleHealth: type: boolean required: - name description: "Mint a measurement-ingest Bearer. There is no `scope` field: this endpoint mints one shape and the permission array is a literal, so no request can widen it. `name` is what the token is listed under — name it after the thing you are pasting it into, because that is what you will be reading when you decide whether to revoke it. `expiresInDays` defaults to 365. `mirrorToAppleHealth` (default false) lets the HealthLog app write the token's readings into Apple Health; it can be changed later at `PATCH /api/tokens/{id}`." CreateWorkoutTokenRequest: type: object properties: name: type: string minLength: 1 maxLength: 100 expiresInDays: type: integer minimum: 1 maximum: 365 required: - name description: "Mint a workout-ingest Bearer. No `scope` field: the endpoint mints one shape. `name` is what the token is listed under; name it after the bridge it is pasted into. `expiresInDays` defaults to 365." CreateDocumentTokenRequest: type: object properties: name: type: string minLength: 1 maxLength: 100 expiresInDays: type: integer minimum: 1 maximum: 365 required: - name description: "Mint a document-upload Bearer. No `scope` field: the endpoint mints one shape. `name` is what the token is listed under; name it after the system it is pasted into. `expiresInDays` defaults to 365." UpdateMeasurementTokenRequest: type: object properties: mirrorToAppleHealth: type: boolean required: - mirrorToAppleHealth additionalProperties: false description: "The one setting a measurement token has. Strict: any other key is refused 422." RegisterRequest: type: object properties: email: type: string format: email pattern: ^(?:[A-Za-z0-9_'+\-]+\.)*[A-Za-z0-9_'+\-]*[A-Za-z0-9_+-]@(?:[A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$ username: type: string minLength: 3 maxLength: 30 pattern: ^[a-zA-Z0-9_-]+$ password: type: string minLength: 12 timezone: type: string maxLength: 64 inviteToken: type: string maxLength: 80 required: - email - username - password description: Account creation. `timezone` is a hint, not a requirement — an unknown zone is replaced with the operator default rather than refused. `inviteToken` is consulted whenever it is present, and is the admission requirement only while self-registration is closed. ChangePasswordRequest: type: object properties: currentPassword: type: string minLength: 1 newPassword: type: string minLength: 12 confirmPassword: type: string minLength: 1 required: - currentPassword - newPassword - confirmPassword description: Password rotation. `newPassword` and `confirmPassword` must match, and `newPassword` must differ from the current one. The strength floor is 12 characters plus a scored check against the account's own username and email. ProfileUpdateRequest: type: object properties: email: anyOf: - type: string format: email pattern: ^(?:[A-Za-z0-9_'+\-]+\.)*[A-Za-z0-9_'+\-]*[A-Za-z0-9_+-]@(?:[A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$ - type: "null" heightCm: anyOf: - type: number minimum: 50 maximum: 300 - type: "null" dateOfBirth: type: - string - "null" gender: description: Stored gender. Null or an empty string clears it; an omitted field is left untouched. anyOf: - type: string enum: - MALE - FEMALE - OTHER - type: "null" displayName: description: Trimmed before its bound is checked. Null, an empty string or only spaces clears it; an omitted field is left untouched. anyOf: - type: string maxLength: 80 - type: "null" locale: anyOf: - type: string enum: - de - en - type: "null" timezone: type: string minLength: 1 maxLength: 64 timeFormat: description: Hour-cycle display preference. AUTO follows the locale convention, H12 forces AM/PM, H24 forces 24-hour. type: string enum: - AUTO - H12 - H24 dateFormat: description: Date-order display preference. AUTO follows the locale convention, DMY pins day-month-year (dd.MM.yyyy), MDY pins month-day-year (MM/dd/yyyy), YMD pins ISO yyyy-MM-dd. type: string enum: - AUTO - DMY - MDY - YMD moodReminderEnabled: type: boolean fullName: anyOf: - type: string maxLength: 120 - type: "null" insurerName: anyOf: - type: string maxLength: 120 - type: "null" insuranceNumber: description: German KVNR. Empty/null clears it; mod-10 check enforced. type: - string - "null" insurerIkNumber: description: German insurer institution number (IKNR). Optional; empty/null clears it. A non-empty value must be exactly 9 digits (no checksum enforced). type: - string - "null" currentPassword: description: "The account password, sent only alongside a NEW `email`. Moving the account to a new address needs a fresh proof because single sign-on links an existing account by it: a browser session that signed in or completed a second factor in the last five minutes needs nothing more, otherwise this field must carry the current password. Without an accepted proof the address is not changed and comes back in `rejectedFields` as `reproof_required` or `reproof_failed` (or, when the address was the only field sent, 401 `auth.reproof.required` / `auth.reproof.failed`). On an account with a second factor the password is not accepted and not checked: only a second factor or passkey completed in the last five minutes counts (at sign-in or at POST /api/auth/reproof), and without one the address comes back as `second_factor_required` (or 401 `profile.update.emailSecondFactorRequired` when it was the only field). A token cannot change the address of such an account. Attempts draw on the account's shared re-proof budget of five per fifteen minutes. Never stored." type: string maxLength: 512 description: Partial profile update. Every field is optional and an omitted field is left untouched. An explicit null clears any nullable field; an empty string also clears `displayName`, `dateOfBirth`, `gender`, `fullName`, `insurerName`, `insuranceNumber` and `insurerIkNumber`. `email` is not among them — it is validated as an address, so send null to clear it. `locale`, `timezone`, `timeFormat` and `dateFormat` are not clearable. `userId` is never accepted — it is narrowed from the session/token. PasskeyReauthRequest: type: object properties: method: type: string enum: - passkey - webauthn description: "Which credential store to assert against: the account's primary passkeys, or its second-factor security keys." required: - method description: The re-proof arm of register-options. Recognised ONLY when `method` is the body's single key — adding any other property routes the request to the enrollment arm, where it will be read as a factor proof and almost certainly refused. PasskeyRegisterVerifyRequest: type: object properties: challengeId: type: string description: The challenge id returned by register-options. credential: type: object propertyNames: type: string additionalProperties: {} description: SimpleWebAuthn attestation response. `response.transports`, when the authenticator reports it, is stored as sent. required: - challengeId - credential description: Finish a passkey enrollment. The handler reads these two fields directly rather than through a Zod schema; a missing one is a 422. CreateMeasurementRequest: type: object properties: type: type: string enum: - WEIGHT - BLOOD_PRESSURE_SYS - BLOOD_PRESSURE_DIA - PULSE - BODY_FAT - SLEEP_DURATION - ACTIVITY_STEPS - BLOOD_GLUCOSE - TOTAL_BODY_WATER - BONE_MASS - OXYGEN_SATURATION - HEART_RATE_VARIABILITY - RESTING_HEART_RATE - ACTIVE_ENERGY_BURNED - FLIGHTS_CLIMBED - WALKING_RUNNING_DISTANCE - VO2_MAX - BODY_TEMPERATURE - FAT_FREE_MASS - FAT_MASS - MUSCLE_MASS - SKIN_TEMPERATURE - PULSE_WAVE_VELOCITY - VASCULAR_AGE - VISCERAL_FAT - AUDIO_EXPOSURE_ENV - AUDIO_EXPOSURE_HEADPHONE - TIME_IN_DAYLIGHT - WALKING_STEADINESS - AUDIO_EXPOSURE_EVENT - RESPIRATORY_RATE - BODY_MASS_INDEX - LEAN_BODY_MASS - WALKING_HEART_RATE_AVERAGE - WALKING_ASYMMETRY - WALKING_DOUBLE_SUPPORT - WALKING_STEP_LENGTH - WALKING_SPEED - CARDIO_RECOVERY - WRIST_TEMPERATURE - FALL_COUNT - SIX_MINUTE_WALK_DISTANCE - STAIR_ASCENT_SPEED - STAIR_DESCENT_SPEED - BREATHING_DISTURBANCES - IRREGULAR_RHYTHM_NOTIFICATION - HIGH_HEART_RATE_EVENT - LOW_HEART_RATE_EVENT - WALKING_STEADINESS_EVENT - BREATHING_DISTURBANCE_EVENT - RECOVERY_SCORE - STRESS_SCORE - STRAIN_SCORE - HRV_RMSSD - DAY_STRAIN - WORKOUT_STRAIN - SLEEP_PERFORMANCE - SLEEP_EFFICIENCY - SLEEP_CONSISTENCY - SLEEP_NEED - ENERGY_EXPENDITURE_KJ - AVERAGE_HEART_RATE - MAX_HEART_RATE - SLEEP_DISTURBANCE_COUNT - ANS_CHARGE - CARDIO_LOAD - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION - RESILIENCE - PHQ9_SCORE - GAD7_SCORE - GRIP_STRENGTH - PAIN_NRS - WAIST_CIRCUMFERENCE - WAIST_TO_HEIGHT - WHO5_SCORE - SCI_SCORE - EXERCISE_MINUTES - STAND_MINUTES - STAND_HOURS - MINDFUL_MINUTES - STATE_OF_MIND_VALENCE - AFIB_BURDEN - INDOOR_CO2 - INDOOR_TEMPERATURE - INDOOR_HUMIDITY value: type: number measuredAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ notes: type: string maxLength: 200 source: default: MANUAL type: string enum: - MANUAL - APPLE_HEALTH glucoseContext: type: string enum: - FASTING - POSTPRANDIAL - RANDOM - BEDTIME deviceType: anyOf: - type: string minLength: 1 maxLength: 32 - type: "null" symptomsPresent: type: boolean required: - type - value - measuredAt description: "Single-measurement ingest body. Plausibility-range guard runs server-side; out-of-range values fail 422. `glucoseContext` stays REQUIRED on a `BLOOD_GLUCOSE` row here: this is the hand-entry surface, where the person taking the reading knows whether it was fasting or after a meal. The bulk ingest paths (CSV import, JSON import, device sync) accept a contextless reading, because a sensor export classifies nothing per sample." NamedSleepStageEntry: type: object properties: type: type: string const: SLEEP_DURATION sleepStage: type: string enum: - IN_BED - ASLEEP - AWAKE - CORE - DEEP - REM startDate: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ endDate: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ measuredAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ value: type: number externalId: type: string minLength: 1 maxLength: 120 source: default: MANUAL type: string enum: - MANUAL - APPLE_HEALTH deviceType: anyOf: - type: string minLength: 1 maxLength: 32 - type: "null" required: - type - sleepStage description: "One sleep-stage segment with the stage named (since v1.39.1), for a bridge that does not speak HealthKit. Chosen whenever the object carries a string `sleepStage`. `type` is `SLEEP_DURATION`. The segment is either `startDate` + `endDate`, or `measuredAt` (its END) + `value` (its length in minutes); a `value` sent beside the dates must agree with them to within a minute. Stored as the batch route stores a HealthKit sleep sample: value in minutes, `measuredAt` at the end, the stage in `sleepStage`. Without an `externalId` the server derives one from the stage and both instants, so sending the same segment again is a `duplicate`, and a segment whose start moved is `updated` rather than added." AppleHealthBatchRequest: type: object properties: entries: minItems: 1 maxItems: 500 type: array items: $ref: "#/components/schemas/AppleHealthBatchEntry" syncTrigger: description: 'Diagnostic-only. Names what triggered this sync (foreground app open, background refresh, a push wake, or a user-initiated `manual` "Sync all" run, since v1.42). `backfill` (v1.43, ios #77; send it only when `GET /api/auth/me` reports `features.syncTriggerBackfill: true`, an older server refuses the whole batch with 422) names a history catch-up: it moves only `syncProgress` and the per-type `lastBackfillAt` in `GET /api/integrations/healthkit`, never `lastReceivedAt`, `lastTrigger`, `lastBackgroundAt`, `lastSyncTrigger`, `lastBackgroundSyncAt` or the sync checkpoint. Recorded on the ingest wide event and kept on the account as `lastSyncTrigger` — plus `lastBackgroundSyncAt` for a `background` or `push` trigger — both readable from `GET /api/integrations/healthkit`. Since v1.42 it is also recorded per measurement type the batch brought something new or changed for (Apple Health rows only), served as `lastReceivedAt` / `lastTrigger` / `lastNewSampleAt` on each `metricFreshness` entry of that read, plus `lastBackgroundAt` (since v1.42.4) for a `background` trigger. Since v1.45 a type the batch carried with duplicates only (an unchanged `stats:` re-post included) moves only its `lastSeenDuplicateAt`. It does not affect dedup, attribution, or how any sample is stored. Optional and backward-compatible: pre-#66 clients omit it.' type: string enum: - foreground - background - push - manual - backfill required: - entries description: Apple Health batch ingest. ≤500 entries per call; idempotent via `Idempotency-Key`. AppleHealthBatchEntry: type: object properties: hkIdentifier: type: string minLength: 1 maxLength: 120 description: HealthKit identifier (e.g. `HKQuantityTypeIdentifierBodyMass`). value: type: number description: Raw HealthKit reading in Apple's native unit; the server applies any canonical scaling at ingest. For a 10-minute heart-rate bucket (see `externalId`) this is the bucket's AVERAGE bpm; the bucket's spread rides `valueMin` / `valueMax`. valueMin: description: "v1.19.2 (iOS #34 extension) — the bucket's MINIMUM bpm for a 10-minute heart-rate bucket (see `externalId`). Persisted ONLY on a well-formed `stats:HKQuantityTypeIdentifierHeartRate:` row; ignored (stored null) on every other entry. Omit on a pre-v1.19.2 client — the bucket keeps the avg-only contract." type: number valueMax: description: "v1.19.2 (iOS #34 extension) — the bucket's MAXIMUM bpm for a 10-minute heart-rate bucket (see `externalId`). Persisted ONLY on a well-formed `stats:HKQuantityTypeIdentifierHeartRate:` row; ignored (stored null) on every other entry. Omit on a pre-v1.19.2 client — the bucket keeps the avg-only contract." type: number unit: type: string minLength: 1 maxLength: 60 description: "Apple's `HKUnit` string for `value`, captured for audit: the server records it and never validates `value` against it. `value` is read in Apple's own `HKUnit` for the identifier — `m` for a length, `kg` for body mass, `kcal` for energy, `degC` for temperature, a `fraction` for the percent-shaped ones (oxygen saturation, body fat) — and HealthLog applies its storage scaling after that. A waist circumference sent as `0.86` in `m` is stored as `86` `cm`; an oxygen saturation sent as `0.98` in `fraction` is stored as `98` `%`. Send the sample in Apple's unit, not in HealthLog's." startDate: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ endDate: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ sleepStage: description: HKCategoryValueSleepAnalysis codepoint; only for sleep samples. type: integer minimum: 0 maximum: 20 categoryValue: description: v1.10.0 — `HKCategoryValue` codepoint for an EVENT-class category sample (irregular-rhythm, high/low-HR, walking-steadiness, breathing-disturbance). Carries the device's own classification verdict / severity, which the server resolves to a stored `rhythmClassification`. HealthLog stores ONLY the device's result — it never re-classifies. Ignored for non-event identifiers. type: integer minimum: 0 maximum: 20 externalId: type: string minLength: 1 maxLength: 120 description: >- Dedup key for the `(userId, type, source, externalId)` composite index. Four shapes: • `HKSample.uuid` — a per-sample reading. First-write-wins: a re-post returns `duplicate` and the stored row is immutable. • `stats::` — a per-day cumulative total (Steps, Active Energy, Sleep Duration, Walking/Running Distance, Flights Climbed). A re-post OVERWRITES the row and returns `updated`. • `stats:HKQuantityTypeIdentifierHeartRate:` — v1.19.0 (iOS #34), refined to 10-minute granularity in v1.30.7, heart-rate bucket carrying the bucket's AVERAGE bpm as one PULSE row. `` is the ISO-8601 UTC 10-minute boundary — zeroed seconds/millis, minutes ∈ {00,10,20,30,40,50}, trailing `Z` (e.g. `2026-06-21T14:10:00.000Z`). A re-post OVERWRITES the row and returns `updated`, so iOS uploads ~144 rows/day instead of one per raw HR sample. A row that targets this prefix with a malformed bucket-start suffix is `skipped` with reason `malformed_hr_bucket_id`. • `stats::T` — v1.43 (ios #77), only when `GET /api/auth/me` reports `features.cumulativeHourBuckets: true`: one local hour's SUM of steps, active energy, walking and running distance or flights climbed, the date and the zero-padded hour being the local hour in the account's zone (`timezone` on `/api/auth/me`). `value` is the hour's sum in the unit of the day form, `startDate` the first instant of that local hour, `endDate` one hour later. Such a row is never a measurement and never changes the day total, which stays the day form: it feeds the intraday profile behind the same-time baseline (a day with at least four posted hours is built from them). A re-post overwrites (`updated`). Skipped with `hour_bucket_misaligned` when `startDate` is not the start of that local hour, `hour_bucket_unsupported_type` for any other identifier or a `MANUAL` / bridge entry, `hour_bucket_too_old` when the day is more than 400 days back. The id MUST be stable across app restarts. An object description or memory address (``, `0x12ab34cd`) is a new value on every launch, so it can never match its own earlier row; such an entry is `skipped` with reason `unstable_external_id` while the rest of the batch still lands. externalSourceVersion: type: string minLength: 1 maxLength: 120 source: description: Source attribution for the row. Defaults to `APPLE_HEALTH` when omitted. Send `MANUAL` to tag rows the user entered by hand on-device (e.g. the standalone adopt-on-pair backfill). Part of the `(userId, type, source, externalId)` dedup key, so the same externalId under a different source is a distinct row. `WITHINGS` / `IMPORT` are not accepted here. type: string enum: - APPLE_HEALTH - MANUAL deviceType: description: Device class mapped from `HKDevice.model`. Used by the analytics aggregator to break ties when the same source contributed multiple devices for the same day. Omit (or send null) on legacy clients — the server treats it as `unknown` and the picker falls through. anyOf: - type: string enum: - watch - band - ring - phone - scale - other - unknown - type: "null" required: - hkIdentifier - value - unit - startDate - endDate - externalId MeasurementsDeleteByExternalIdsRequest: type: object properties: externalIds: minItems: 0 maxItems: 500 type: array items: type: string minLength: 1 maxLength: 120 required: - externalIds description: iOS deletion-sync. Up to 500 externalIds per call; matching rows owned by other users are silently skipped (cross-user 404 guard). MeasurementsBulkDeleteRequest: type: object properties: ids: minItems: 1 maxItems: 200 type: array items: type: string minLength: 1 required: - ids description: 1..200 measurement ids, scoped to the caller. Forged / foreign ids are silently skipped (no existence leak). MeasurementsRestoreRequest: type: object properties: ids: minItems: 1 maxItems: 200 type: array items: type: string minLength: 1 required: - ids description: 1..200 measurement ids, scoped to the caller. Forged / foreign / not-deleted ids are silently skipped (no existence leak). CreateBatchWorkoutRequest: type: object properties: workouts: minItems: 1 maxItems: 100 type: array items: type: object properties: sportType: type: string enum: - walking - running - cycling - hiking - swimming - rowing - elliptical - stairClimber - yoga - mindAndBody - strength - hiit - dance - golf - badminton - tennis - basketball - soccer - crossTraining - mixedCardio - other startedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ endedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ totalEnergyKcal: type: number minimum: 0 maximum: 50000 totalDistanceM: type: number minimum: 0 maximum: 1000000 avgHeartRate: type: integer minimum: 20 maximum: 300 maxHeartRate: type: integer minimum: 20 maximum: 300 minHeartRate: type: integer minimum: 20 maximum: 300 stepCount: type: integer minimum: 0 maximum: 200000 elevationM: type: number minimum: -500 maximum: 10000 pauseDurationSec: type: integer minimum: 0 maximum: 86400 source: default: MANUAL type: string enum: - MANUAL - APPLE_HEALTH externalId: type: string maxLength: 128 externalSourceVersion: type: string maxLength: 64 metadata: type: object propertyNames: type: string additionalProperties: {} route: type: object properties: geometry: type: object properties: type: type: string const: LineString coordinates: minItems: 2 maxItems: 20000 type: array items: type: array prefixItems: - type: number minimum: -180 maximum: 180 - type: number minimum: -90 maximum: 90 items: type: number minItems: 2 required: - type - coordinates sampleTimestamps: type: array items: type: object properties: t: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ speedMs: type: number minimum: 0 maximum: 50 hr: type: integer minimum: 20 maximum: 300 required: - t required: - geometry samples: minItems: 1 maxItems: 30000 type: array items: type: object properties: t: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ hr: type: integer minimum: 20 maximum: 300 speedMs: type: number minimum: 0 maximum: 50 power: type: number minimum: 0 maximum: 3000 cadence: type: number minimum: 0 maximum: 400 required: - t required: - sportType - startedAt - endedAt required: - workouts description: "Typed workout batch ingest. Each entry is an HKWorkout-aligned record with an optional nested GeoJSON LineString route AND an optional route-independent per-workout heart-rate series (`samples`: `[{ t, hr?, speedMs?, power?, cadence? }]`, up to 30 000 points). The `samples` series is the strain-engine input for indoor workouts that have no GPS route. Up to 100 workouts per call; nested route geometry capped at 20 000 points. Withings server-to-server callers pass source: WITHINGS and ship no route (Withings reports aggregates only)." ActivityGoalsPatch: type: object properties: workoutsPerWeek: description: Workouts per week (Monday to Sunday). `null` clears the goal. anyOf: - type: integer minimum: 1 maximum: 28 - type: "null" activeMinutesPerWeek: description: "Active minutes per week: the larger of the day's exercise minutes and the day's workout minutes, summed over the week. `null` clears the goal." anyOf: - type: integer minimum: 10 maximum: 5000 - type: "null" stepsPerWeek: description: Steps per week. `null` clears the goal. anyOf: - type: integer minimum: 1000 maximum: 700000 - type: "null" stepsPerMonth: description: Steps per calendar month. `null` clears the goal. anyOf: - type: integer minimum: 1000 maximum: 3000000 - type: "null" additionalProperties: false description: The goals to change. A key that is present replaces the stored goal (`null` clears it); a key that is absent stays as it is. Unknown keys are refused. DeviceRegisterRequest: type: object properties: token: type: string minLength: 8 maxLength: 512 pattern: ^[A-Za-z0-9+/=._:-]+$ description: Generic device identifier (legacy; APNs token below). bundleId: type: string minLength: 1 maxLength: 128 locale: type: string minLength: 2 maxLength: 16 appVersion: type: string minLength: 1 maxLength: 32 model: type: string minLength: 1 maxLength: 64 apnsToken: description: Hex-encoded APNs device token. Must be paired with `apnsEnvironment`. type: string minLength: 8 maxLength: 256 pattern: ^[A-Fa-f0-9]+$ apnsEnvironment: description: Gateway the iOS client received `apnsToken` from. Server never auto-detects. type: string enum: - sandbox - production medicationDelivery: description: v1.7.0 per-device medication-delivery override. NULL / omitted = inherit the user-level roaming default. "server" forces server APNs for this device; "client" forces local. Stored + echoed; cron suppression stays user-level. anyOf: - type: string enum: - server - client - type: "null" liveActivityPushToken: description: v1.17.1 (#22) hex-encoded ActivityKit Live Activity push token (distinct from the device APNs token). The server addresses a Live Activity update / end push on a medication-intake mutation. Omitted = keep the prior value; null = clear it when the Activity ends. anyOf: - type: string minLength: 8 maxLength: 256 pattern: ^[A-Fa-f0-9]+$ - type: "null" required: - token - bundleId description: Native device registration. Re-registering an APNs token belonging to another user returns 409 (cross-user-hijack guard). MedicationApiEndpointPutRequest: type: object properties: enabled: type: boolean description: '`true` — and only the literal boolean true — enables. Anything else, including the string `"true"` and an absent key, disables.' required: - enabled ReminderPhaseConfigPutRequest: type: object properties: greenValue: type: integer minimum: 0 maximum: 1440 greenMode: type: string enum: - MINUTES - PERCENT yellowValue: type: integer minimum: 0 maximum: 1440 yellowMode: type: string enum: - MINUTES - PERCENT orangeValue: type: integer minimum: 0 maximum: 1440 orangeMode: type: string enum: - MINUTES - PERCENT redValue: type: integer minimum: 0 maximum: 1440 redMode: type: string enum: - MINUTES - PERCENT required: - greenValue - greenMode - yellowValue - yellowMode - orangeValue - orangeMode - redValue - redMode description: The complete phase configuration; every field is required, so this replaces rather than merges. Values are 0..1440 and each carries its own mode. CreateMedicationSideEffectRequest: type: object properties: entry: type: string enum: - NAUSEA - VOMITING - DIARRHEA - CONSTIPATION - ABDOMINAL_PAIN - HYPOGLYCEMIA_SYMPTOMS - DEHYDRATION - ANOREXIA - ELECTROLYTE_FATIGUE - INJECTION_REDNESS - INJECTION_SWELLING - INJECTION_BRUISING - INJECTION_INDURATION - BRAIN_FOG - DIZZINESS - LOW_MOOD - LOW_ENERGY - EARLY_SATIETY - GASTROPARESIS_LIKE - DYSGEUSIA - GALLBLADDER_DISCOMFORT severity: type: integer minimum: 1 maximum: 5 occurredAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ notes: anyOf: - type: string maxLength: 280 - type: "null" required: - entry - severity description: Log one side effect. `category` is NOT a field — the server derives it from `entry` through the authoritative taxonomy, and a category sent anyway is dropped rather than refused, so an older client cannot stamp a row with one that contradicts its entry. `occurredAt` defaults to now. `notes` is capped at 280 characters and is encrypted at rest. UpdateMedicationIntakeEventRequest: type: object properties: takenAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" skipped: type: boolean scheduledFor: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ forceSlotInstant: description: "Late-take override on edit: pin the edited dose onto the named scheduled slot instead of re-attributing by window band. Must be a real scheduled slot of this medication on its day (validated server-side); a non-slot instant returns 422. Explicit null unpins: the dose re-attributes by window band on its takenAt (ad-hoc when no band matches). Absent re-runs the default window-band attribution on the edited `takenAt`." anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" description: Edit a single intake event. v1.15.18 re-runs window-band slot attribution whenever `takenAt` or `skipped` change, snapping `scheduledFor` to the matched slot (or the take's own time when it falls in no window). `forceSlotInstant` overrides that to pin the take onto a named real slot (explicit null unpins, re-attributing by band); an explicit `scheduledFor` still wins when supplied directly. `takenAt` must not be in the future (5-minute clock-skew allowance) nor more than 5 years in the past; a `takenAt` before the medication's start date returns 422. BulkDeleteMedicationIntakeRequest: type: object properties: eventIds: minItems: 1 maxItems: 500 type: array items: type: string minLength: 1 maxLength: 64 required: - eventIds description: The intake events to tombstone, 1..500 ids. Ids that do not belong to this medication AND this record are silently DROPPED rather than refused, so a partial match succeeds with a lower `deleted` count than the ids sent. MedicationIntakeImportRequest: minItems: 1 maxItems: 1000 type: array items: type: object properties: datum: type: string description: "`YYYY-MM-DD`. Strict; no other form parses." uhrzeit: type: string description: "`HH:MM:SS`. Strict; no other form parses." required: - datum - uhrzeit description: The rows to import. May also be sent wrapped in an object, in which case the first array-valued property is used. Unrecognised per-row keys are dropped rather than refused. The pair must be a real calendar instant — 31 February is refused — and must not be in the future. BulkMedicationIntakeRequest: type: object properties: entries: minItems: 1 maxItems: 500 type: array items: $ref: "#/components/schemas/BulkMedicationIntakeEntry" syncTrigger: description: "Diagnostic-only, the same field `POST /api/measurements/batch` takes (since v1.42.4): what woke the client for this sync. `backfill` (v1.43, only when `features.syncTriggerBackfill` is `true`) marks a history catch-up, which moves only the `MEDICATION_DOSE` entry's `lastBackfillAt` and leaves its live stamps and the sync checkpoint alone. When the batch brings at least one new or changed entry with `source: \"APPLE_HEALTH\"` (`inserted` or `updated`), it is recorded on the `MEDICATION_DOSE` entry of `metricFreshness` in `GET /api/integrations/healthkit` as `lastTrigger`, and a `background` value also dates its `lastBackgroundAt`. Since v1.45 a batch whose Apple Health entries are all `duplicate` moves only that entry's `lastSeenDuplicateAt`. It changes no dose row. Optional; omitted records a null trigger." type: string enum: - foreground - background - push - manual - backfill required: - entries description: iOS SyncMode bulk intake backfill. 1–500 entries per call; idempotent via `Idempotency-Key` and per-entry `idempotencyKey` / `externalId`. BulkMedicationIntakeEntry: type: object properties: medicationId: type: string minLength: 1 scheduledFor: description: ISO instant; defaults to `takenAt` then now() when omitted. type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ takenAt: description: ISO instant of the take; omit + `skipped:false` = pending. type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ skipped: description: Default false. type: boolean idempotencyKey: type: string minLength: 1 maxLength: 128 injectionSite: description: Per-entry injection site; a disallowed site marks THIS entry skipped without failing the batch. type: string enum: - ABDOMEN_LEFT - ABDOMEN_RIGHT - ABDOMEN_UPPER_LEFT - ABDOMEN_UPPER_RIGHT - THIGH_LEFT - THIGH_RIGHT - UPPER_ARM_LEFT - UPPER_ARM_RIGHT forceSlotInstant: description: Pin a taken entry onto a named real scheduled slot; ignored on non-taken entries. type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ doseTaken: description: Per-entry dose override; persisted only on a taken entry. type: string minLength: 1 maxLength: 50 source: description: v1.28 — Apple Health dose-event import. Must be supplied together with `externalId`, and may only target a medication mirrored from Apple Health (else the whole batch 422s). type: string const: APPLE_HEALTH externalId: description: "The HealthKit dose-event UUID. Drives the idempotent re-sync dedup (first-write-wins per Apple dose). Must be stable across app restarts — an object description or memory address (``, `0x12ab34cd`) is a new value on every launch; such an entry is `skipped` with reason `unstable_external_id` while the rest of the batch still lands." type: string minLength: 1 maxLength: 128 required: - medicationId description: One bulk medication-intake entry. `source` + `externalId` are both-or-neither. CreateMedicationRequest: type: object properties: name: type: string minLength: 1 maxLength: 100 dose: type: string minLength: 1 maxLength: 50 category: anyOf: - type: string enum: - BLOOD_PRESSURE - VITAMIN - SUPPLEMENT - PAIN_RELIEF - ALLERGY - DIGESTIVE - THYROID - HORMONE - SKIN - SLEEP_AID - DIABETES - ANTIBIOTIC - MENTAL_HEALTH - OTHER - type: string pattern: ^custom:[0-9a-f-]{36}$ description: A built-in clinical category (BLOOD_PRESSURE, VITAMIN, SUPPLEMENT, PAIN_RELIEF, ALLERGY, DIGESTIVE, THYROID, HORMONE, SKIN, SLEEP_AID, DIABETES, ANTIBIOTIC, MENTAL_HEALTH, OTHER) or `custom:`, the key of one of the caller's own categories. treatmentClass: type: string enum: - GENERIC - GLP1 dosesPerUnit: type: integer minimum: 1 maximum: 1000 unitsPerDose: description: "Inventory units consumed per dose: above 0 and at most 100, with at most 4 decimal places. A whole number (2 tablets of 2 mg for a 4 mg dose), a split pill (0.5, thirds as 0.3333 / 0.6667), a whole number plus a fraction (1.5, 2.25) or a measured amount (0.8). Default 1. The intake consumption hook decrements this many units per taken dose; dose-derived readouts divide unit counts by it." type: number reorderLeadDays: description: Per-medication reorder lead time in days (0–60). The low-stock alert fires the supply early enough to reorder before the last dose. Omitted = inherit the user-level reorderLeadDays default (10). type: integer minimum: 0 maximum: 60 deliveryForm: type: string enum: - ORAL - INJECTION - OTHER trackInjectionSites: description: Per-medication injection-site tracking opt-in. Default false. Only meaningful for an INJECTION delivery form; when true the client prompts (skippably) for the site after a taken dose. type: boolean allowedInjectionSites: description: Per-medication allowed / preferred injection sites. Empty array = no per-medication restriction (every site offered). The effective pickable set is this list minus the user's global exclusion. maxItems: 8 type: array items: type: string enum: - ABDOMEN_LEFT - ABDOMEN_RIGHT - ABDOMEN_UPPER_LEFT - ABDOMEN_UPPER_RIGHT - THIGH_LEFT - THIGH_RIGHT - UPPER_ARM_LEFT - UPPER_ARM_RIGHT notificationsEnabled: type: boolean liveActivityEnabled: description: iOS Live Activity opt-in for this medication's reminders. Default false. The iOS client owns the ActivityKit lifecycle; the server only stores + echoes the flag. type: boolean criticalAlarmEnabled: description: iOS 26 AlarmKit critical-reminder opt-in. Default false. Critical alarms bypass the device mute switch / Focus; the server stores the preference only and hangs no server-side behaviour off it. type: boolean atcCode: description: Optional WHO ATC classification code (active-substance class, 7 chars, e.g. `A10BX10`). User/clinician-asserted; never machine-guessed. Emitted on the FHIR `medicationCodeableConcept` under `http://www.whocc.no/atc`. NULL clears it; absent leaves it untouched. anyOf: - type: string pattern: ^[A-Z]\d{2}[A-Z]{2}\d{2}$ - type: "null" rxNormCode: description: Optional RxNorm RxCUI (numeric, US identifier, e.g. `2601723`). Secondary coding emitted under `http://www.nlm.nih.gov/research/umls/rxnorm` alongside any ATC code, never instead of the free-text name. NULL clears it; absent leaves it untouched. anyOf: - type: string maxLength: 20 pattern: ^\d+$ - type: "null" externalSource: description: "Marks the medication as MIRRORED from an external medication list. Only `APPLE_HEALTH` (the iOS 26+ HealthKit Medications sync) is accepted. Must be supplied together with `externalId`. A mirrored medication is source-exclusive: dose events from that source attach only to it, and the app's own dose-logging UI stays off it." type: string const: APPLE_HEALTH externalId: description: "Stable external identifier of the mirrored medication (the HealthKit `medicationConceptIdentifier`, max 128 chars). Must be supplied together with `externalSource`. Idempotent: re-posting the same pair returns the existing medication with status 200 instead of minting a duplicate. The value MUST be identical across app restarts: an object description or memory address (``, `0x12ab34cd`) is a new value on every launch, never matches its own earlier row, and is refused with 422 rather than minting a duplicate mirror." type: string minLength: 1 maxLength: 128 startsOn: description: Date the medication course begins (ISO `YYYY-MM-DD`). Anchors RRULE BYDAY / BYMONTHDAY patterns and the rolling-interval countdown's first window. NULL = active from creation (the legacy implicit behaviour). Required when `oneShot` is true. anyOf: - type: string format: date pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ - type: "null" endsOn: description: Date the medication course ends (ISO `YYYY-MM-DD`). NULL = no end date (chronic). When `oneShot` is true the route normalises `endsOn` to equal `startsOn`. anyOf: - type: string format: date pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ - type: "null" oneShot: description: Single-administration medication (e.g. flu shot, post-op single dose). When true the medication has at most one schedule with no `rrule` / `rollingIntervalDays`, and `active` auto-flips to false once the dose is logged (non-skipped). type: boolean asNeeded: description: "As-needed (PRN) medication (pain relief, rescue inhaler). When true the medication carries NO schedules (supplying any schedule entry is a 422): it is never due, never reminded, and excluded from compliance rates/streaks, but intakes still log (ad-hoc), inventory still consumes, and the history renders. Stays active indefinitely. Mutually exclusive with `oneShot`." type: boolean trackIntake: description: "v1.39.1 — intake tracking. Default true. When false the medication keeps its dose, dates and schedules as information, but nothing is ever due: no reminder on any channel, no projected slot, no entry on the today list or the doses card, and no adherence figure anywhere. Existing intake history stays stored. Switching it back on resumes from that moment; the time it was off never counts as missed. Omitted on update leaves the current value. On a medication whose tracking is off, an update that does not name `trackIntake` leaves its stored schedule alone: `schedules`, `asNeeded` and `oneShot` in such a request are ignored (a client that does not know the field cannot have seen the stored schedule, which is served in `recordedSchedules`). Name `trackIntake` to change the schedule of such a medication." type: boolean autoMarkTaken: description: "v1.42.2 — record each scheduled dose as taken automatically. Default false. When true the server records a taken intake (`autoRecorded: true`) for every scheduled slot once its time has passed, from the moment the option was switched on (`autoMarkTakenSince`); slots before that are never filled. A slot the person already took, skipped or removed is left alone, and the person can still change or skip any dose by hand. While on, no medication reminder is sent for this medication on any channel; a client that arms local reminders should not arm them either. Has no effect on an as-needed medication or one whose intake tracking is off. Omitted on update leaves the current value." type: boolean schedules: type: array items: $ref: "#/components/schemas/MedicationScheduleInput" required: - name - dose description: 'Create-medication body. The route enforces the v1.5 cross-field invariants on top of the per-schedule `rrule_xor_rolling` Zod refine: a `oneShot:true` medication may carry at most one schedule and that schedule must not declare a recurrence; `endsOn` is normalised to equal `startsOn` for one-shot doses; a recurring schedule with no `rrule`, `rollingIntervalDays`, or legacy `daysOfWeek` defaults to `rrule = "FREQ=DAILY"`; and `timesOfDay` is dual-written from `windowStart` when the caller omits it. v1.16.11 — `asNeeded: true` creates a PRN medication with ZERO schedules (`schedules` must be absent or empty, 422 otherwise); a scheduled medication still requires at least one schedule entry. v1.28 — `externalSource` + `externalId` (both-or-neither) mark the medication as mirrored from Apple Health; re-posting the same pair is idempotent and returns the existing medication with status 200.' MedicationScheduleInput: type: object properties: windowStart: type: string pattern: ^([01]\d|2[0-3]):([0-5]\d)$ description: Legacy single-time-of-intake (HH:mm, user local). v1.5 keeps the field for backwards compatibility with pre-wizard iOS clients; the new `timesOfDay` array supersedes it. windowEnd: type: string pattern: ^([01]\d|2[0-3]):([0-5]\d)$ description: Legacy reminder-window upper bound (HH:mm). Used to derive the late-classification grace span when `reminderGraceMinutes` is null. label: description: Optional human label (e.g. "Morning", "Evening"). type: string maxLength: 50 dose: description: Per-schedule dose override. NULL means the schedule inherits `Medication.dose`. type: string maxLength: 50 unitsPerDose: description: Per-schedule inventory units consumed per dose (#219). Above 0 and at most 100 with at most 4 decimal places (1, 0.5, 1.5). Omitted / NULL means the schedule inherits `Medication.unitsPerDose`. Lets one medication decrement a different tablet count at different times of day (a whole tablet in the morning, a half at noon). type: number daysOfWeek: description: Legacy day-of-week filter (0=Sunday..6=Saturday). v1.5 reads new writes through `rrule` first; this field is preserved for pre-v1.5 rows and is the input the route serialises into the persisted `days_of_week` string. type: array items: type: integer minimum: 0 maximum: 6 intervalWeeks: description: Legacy multi-week stride (1..4). Bi-weekly + tri-weekly were broken in the pre-v1.5 reminder worker; new writes encode the same intent via `rrule` (e.g. `FREQ=WEEKLY;INTERVAL=2;BYDAY=WE`). type: integer minimum: 1 maximum: 4 timesOfDay: description: v1.5 first-class points-in-time the dose is taken (HH:mm, user local). Up to 8 entries. Absent or empty means the route stamps `[windowStart]` so the new engine always sees a populated array. maxItems: 8 type: array items: type: string pattern: ^([01]\d|2[0-3]):([0-5]\d)$ reminderGraceMinutes: description: Reminder grace window in minutes. Caps at 24h. NULL falls back to the legacy `windowEnd - windowStart` span. type: integer minimum: 1 maximum: 1440 rrule: description: "RFC 5545 RRULE string (subset). Use for daily / weekly-with-BYDAY / multi-week / monthly / yearly cadences. Mutually exclusive with `rollingIntervalDays`. Examples: `FREQ=DAILY`, `FREQ=WEEKLY;BYDAY=MO,WE,FR`, `FREQ=WEEKLY;INTERVAL=2;BYDAY=WE`, `FREQ=MONTHLY;BYMONTHDAY=1`, `FREQ=YEARLY;BYMONTH=1;BYMONTHDAY=1`." type: string maxLength: 200 pattern: ^FREQ=(?:DAILY|WEEKLY|MONTHLY|YEARLY)(?:;(?:INTERVAL=\d{1,3}|BYDAY=(?:MO|TU|WE|TH|FR|SA|SU)(?:,(?:MO|TU|WE|TH|FR|SA|SU))*|BYMONTHDAY=-?\d{1,2}(?:,-?\d{1,2})*|BYMONTH=\d{1,2}(?:,\d{1,2})*|COUNT=\d{1,4}|UNTIL=\d{8}T\d{6}Z))*$ rollingIntervalDays: description: Flexible-rolling interval in days, counted forward from the latest `MedicationIntakeEvent.takenAt` (the dose re-anchors when logged). Mutually exclusive with `rrule`. Range 1..365. type: integer minimum: 1 maximum: 365 scheduleType: description: Schedule type. SCHEDULED (default) = rrule / rolling / legacy cadence. PRN = as-needed (never projected, reminded, or counted in compliance expected; still loggable via the intake route). CYCLIC = N weeks on / M weeks off, gating whichever inner cadence the rrule / legacy fields describe. type: string enum: - SCHEDULED - PRN - CYCLIC cyclicOnWeeks: description: Cyclic "on" weeks (1..52). Required when `scheduleType` is CYCLIC; ignored otherwise. type: integer minimum: 1 maximum: 52 cyclicOffWeeks: description: Cyclic "off" weeks (0..52). Required when `scheduleType` is CYCLIC; ignored otherwise. type: integer minimum: 0 maximum: 52 doseWindows: description: Per-dose on-time intake windows. Each `{ timeOfDay, start, end }` HH:mm triple sets the explicit on-time band for the matching dose time; a dose time with no entry keeps the symmetric ±1h default. `timeOfDay` must match one of `timesOfDay` (or `windowStart`); `start <= end`. Up to 8 entries. Absent leaves every slot on the default derivation. The late tail stays cadence-derived. maxItems: 8 type: array items: $ref: "#/components/schemas/DoseWindowEntry" required: - windowStart - windowEnd description: Single schedule entry on a medication. v1.5 introduces `timesOfDay`, `rrule`, `rollingIntervalDays`, and `reminderGraceMinutes` as first-class fields; `windowStart`, `windowEnd`, `daysOfWeek`, and `intervalWeeks` are preserved through the v1.5.x line for backwards compatibility. **`rrule` and `rollingIntervalDays` are mutually exclusive** — supplying both fails 422 (`rrule_xor_rolling`). The DB enforces the same invariant via a CHECK constraint. DoseWindowEntry: type: object properties: timeOfDay: type: string pattern: ^([01]\d|2[0-3]):([0-5]\d)$ description: The dose time this window applies to (matches a `timesOfDay` entry). start: type: string pattern: ^([01]\d|2[0-3]):([0-5]\d)$ description: On-time band lower bound (HH:mm, user local). end: type: string pattern: ^([01]\d|2[0-3]):([0-5]\d)$ description: On-time band upper bound (HH:mm, user local). Must be >= `start`. required: - timeOfDay - start - end description: One explicit per-dose on-time intake window. `timeOfDay` matches a schedule dose time; `[start, end]` (HH:mm, user local, `start <= end`) is the on-time band. Outside it the cadence-derived late tail applies, then ad-hoc. CreateMedicationCategoryRequest: type: object properties: label: type: string minLength: 1 maxLength: 40 required: - label description: v1.40 — name a new medication category. The label is trimmed, 1 to 40 characters, and encrypted at rest. UpdateMedicationCategoryRequest: type: object properties: label: type: string minLength: 1 maxLength: 40 sortOrder: type: integer minimum: 0 maximum: 1000 isActive: type: boolean description: "v1.40 — rename, reorder or hide a custom medication category. At least one field. Hiding (`isActive: false`) removes it from the picker; medications filed under it keep it." CreateMedicationCourseRequest: type: object properties: startsOn: description: First day of the course (ISO `YYYY-MM-DD`, inclusive, the person's calendar). type: string format: date pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ endsOn: description: Last day (inclusive). NULL or absent = open; only the latest course may be open. anyOf: - type: string format: date pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ - type: "null" note: description: Why this course, in the person's words. Encrypted at rest. anyOf: - type: string maxLength: 280 - type: "null" required: - startsOn description: v1.40 — start a course. Refused when it shares a day with another course (422 `medications.course.overlap`), when it would sit after a course that has not ended (422 `medications.course.currentOpen`), on a one-time medication that already has one (422 `medications.course.oneShot`), or when it ends before it starts (422 `medications.course.invalidRange`). UpdateMedicationCourseRequest: type: object properties: startsOn: type: string format: date pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ endsOn: anyOf: - type: string format: date pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ - type: "null" note: anyOf: - type: string maxLength: 280 - type: "null" description: v1.40 — edit a course field by field. Same refusals as the create. MedicationListLayoutPutBody: type: object properties: version: type: number const: 1 view: description: Which presentation /medications renders in. Default "cards". Optional on PUT — when omitted the stored value is preserved (preserve-when-absent, like `chartOverlayPrefs` on the dashboard layout). Always present on responses. type: string enum: - cards - table order: description: User-defined manual medication order (medication ids, first = top), shared by both views. Display-only — unknown / deleted ids are ignored at render time, never 422. Optional on PUT (preserve-when-absent); always present on responses. maxItems: 200 type: array items: type: string minLength: 1 maxLength: 64 baseUpdatedAt: description: "Optimistic-concurrency base token: the `updatedAt` the client last read for this resource. Omit it for the legacy unconditional write (older clients are unaffected). When present, the write is guarded on the stored row still carrying this exact value — a stale token fails with 409 and changes nothing. A present-but-unparseable value fails with 422 and `meta.errorCode` = `invalid_base_updated_at` — note that this is NOT the unconditional write: sending `null` or a malformed string is rejected rather than treated as an omitted token. Treat as opaque: only ever echo a server-returned value, never parse or synthesise it." type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - version description: PUT body for the medications list presentation — the presentation fields plus the optional optimistic-concurrency base token (`baseUpdatedAt`). Omit the token for the legacy unconditional write. UpdateMedicationRequest: type: object properties: name: type: string minLength: 1 maxLength: 100 dose: type: string minLength: 1 maxLength: 50 category: anyOf: - type: string enum: - BLOOD_PRESSURE - VITAMIN - SUPPLEMENT - PAIN_RELIEF - ALLERGY - DIGESTIVE - THYROID - HORMONE - SKIN - SLEEP_AID - DIABETES - ANTIBIOTIC - MENTAL_HEALTH - OTHER - type: string pattern: ^custom:[0-9a-f-]{36}$ description: A built-in clinical category (BLOOD_PRESSURE, VITAMIN, SUPPLEMENT, PAIN_RELIEF, ALLERGY, DIGESTIVE, THYROID, HORMONE, SKIN, SLEEP_AID, DIABETES, ANTIBIOTIC, MENTAL_HEALTH, OTHER) or `custom:`, the key of one of the caller's own categories. treatmentClass: type: string enum: - GENERIC - GLP1 dosesPerUnit: anyOf: - type: integer minimum: 1 maximum: 1000 - type: "null" unitsPerDose: description: "Inventory units consumed per dose: above 0 and at most 100, with at most 4 decimal places (1, 0.5, 1.5, 2.25). The intake consumption hook decrements this many units per taken dose; already-stamped intake events keep their recorded consumption." type: number reorderLeadDays: description: Per-medication reorder lead time in days (0–60). null clears the override and reverts to the user-level reorderLeadDays default. anyOf: - type: integer minimum: 0 maximum: 60 - type: "null" deliveryForm: type: string enum: - ORAL - INJECTION - OTHER trackInjectionSites: description: Per-medication injection-site tracking opt-in. Only meaningful for an INJECTION delivery form. Set false to deactivate tracking. type: boolean allowedInjectionSites: description: Per-medication allowed / preferred injection sites. Empty array clears the restriction. The effective pickable set subtracts the user's global exclusion (deny wins). maxItems: 8 type: array items: type: string enum: - ABDOMEN_LEFT - ABDOMEN_RIGHT - ABDOMEN_UPPER_LEFT - ABDOMEN_UPPER_RIGHT - THIGH_LEFT - THIGH_RIGHT - UPPER_ARM_LEFT - UPPER_ARM_RIGHT active: type: boolean notificationsEnabled: type: boolean liveActivityEnabled: description: iOS Live Activity opt-in for this medication's reminders. The iOS client owns the ActivityKit lifecycle; the server only stores + echoes the flag. type: boolean criticalAlarmEnabled: description: iOS 26 AlarmKit critical-reminder opt-in. Critical alarms bypass the device mute switch / Focus; the server stores the preference only. type: boolean atcCode: description: Optional WHO ATC classification code (active-substance class, 7 chars, e.g. `A10BX10`). User/clinician-asserted; never machine-guessed. Emitted on the FHIR `medicationCodeableConcept` under `http://www.whocc.no/atc`. NULL clears it; absent leaves it untouched. anyOf: - type: string pattern: ^[A-Z]\d{2}[A-Z]{2}\d{2}$ - type: "null" rxNormCode: description: Optional RxNorm RxCUI (numeric, US identifier, e.g. `2601723`). Secondary coding emitted under `http://www.nlm.nih.gov/research/umls/rxnorm` alongside any ATC code, never instead of the free-text name. NULL clears it; absent leaves it untouched. anyOf: - type: string maxLength: 20 pattern: ^\d+$ - type: "null" startsOn: description: Date the medication course begins (ISO `YYYY-MM-DD`). Anchors RRULE BYDAY / BYMONTHDAY patterns and the rolling-interval countdown's first window. NULL = active from creation (the legacy implicit behaviour). Required when `oneShot` is true. anyOf: - type: string format: date pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ - type: "null" endsOn: description: Date the medication course ends (ISO `YYYY-MM-DD`). NULL = no end date (chronic). When `oneShot` is true the route normalises `endsOn` to equal `startsOn`. anyOf: - type: string format: date pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ - type: "null" oneShot: description: Single-administration medication (e.g. flu shot, post-op single dose). When true the medication has at most one schedule with no `rrule` / `rollingIntervalDays`, and `active` auto-flips to false once the dose is logged (non-skipped). type: boolean asNeeded: description: "As-needed (PRN) medication (pain relief, rescue inhaler). When true the medication carries NO schedules (supplying any schedule entry is a 422): it is never due, never reminded, and excluded from compliance rates/streaks, but intakes still log (ad-hoc), inventory still consumes, and the history renders. Stays active indefinitely. Mutually exclusive with `oneShot`." type: boolean trackIntake: description: "v1.39.1 — intake tracking. Default true. When false the medication keeps its dose, dates and schedules as information, but nothing is ever due: no reminder on any channel, no projected slot, no entry on the today list or the doses card, and no adherence figure anywhere. Existing intake history stays stored. Switching it back on resumes from that moment; the time it was off never counts as missed. Omitted on update leaves the current value. On a medication whose tracking is off, an update that does not name `trackIntake` leaves its stored schedule alone: `schedules`, `asNeeded` and `oneShot` in such a request are ignored (a client that does not know the field cannot have seen the stored schedule, which is served in `recordedSchedules`). Name `trackIntake` to change the schedule of such a medication." type: boolean autoMarkTaken: description: "v1.42.2 — record each scheduled dose as taken automatically. Default false. When true the server records a taken intake (`autoRecorded: true`) for every scheduled slot once its time has passed, from the moment the option was switched on (`autoMarkTakenSince`); slots before that are never filled. A slot the person already took, skipped or removed is left alone, and the person can still change or skip any dose by hand. While on, no medication reminder is sent for this medication on any channel; a client that arms local reminders should not arm them either. Has no effect on an as-needed medication or one whose intake tracking is off. Omitted on update leaves the current value." type: boolean schedules: type: array items: $ref: "#/components/schemas/MedicationScheduleInput" reminderGraceMinutes: description: "Detail-page bridge: primary-schedule reminder-window in minutes. The route maps the value onto the primary schedule's `reminderGraceMinutes` field; ignored when a full `schedules` array is also supplied." anyOf: - type: integer minimum: 0 maximum: 1440 - type: "null" description: "Update-medication body. Every field is optional; omitted fields are left untouched. Supplying `schedules` REPLACES the medication's full schedule list (the route deletes existing rows before re-creating). Flipping `active` to false records the current timestamp on `pausedAt`; flipping back to true clears it. v1.5 invariants on the `schedules` array match the create path. v1.16.11 — `asNeeded: true` requires the medication to end schedule-less: supply `schedules: []` to clear an existing plan (any schedule entry is a 422); an empty `schedules` array without `asNeeded: true` is likewise a 422." MedicationIntakeRequest: type: object properties: medicationId: type: string minLength: 1 description: Server-narrowed from the URL path. The route layer overwrites whatever the body supplies before Zod parsing so a caller cannot log an intake against another medication. scheduledFor: description: Slot the dose belongs to. Defaults to `takenAt` (or `now()` when both are absent) so the compliance pairing logic can pin the dose to a schedule slot. type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ takenAt: description: When the dose was actually taken. NULL when `skipped` is true; defaults to `now()` for non-skipped intakes. Must not lie in the future (5-minute clock-skew allowance) nor more than 5 years in the past. type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ skipped: default: false description: True to log a skipped slot (no consumption, no inventory decrement, one-shot medications stay active). type: boolean idempotencyKey: description: Caller-issued de-dup key. A second POST with the same key returns the original event without creating a new row. type: string maxLength: 128 injectionSite: description: Optional injection site for a taken dose. Honoured only when the medication is an INJECTION with site-tracking enabled; validated against the medication's effective allowed set (per-medication allowed sites minus the user's global exclusion). A disallowed site returns 422. Omit to record the dose without a site. type: string enum: - ABDOMEN_LEFT - ABDOMEN_RIGHT - ABDOMEN_UPPER_LEFT - ABDOMEN_UPPER_RIGHT - THIGH_LEFT - THIGH_RIGHT - UPPER_ARM_LEFT - UPPER_ARM_RIGHT forceSlotInstant: description: "Late-take override: pin this taken dose onto the named scheduled slot instead of orphaning it to an ad-hoc row. Must be a real scheduled slot of this medication on its day (validated server-side against the dose-window band anchors); an instant that is not a slot returns 422. Absent applies the default window-band attribution." type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ doseTaken: description: Dose actually consumed for THIS intake when it deviates from (or documents) the medication's configured dose, e.g. a half tablet or a titration step. Free text, max 50 characters. Omit to record the take under the medication's configured dose. type: string minLength: 1 maxLength: 50 required: - medicationId description: Per-medication intake log body. Idempotent via `idempotencyKey`; the server also dedupes by a 60-second sliding window when the key is absent. Non-skipped intakes auto-decrement pen inventory (best-effort), refresh the per-day compliance rollup, and — for one-shot medications — flip `active` to false. The optional `injectionSite` is persisted only for an INJECTION medication with site-tracking enabled and is validated against the medication's effective allowed set (422 on a disallowed value). CreateMedicationInventoryItemRequest: type: object properties: unitsTotal: type: number minimum: 1 maximum: 1000 description: Units the container ships with (tablets / ampoules / puffs; 1–1000, fractional allowed for split-pill packs). Dose-derived readouts divide by the medication's `unitsPerDose`. containerType: description: Kind of physical container (PEN / AMPOULE / BLISTER / INHALER / BOTTLE / OTHER). Defaults to OTHER. PEN and AMPOULE are the sealed reservoirs the first dose breaches, so they carry a 30-day post-opening window; every other kind expires only on its printed date. type: string enum: - PEN - AMPOULE - BLISTER - INHALER - BOTTLE - OTHER printedExpiry: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" purchasedAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" manufacturer: description: Marketing-authorisation holder / maker as printed on the carton. Free text, max 120 chars. anyOf: - type: string maxLength: 120 - type: "null" doseStrength: description: Strength as printed on the container, e.g. "5 mg/0.5 ml". Free text, max 60 chars — deliberately not split into a number + unit because pens state strength per dose, per ml, or per cartridge. anyOf: - type: string maxLength: 60 - type: "null" notes: anyOf: - type: string maxLength: 200 - type: "null" required: - unitsTotal description: Register a new supply container (pen / blister pack / bottle). `unitsTotal` counts UNITS (1–1000); the item starts ACTIVE with `unitsRemaining = unitsTotal` and the intake consumption hook decrements it per taken dose. UpdateMedicationInventoryItemRequest: type: object properties: markAsFirstUseAt: description: Opening date of the container (the user opened it without logging an intake). ACTIVE flips to IN_USE. `null` clears it — the container reads as unopened again, which is how a wrongly auto-opened row is corrected. For a PEN or an AMPOULE this also starts / stops the 30-day in-use clock; other container kinds carry no such clock and expire only on their printed date. anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" markAsUsedUp: description: "Terminal override: zero the remaining units and mark the container USED_UP (physically discarded)." type: boolean printedExpiry: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" unitsRemaining: description: Absolute remaining-unit correction (0–1000, fractional allowed). Clamped server-side to the item's `unitsTotal`; the canonical state machine re-derives the state (0 ⇒ USED_UP). type: number minimum: 0 maximum: 1000 manufacturer: description: Marketing-authorisation holder / maker as printed on the carton. Free text, max 120 chars. anyOf: - type: string maxLength: 120 - type: "null" doseStrength: description: Strength as printed on the container, e.g. "5 mg/0.5 ml". Free text, max 60 chars — deliberately not split into a number + unit because pens state strength per dose, per ml, or per cartridge. anyOf: - type: string maxLength: 60 - type: "null" notes: anyOf: - type: string maxLength: 200 - type: "null" description: "Per-item inventory mutation: manual first-use, used-up override, printed-expiry correction, absolute remaining-unit correction, notes. Every field is optional and commutative." MedicationExtractRequest: type: object properties: text: type: string minLength: 1 maxLength: 2000 description: Free-text medication description (any locale). Up to 2 000 characters. The model never echoes the text back into another tenant — it is only used to produce the structured fields. locale: description: Optional UI locale hint for the model. type: string enum: - de - en - fr - es - it - pl - ko - nl - pt-BR today: description: "Optional override of the reference date used to resolve relative phrases (\"tomorrow\", \"next Monday\"). Format: `YYYY-MM-DD`. Defaults to the account's own day in its time zone." type: string pattern: ^\d{4}-\d{2}-\d{2}$ required: - text description: Free-text medication description payload. The route runs the text through the Coach provider chain and returns a partial structured payload the wizard merges. Rate-limited 10 requests / 5 minutes / user; budget-gated against the daily Coach token ceiling. SetMedicationEfficacyTargetRequest: type: object properties: clear: type: boolean measurementType: type: string enum: - WEIGHT - BLOOD_PRESSURE_SYS - BLOOD_PRESSURE_DIA - PULSE - BODY_FAT - SLEEP_DURATION - ACTIVITY_STEPS - BLOOD_GLUCOSE - TOTAL_BODY_WATER - BONE_MASS - OXYGEN_SATURATION - HEART_RATE_VARIABILITY - RESTING_HEART_RATE - ACTIVE_ENERGY_BURNED - FLIGHTS_CLIMBED - WALKING_RUNNING_DISTANCE - VO2_MAX - BODY_TEMPERATURE - FAT_FREE_MASS - FAT_MASS - MUSCLE_MASS - SKIN_TEMPERATURE - PULSE_WAVE_VELOCITY - VASCULAR_AGE - VISCERAL_FAT - AUDIO_EXPOSURE_ENV - AUDIO_EXPOSURE_HEADPHONE - TIME_IN_DAYLIGHT - WALKING_STEADINESS - AUDIO_EXPOSURE_EVENT - RESPIRATORY_RATE - BODY_MASS_INDEX - LEAN_BODY_MASS - WALKING_HEART_RATE_AVERAGE - WALKING_ASYMMETRY - WALKING_DOUBLE_SUPPORT - WALKING_STEP_LENGTH - WALKING_SPEED - CARDIO_RECOVERY - WRIST_TEMPERATURE - FALL_COUNT - SIX_MINUTE_WALK_DISTANCE - STAIR_ASCENT_SPEED - STAIR_DESCENT_SPEED - BREATHING_DISTURBANCES - IRREGULAR_RHYTHM_NOTIFICATION - HIGH_HEART_RATE_EVENT - LOW_HEART_RATE_EVENT - WALKING_STEADINESS_EVENT - BREATHING_DISTURBANCE_EVENT - RECOVERY_SCORE - STRESS_SCORE - STRAIN_SCORE - HRV_RMSSD - DAY_STRAIN - WORKOUT_STRAIN - SLEEP_PERFORMANCE - SLEEP_EFFICIENCY - SLEEP_CONSISTENCY - SLEEP_NEED - ENERGY_EXPENDITURE_KJ - AVERAGE_HEART_RATE - MAX_HEART_RATE - SLEEP_DISTURBANCE_COUNT - ANS_CHARGE - CARDIO_LOAD - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION - RESILIENCE - PHQ9_SCORE - GAD7_SCORE - GRIP_STRENGTH - PAIN_NRS - WAIST_CIRCUMFERENCE - WAIST_TO_HEIGHT - WHO5_SCORE - SCI_SCORE - EXERCISE_MINUTES - STAND_MINUTES - STAND_HOURS - MINDFUL_MINUTES - STATE_OF_MIND_VALENCE - AFIB_BURDEN - INDOOR_CO2 - INDOOR_TEMPERATURE - INDOOR_HUMIDITY biomarkerId: type: string minLength: 1 maxLength: 64 primary: type: boolean description: Set or clear the user's explicit efficacy-target override for a medication. `clear:true` removes the override so the resolver reverts to the derived (ATC class prefix → name inference) target; otherwise pin exactly ONE of `measurementType` (a metric series) / `biomarkerId` (a lab analyte). `userId` is never a field — ownership is narrowed through the medication (and the biomarker for a lab target). CreateScheduleRevisionRequest: type: object properties: validFrom: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ description: Inclusive start instant of the manual era (ISO 8601). validUntil: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ description: Exclusive end instant of the manual era (ISO 8601). Must precede the current plan. timesOfDay: description: Daily dose times (HH:mm, user local) the era ran at. 1–8 entries; duplicates are collapsed. minItems: 1 maxItems: 8 type: array items: type: string pattern: ^([01]\d|2[0-3]):([0-5]\d)$ required: - validFrom - validUntil - timesOfDay description: "Manual schedule-era payload: era bounds plus the daily dose times that were live during the era." UpdateScheduleRevisionRequest: description: "Corrected schedule-era payload: replacement era bounds plus the daily dose times that were live during the era." $ref: "#/components/schemas/CreateScheduleRevisionRequest" AiTestRequest: type: object properties: provider: anyOf: - type: string enum: - OPENAI - ANTHROPIC - LOCAL - OPENAI_COMPATIBLE - CHATGPT_OAUTH - type: "null" model: anyOf: - type: string minLength: 1 maxLength: 120 - type: "null" baseUrl: anyOf: - type: string maxLength: 2048 format: uri - type: "null" anthropicKey: anyOf: - type: string minLength: 1 maxLength: 500 - type: "null" localKey: anyOf: - type: string minLength: 1 maxLength: 500 - type: "null" openaiKey: anyOf: - type: string minLength: 1 maxLength: 500 - type: "null" compatBaseUrl: anyOf: - type: string maxLength: 2048 format: uri - type: "null" compatKey: anyOf: - type: string minLength: 1 maxLength: 500 - type: "null" compatModel: anyOf: - type: string minLength: 1 maxLength: 120 - type: "null" additionalProperties: false description: "An UNSAVED provider selection to probe. Every field is optional and falls back to the stored configuration, so a user with a saved key can change only the model and test it without re-typing the credential. Nothing here is persisted. Strict: an unknown key is a 422." AiProviderPatchRequest: type: object properties: provider: anyOf: - anyOf: - type: string enum: - OPENAI - ANTHROPIC - LOCAL - OPENAI_COMPATIBLE - CHATGPT_OAUTH - type: string const: "" - type: "null" model: type: - string - "null" baseUrl: type: - string - "null" compatBaseUrl: type: - string - "null" compatModel: type: - string - "null" compatKey: type: - string - "null" anthropicKey: type: - string - "null" localKey: type: - string - "null" openaiKey: type: - string - "null" responseTimeoutSeconds: anyOf: - type: integer minimum: 10 maximum: 600 - type: "null" localReasoningEffort: description: Reasoning setting of the Local provider entry, stored on that entry of the provider chain. `none` = Off, `null` = Default (nothing sent). Omitted leaves it untouched. anyOf: - type: string enum: - none - low - medium - high - type: "null" compatReasoningEffort: description: Reasoning setting of the OpenAI-compatible gateway entry, stored on that entry of the provider chain. `none` = Off, `null` = Default (nothing sent). Omitted leaves it untouched. anyOf: - type: string enum: - none - low - medium - high - type: "null" description: "Partial update of the calling user's AI-provider configuration. An omitted key is left untouched; `null` (or an empty string, on the string fields) clears the column. At least one recognised key must be present, or the write is refused. Key material is write-only: it is encrypted on the way in and never returned. Unknown keys are ignored rather than refused — the object is deliberately not strict, so a client sending a field this server does not know yet is not broken by it." PutCoachPrefsRequest: type: object properties: tone: default: warm type: string enum: - warm - neutral - concise verbosity: default: default type: string enum: - brief - default - detailed excludeMetrics: default: [] maxItems: 11 type: array items: type: string enum: - bp - weight - pulse - mood - compliance - hrv - sleep - resting_hr - steps - medications - anthropometrics showEvidenceByDefault: default: false type: boolean defaultWindow: default: allTime type: string enum: - last7days - last30days - last90days - lastYear - allTime description: "How far back the Coach may read, in every answer: snapshot, retrieval tools and summaries alike. `allTime` (the default) is no limit. Formerly only the starting window; `lastYear` came with the limit." dataClusters: maxItems: 10 type: array items: type: string enum: - cardio - body - activity - workouts - sleep - mood - glucose - medication - mobility - environment reminderSuggestions: type: object properties: enabled: default: true type: boolean stopped: default: false type: boolean dismissedCadences: default: [] maxItems: 32 type: array items: type: string maxLength: 64 lastSuggestedAt: default: null anyOf: - type: string maxLength: 40 - type: "null" followUpChips: description: v1.39.4 — offer up to three follow-up chips under the Coach's latest answer. Absent means on; only `false` switches them off. type: boolean reasoning: description: "v1.41 — how hard the Coach thinks before it answers: `off`, `low`, `medium` or `high`. Absent means `medium`. The operator may switch reasoning off or cap the level; the resolved value is published on `GET /api/auth/me` as `coachReasoning`." type: string enum: - off - low - medium - high baseUpdatedAt: description: "Optimistic-concurrency base token: the `updatedAt` the client last read for this resource. Omit it for the legacy unconditional write (older clients are unaffected). When present, the write is guarded on the stored row still carrying this exact value — a stale token fails with 409 and changes nothing. A present-but-unparseable value fails with 422 and `meta.errorCode` = `invalid_base_updated_at` — note that this is NOT the unconditional write: sending `null` or a malformed string is rejected rather than treated as an omitted token. Treat as opaque: only ever echo a server-returned value, never parse or synthesise it." type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ description: Coach prompt-tuning preferences to persist, plus the optional optimistic-concurrency `baseUpdatedAt` token. The guarded write covers both `coachPrefsJson` and the mirrored `insightsExcludeMetrics` column atomically. Omit the token for the legacy unconditional write. DisableCoachFlag: type: object properties: disableCoach: type: boolean required: - disableCoach description: Per-account Coach opt-out toggle (v1.4.47 W3). `true` hides the Coach FAB and short-circuits its API gates. DiabetesFlag: type: object properties: hasDiabetes: type: boolean required: - hasDiabetes description: Per-account diabetes opt-in (v1.18.6). `true` selects the ADA glycemic GOAL bands (fasting 80–130, postprandial < 180) for glucose targets instead of the general non-diabetic bands. A user-declared preference only — never inferred from a value, never a diagnosis. PatchHealthScoreConfigRequest: type: object properties: pillars: maxItems: 7 type: array items: $ref: "#/components/schemas/ScorePillarId" description: The pillars that should count — the positive selection, not the exclusions. Replaces the stored recipe whole; it never merges. An id outside the catalogue is a 422 rather than being dropped, because a client has no business sending one. baseUpdatedAt: description: "Optimistic-concurrency base token: the `updatedAt` the client last read for this resource. Omit it for the legacy unconditional write (older clients are unaffected). When present, the write is guarded on the stored row still carrying this exact value — a stale token fails with 409 and changes nothing. A present-but-unparseable value fails with 422 and `meta.errorCode` = `invalid_base_updated_at` — note that this is NOT the unconditional write: sending `null` or a malformed string is rejected rather than treated as an omitted token. Treat as opaque: only ever echo a server-returned value, never parse or synthesise it." type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - pillars additionalProperties: false description: "The pillars that should count toward the score. Strict: any key other than `pillars` and `baseUpdatedAt` is rejected." ScorePillarId: type: string enum: - BLOOD_PRESSURE - GLYCAEMIA - ACTIVITY - SLEEP - ADIPOSITY - WELLBEING - LIPIDS description: A Health Score pillar id. ModulePrefsPatchRequest: type: object properties: mood: type: boolean sleep: type: boolean glucose: type: boolean workouts: type: boolean recovery: type: boolean labs: type: boolean illness: type: boolean achievements: type: boolean insights: type: boolean medications: type: boolean doctorReport: type: boolean environment: type: boolean mcp: type: boolean inboundDocuments: type: boolean mentalHealth: type: boolean nutrients: type: boolean vaccinations: type: boolean timeline: type: boolean baseUpdatedAt: description: "Optimistic-concurrency base token: the `updatedAt` the client last read for this resource. Omit it for the legacy unconditional write (older clients are unaffected). When present, the write is guarded on the stored row still carrying this exact value — a stale token fails with 409 and changes nothing. A present-but-unparseable value fails with 422 and `meta.errorCode` = `invalid_base_updated_at` — note that this is NOT the unconditional write: sending `null` or a malformed string is rejected rather than treated as an omitted token. Treat as opaque: only ever echo a server-returned value, never parse or synthesise it." type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ additionalProperties: false description: Partial module enable/disable update. Each key is an optional boolean; an omitted key is left untouched, `false` disables the module, `true` (re-)enables it. Only the toggleable "secondary domains" are accepted — a core-domain key (`weight`, `bloodPressure`, `pulse`, `medications`) or any unknown key is a 422, so the measurement engine + medications can never be disabled here. `cycle`/`coach` are accepted for forward-compat but their real enabled-state is owned elsewhere (the cycle gate / `disableCoach` + the operator assistant flag). `baseUpdatedAt` is the optional optimistic-concurrency token — omit it for the legacy unconditional write. NotificationPrefsPatchRequest: type: object properties: medication: type: object properties: clientManaged: type: boolean deliveryDefault: type: string enum: - server - client lowStockRunwayDays: anyOf: - type: integer minimum: 1 maximum: 60 - type: "null" reorderLeadDays: type: integer minimum: 0 maximum: 60 mood: type: object properties: reminderHour: type: integer minimum: 0 maximum: 23 cycle: type: object properties: clientManaged: type: boolean coach: type: object properties: nudgesEnabled: type: boolean nudgeMedication: type: boolean nudgeVitals: type: boolean nudgeRoutine: type: boolean nudgeFrequency: type: string enum: - weekly - biweekly nudgeAiComposed: type: boolean measurementReminder: type: object properties: clientManaged: type: boolean content: type: object properties: detail: type: string enum: - generic - detailed baseUpdatedAt: description: "Optimistic-concurrency base token: the `updatedAt` the client last read for this resource. Omit it for the legacy unconditional write (older clients are unaffected). When present, the write is guarded on the stored row still carrying this exact value — a stale token fails with 409 and changes nothing. A present-but-unparseable value fails with 422 and `meta.errorCode` = `invalid_base_updated_at` — note that this is NOT the unconditional write: sending `null` or a malformed string is rejected rather than treated as an omitted token. Treat as opaque: only ever echo a server-returned value, never parse or synthesise it." type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ description: "Partial per-user notification preferences. Every category and field is optional; the server deep-merges the supplied keys over the persisted row, so a PATCH touching only `medication` leaves the siblings intact. `medication.lowStockRunwayDays` (1–60, nullable) is the low-stock alert threshold in remaining runway days — `null` switches the alert off. `baseUpdatedAt` is the optional optimistic-concurrency token: echo it to have the server refuse (409) a write that would silently revert a category another writer set from a fresher read (e.g. iOS `medication.clientManaged`). Omit it for the legacy unconditional write." SavedReportProfile: type: object properties: v: type: number const: 2 leaves: maxItems: 106 type: array items: type: string minLength: 1 maxLength: 64 format: type: string enum: - pdf - fhir - package rangeDays: type: integer minimum: 1 maximum: 365 includeCharts: type: boolean required: - v - leaves - format - rangeDays - includeCharts additionalProperties: false description: "A saved doctor-report scope: which leaves are included plus the export format, trailing window and chart toggle. Replace-only — there is no partial-merge write." UnitPreferencePatchRequest: type: object properties: unitPreference: type: string enum: - metric - imperial required: - unitPreference description: Which display-unit branch to render. Presentation only — the stored measurement rows stay SI either way. GlucoseUnitPatchRequest: type: object properties: glucoseUnit: type: string enum: - mg/dL - mmol/L required: - glucoseUnit description: Which unit blood-glucose readings are rendered in. Presentation only — every stored reading is mg/dL either way. DocumentsAutoAiReadPatchRequest: type: object properties: documentsAutoAiRead: type: boolean required: - documentsAutoAiRead description: Turn automatic AI reading of newly uploaded documents on or off. Turning it on also mints an `ai_extraction` consent receipt and schedules a catch-up over the existing vault. InjectionSitePrefsPatchRequest: type: object properties: globalExcludedInjectionSites: maxItems: 8 type: array items: type: string enum: - ABDOMEN_LEFT - ABDOMEN_RIGHT - ABDOMEN_UPPER_LEFT - ABDOMEN_UPPER_RIGHT - THIGH_LEFT - THIGH_RIGHT - UPPER_ARM_LEFT - UPPER_ARM_RIGHT description: User-level injection-site deny-list. Sites here are never offered for any medication and rejected at intake (deny wins). Empty array clears the exclusion. required: - globalExcludedInjectionSites description: Replace the user-level injection-site deny-list. An empty array clears it. ThresholdsUpdateRequest: type: object properties: WEIGHT: type: object properties: min: type: number minimum: 30 maximum: 300 max: type: number minimum: 30 maximum: 300 required: - min - max BLOOD_PRESSURE_SYS: type: object properties: min: type: number minimum: 80 maximum: 220 max: type: number minimum: 80 maximum: 220 required: - min - max BLOOD_PRESSURE_DIA: type: object properties: min: type: number minimum: 40 maximum: 140 max: type: number minimum: 40 maximum: 140 required: - min - max PULSE: type: object properties: min: type: number minimum: 30 maximum: 220 max: type: number minimum: 30 maximum: 220 required: - min - max BODY_FAT: type: object properties: min: type: number minimum: 3 maximum: 60 max: type: number minimum: 3 maximum: 60 required: - min - max SLEEP_DURATION: type: object properties: min: type: number minimum: 3 maximum: 14 max: type: number minimum: 3 maximum: 14 required: - min - max ACTIVITY_STEPS: type: object properties: min: type: number minimum: 0 maximum: 50000 max: type: number minimum: 0 maximum: 50000 required: - min - max BLOOD_GLUCOSE_FASTING: type: object properties: min: type: number minimum: 40 maximum: 400 max: type: number minimum: 40 maximum: 400 required: - min - max BLOOD_GLUCOSE_POSTPRANDIAL: type: object properties: min: type: number minimum: 40 maximum: 500 max: type: number minimum: 40 maximum: 500 required: - min - max BLOOD_GLUCOSE_RANDOM: type: object properties: min: type: number minimum: 40 maximum: 500 max: type: number minimum: 40 maximum: 500 required: - min - max BLOOD_GLUCOSE_BEDTIME: type: object properties: min: type: number minimum: 40 maximum: 400 max: type: number minimum: 40 maximum: 400 required: - min - max TOTAL_BODY_WATER: type: object properties: min: type: number minimum: 5 maximum: 100 max: type: number minimum: 5 maximum: 100 required: - min - max BONE_MASS: type: object properties: min: type: number minimum: 0.5 maximum: 8 max: type: number minimum: 0.5 maximum: 8 required: - min - max OXYGEN_SATURATION: type: object properties: min: type: number minimum: 50 maximum: 100 max: type: number minimum: 50 maximum: 100 required: - min - max additionalProperties: false description: A partial map of `ThresholdMetric` to `{ min, max }`. Only the metrics named are written; the rest keep their existing override. Strict — an unknown key is a 422, not a silent drop. Each range must sit inside that metric's physiological bounds with `min` strictly below `max`. ThresholdMetric: type: string enum: - WEIGHT - BLOOD_PRESSURE_SYS - BLOOD_PRESSURE_DIA - PULSE - BODY_FAT - SLEEP_DURATION - ACTIVITY_STEPS - BLOOD_GLUCOSE_FASTING - BLOOD_GLUCOSE_POSTPRANDIAL - BLOOD_GLUCOSE_RANDOM - BLOOD_GLUCOSE_BEDTIME - TOTAL_BODY_WATER - BONE_MASS - OXYGEN_SATURATION ThresholdsResetAllRequest: type: object properties: confirm: type: string const: RESET_THRESHOLDS description: Required when `metric` is omitted. Compared exactly; any other value is refused. required: - confirm description: Confirmation body for the reset-everything form. Send no body at all when resetting a single metric with `?metric=`. DevicePatchRequest: type: object properties: medicationDelivery: anyOf: - type: string enum: - server - client - type: "null" required: - medicationDelivery description: Per-device medication-reminder delivery override. `server` forces server-side dispatch to this device, `client` leaves it to the app's own local reminders, and `null` CLEARS the override so the device inherits the account default. LabsLocalOcrPatchRequest: type: object properties: labsLocalOcrEnabled: type: boolean required: - labsLocalOcrEnabled description: Turn browser-side OCR on or off for lab-report scanning. When on, a text-only provider can still read a paper report because the image is transcribed on the device and only the TEXT is sent. WorkoutRoutesPatchRequest: type: object properties: storeWorkoutRoutes: type: boolean required: - storeWorkoutRoutes description: Keep or stop keeping the GPS track of new workouts. Off by default. UseCentralCodexPatchRequest: type: object properties: useCentralCodex: type: boolean required: - useCentralCodex description: Opt in or out of the operator's shared ChatGPT subscription as a trailing entry in this account's provider chain. LocalePutRequest: type: object properties: locale: type: string enum: - de - en - fr - es - it - pl - ko - nl - pt-BR description: One of the interface locales this build ships. required: - locale description: The interface language. Validated against the supported set at the surface so the column can never hold a value the resolver would later have to defend against. The handler checks membership directly rather than through a Zod schema; the effect is the same. TimezonePutRequest: type: object properties: timezone: type: string description: An IANA zone name. Accepted when the running engine's `Intl.DateTimeFormat` accepts it, which is the whole IANA set the deployment knows — not a fixed list this contract could enumerate. required: - timezone description: The account's timezone. The handler reads and trims the field directly rather than through a Zod schema. CoachChatRequest: type: object properties: conversationId: type: string minLength: 1 maxLength: 64 message: type: string minLength: 1 maxLength: 4000 prefill: type: string maxLength: 2000 locale: type: string enum: - en - de scope: type: object properties: sources: maxItems: 40 type: array items: type: string enum: - bp - weight - pulse - mood - compliance - hrv - sleep - resting_hr - steps - active_energy - flights - distance - vo2_max - body_temp - walking_hr - respiratory_rate - spo2 - pulse_wave_velocity - vascular_age - body_fat - fat_mass - fat_free_mass - muscle_mass - lean_body_mass - bone_mass - total_body_water - bmi - visceral_fat - glucose - walking_steadiness - walking_asymmetry - walking_double_support - walking_step_length - walking_speed - audio_env - audio_headphone - audio_event - daylight - skin_temp - workouts window: type: string enum: - last7days - last30days - last90days - lastYear - allTime guidedQuestion: type: string minLength: 1 maxLength: 500 workoutId: type: string maxLength: 64 followUp: type: object properties: messageId: type: string minLength: 1 maxLength: 64 id: type: string minLength: 1 maxLength: 8 required: - messageId - id clarification: type: object properties: messageId: type: string minLength: 1 maxLength: 64 choiceId: type: string minLength: 1 maxLength: 8 required: - messageId memoryDecision: type: object properties: messageId: type: string minLength: 1 maxLength: 64 proposalId: type: string minLength: 1 maxLength: 64 accept: type: boolean required: - messageId - proposalId - accept planDecision: type: object properties: messageId: type: string minLength: 1 maxLength: 64 planId: type: string minLength: 1 maxLength: 64 accept: type: boolean required: - messageId - planId - accept required: - message description: "Inbound Coach turn. `message` is the user's turn (1–4 000 chars). `conversationId` is omitted to start a new conversation (the server mints a title from the first message) and supplied to continue one. `scope` narrows which metrics the snapshot ships and which window the timeline covers; omitted fields fall back to server defaults. `locale` picks the reply language. `guidedQuestion` carries the clarifying question a message answers (client-side bubble, never persisted). v1.39.4 — `followUp: { messageId, id }` names a follow-up chip the person tapped (send the chip's `label` as `message`); the server resolves the chip from the conversation's latest assistant message, and a chip that is no longer current is treated as a plain message. `clarification: { messageId, choiceId? }` answers a clarifying question, `choiceId` absent when the person typed their own answer. No `userId` field — the owner is narrowed from the session / Bearer." RenameCoachConversationRequest: type: object properties: title: type: string minLength: 1 maxLength: 80 description: The new title. Trimmed before validation, so whitespace alone is refused as empty. 1..80 characters. required: - title description: Rename body. Strict — an unrecognised key is refused rather than dropped, which is not how most partial updates on this surface behave. FencedCoachChatRequest: type: object properties: conversationId: type: string minLength: 1 maxLength: 64 message: type: string minLength: 1 maxLength: 4000 locale: type: string enum: - en - de attachmentIds: minItems: 1 maxItems: 5 type: array items: type: string minLength: 1 maxLength: 64 required: - message additionalProperties: false CoachAttachmentCreateRequest: type: object properties: documentId: type: string minLength: 1 maxLength: 64 required: - documentId additionalProperties: false InsightsLayoutPutBody: type: object properties: version: anyOf: - type: number const: 1 - type: number const: 2 sections: maxItems: 50 type: array items: type: object properties: id: type: string enum: - wellness-scores - daily-briefing - vitals - trends - period-review - cycle-summary - signals - rhythm-events - health-status - breathing - labs-changes - ecg visible: type: boolean order: type: integer minimum: 0 maximum: 99 required: - id - visible - order tiles: minItems: 1 maxItems: 63 type: array items: type: object properties: id: type: string enum: - overview - blood-pressure - pulse - oxygen - body-temperature - respiratory-rate - pain - weight - bmi - body-fat - body-water - bone-mass - fat-free-mass - fat-mass - muscle-mass - visceral-fat - lean-body-mass - waist - waist-to-height - steps - active-energy - workouts - flights-climbed - walking-distance - walking-steadiness - walking-heart-rate - walking-asymmetry - double-support-time - step-length - walking-speed - cardio-fitness - falls - six-minute-walk - stair-ascent-speed - stair-descent-speed - grip-strength - sleep - breathing-disturbances - resting-pulse - hrv - pulse-wave-velocity - vascular-age - cardio-recovery - environmental-audio - headphone-audio - audio-events - daylight - blood-glucose - skin-temperature - wrist-temperature - mood - medications - nutrients - blutdruck - puls - sauerstoff - koerpertemperatur - gewicht - aktive-energie - schlaf - ruhepuls - stimmung - medikamente visible: type: boolean order: type: integer minimum: 0 maximum: 99 required: - id - visible - order baseUpdatedAt: description: "Optimistic-concurrency base token: the `updatedAt` the client last read for this resource. Omit it for the legacy unconditional write (older clients are unaffected). When present, the write is guarded on the stored row still carrying this exact value — a stale token fails with 409 and changes nothing. A present-but-unparseable value fails with 422 and `meta.errorCode` = `invalid_base_updated_at` — note that this is NOT the unconditional write: sending `null` or a malformed string is rejected rather than treated as an omitted token. Treat as opaque: only ever echo a server-returned value, never parse or synthesise it." type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - version description: PUT body for the Insights layout — the layout fields plus the optional optimistic-concurrency base token (`baseUpdatedAt`). Omit the token for the legacy unconditional write. CoachFactCreateRequest: type: object properties: messageId: type: string minLength: 1 maxLength: 64 required: - messageId additionalProperties: false description: "v1.41 — the remember button: the id of one of the caller's own Coach messages. Never the text: the server reads the message it stored. Strict: unknown keys 422." CoachFactPatchRequest: type: object properties: fact: type: string minLength: 3 maxLength: 160 required: - fact additionalProperties: false description: "v1.41 — the fact's new wording (3..160 characters). Only the text: the server reads the new wording to file it (a fact that names a medication moves to `medication`, other health wording moves a non-health fact to `condition`) and a health fact the caller wrote counts as confirmed. Strict: unknown keys 422." UpdateEmergencyProfileRequest: type: object properties: bloodType: anyOf: - type: string enum: - A_POS - A_NEG - B_POS - B_NEG - AB_POS - AB_NEG - O_POS - O_NEG - UNKNOWN - type: "null" organDonor: anyOf: - type: string enum: - YES - NO - UNKNOWN - type: "null" advanceDirective: anyOf: - type: string enum: - EXISTS - NONE - UNKNOWN - type: "null" contacts: anyOf: - type: string maxLength: 2000 - type: "null" implants: anyOf: - type: string maxLength: 1000 - type: "null" note: anyOf: - type: string maxLength: 2000 - type: "null" additionalProperties: false description: Partial edit of the emergency (Notfalldaten) profile. An omitted key leaves the column untouched; a `null` enum or an emptied free-text field clears it. `bloodType`, `organDonor` and `advanceDirective` are closed enums; `contacts`, `implants` and `note` are encrypted at rest. Rejects unknown keys. CoachAboutMeAdoptRequest: type: object properties: question: type: string minLength: 1 maxLength: 500 answer: type: string minLength: 1 maxLength: 500 required: - answer description: An answer to fold into the stored self-context. `question` is the clarifying question it belongs to and is what the server matches the target field from; omit it for the remember-a-message path and the field is matched from `answer` instead. `answer` is capped at 500 characters — the same cap as a structured self-context field. CoachPlanPatchRequest: type: object properties: status: type: string enum: - proposed - active - met - abandoned - review_due - reviewed reviewDate: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" additionalProperties: false description: "v1.21.3 — confirm or update a Coach plan's lifecycle. `status` moves a `proposed` plan to `active` (confirm), or to `met` / `abandoned`. `reviewDate` pins (ISO instant) or clears (null) a check-in checkpoint. At least one field is required. Strict: unknown keys 422 — the body can never carry the metric, the encrypted text, or a userId." CoachReminderSuggestionAction: type: object properties: cadenceId: type: string minLength: 1 maxLength: 64 action: type: string enum: - accept - dismiss - stop required: - cadenceId - action description: "v1.18.1 — act on a Coach cadence suggestion. `cadenceId` names a closed-catalog preset (e.g. `weight_daily`, `bp_7_2_2`); the client never sends a schedule. `action`: `accept` creates a `MeasurementReminder` (origin: COACH) through the same engine the Vorsorge surface uses; `dismiss` records dismissal memory; `stop` suppresses all future cadence suggestions. Strict: unknown keys 422." CoachReminderCreate: type: object properties: note: type: string minLength: 1 maxLength: 280 when: type: string minLength: 1 maxLength: 32 metric: type: string minLength: 1 maxLength: 60 pattern: ^[A-Za-z][A-Za-z0-9_]*$ required: - note additionalProperties: false description: "Create a Coach reminder manually. `note` is free text (the only writable content). `when` is an optional closed grammar token — an ISO date (YYYY-MM-DD), a relative offset (+Nd / +Nw), or a context cue (NEXT_BP_LOGGED, NEXT_WEIGHT_LOGGED, NEXT_SLEEP_LOGGED, NEXT_APP_OPEN) — resolved into the trigger server-side. Omit `when` for a recall-only note. Strict: unknown keys 422." CoachReminderPatch: type: object properties: status: type: string enum: - proposed - active - due - surfaced - done - dismissed when: anyOf: - type: string minLength: 1 maxLength: 32 - type: "null" additionalProperties: false description: "Update a Coach reminder's lifecycle. `status` confirms (proposed → active), or marks done / dismissed. `when` re-schedules via the closed grammar (null clears the due moment). Never carries the note text. Strict: unknown keys 422." CoachSuggestedAction: oneOf: - type: object properties: actionType: type: string const: checkup.create label: type: string minLength: 1 maxLength: 120 interval: type: string enum: - yearly - halfYearly - quarterly - monthly required: - actionType - label - interval - type: object properties: actionType: type: string const: reminder.note note: type: string minLength: 1 maxLength: 280 when: type: string minLength: 1 maxLength: 32 metric: type: string minLength: 1 maxLength: 60 pattern: ^[A-Za-z][A-Za-z0-9_]*$ required: - actionType - note description: Confirm a Coach action card. `actionType` is from the closed allowlist (checkup.create, reminder.note); NEVER a medication or clinical change. `checkup.create` builds a preventive-care Vorsorge reminder (free-text label + a closed interval id resolved to an RRULE server-side); `reminder.note` builds a CoachReminder (note + optional closed `when` grammar + optional metric). Nothing is created without this explicit confirm. type: object CoachMessageFeedbackRequest: type: object properties: rating: type: string enum: - helpful - unhelpful reason: type: string minLength: 1 maxLength: 200 required: - rating description: Per-message helpful/unhelpful feedback (v1.4.23 H7). Optional `reason` is free-form prose, capped at 200 chars. AdminMeasurementMaintenanceRequest: type: object properties: vacuum: default: true description: Run `VACUUM (ANALYZE)` on `measurements`. type: boolean reindex: default: true description: Rebuild every index of `measurements` with `REINDEX INDEX CONCURRENTLY`, largest first, one at a time. type: boolean additionalProperties: false InsightsPregenerateRequest: type: object properties: {} description: No body fields. The user is taken from the session / Bearer and the locale from the session; the warm covers every assessment for that user. InsightsGeneratePostRequest: type: object properties: force: description: Send `true` to bypass the 24-hour cache and generate now. Anything other than the boolean `true` — including the string `"true"` — is treated as absent. Forcing also means an exhausted hourly quota answers an honest 429 instead of degrading to the cached payload. type: boolean description: Optional body for the generating POST. Capped at 16 KiB. `userId` is taken from the session or the Bearer token and is never a body field. ProviderChainPutRequest: type: object properties: chain: minItems: 1 maxItems: 7 type: array items: type: object properties: providerType: type: string enum: - codex - openai - anthropic - local - openai-compatible - admin-openai - admin-codex description: Closed allow-list. The mock provider is excluded from it structurally, which is what keeps production from reaching one. priority: description: ACCEPTED AND IGNORED. Priority is recomputed from the array's insertion order, so a stale client cannot persist a chain whose displayed order disagrees with its stored one. Send the order you want as the order of the array. type: integer minimum: -9007199254740991 maximum: 9007199254740991 enabled: type: boolean reasoningEffort: description: Only on a `local` or `openai-compatible` entry; refused on any other. Omitted keeps the value already stored for that provider, `null` restores Default, `none` / `low` / `medium` / `high` are sent as `reasoning_effort`. anyOf: - type: string enum: - none - low - medium - high - type: "null" required: - providerType - enabled additionalProperties: false description: The whole chain, in the order it should be walked. At least one entry and at most one per known provider type; a repeated type is refused. required: - chain additionalProperties: false description: Replaces the account's provider chain wholesale. No credential is written or read here — only which provider types exist, in what order, which are enabled, and the reasoning setting of the Local and OpenAI-compatible entries. InsightsFeedbackRequest: type: object properties: recommendationId: type: string minLength: 1 maxLength: 200 recommendationText: type: string minLength: 1 maxLength: 2000 recommendationSeverity: type: string enum: - info - suggestion - important - urgent metricSourceType: type: string minLength: 1 maxLength: 80 metricSourceTimeRange: type: string enum: - last7days - last30days - last90days - allTime helpful: type: boolean required: - recommendationId - recommendationText - recommendationSeverity - metricSourceType - metricSourceTimeRange - helpful description: "A thumbs-up / thumbs-down on one recommendation. The body carries the rec id AND a snapshot of the text as rendered, because deduplication is on the pair: a regeneration that rewrites the same id with new text is a genuinely different rating, which is the signal the aggregator wants. `providerType` and `promptVersion` are NOT accepted — the server fills both from the account's own attribution, so a client cannot tamper with the slice the quality dashboard reports on." MeasurementType: type: string enum: - WEIGHT - BLOOD_PRESSURE_SYS - BLOOD_PRESSURE_DIA - PULSE - BODY_FAT - SLEEP_DURATION - ACTIVITY_STEPS - BLOOD_GLUCOSE - TOTAL_BODY_WATER - BONE_MASS - OXYGEN_SATURATION - HEART_RATE_VARIABILITY - RESTING_HEART_RATE - ACTIVE_ENERGY_BURNED - FLIGHTS_CLIMBED - WALKING_RUNNING_DISTANCE - VO2_MAX - BODY_TEMPERATURE - FAT_FREE_MASS - FAT_MASS - MUSCLE_MASS - SKIN_TEMPERATURE - PULSE_WAVE_VELOCITY - VASCULAR_AGE - VISCERAL_FAT - AUDIO_EXPOSURE_ENV - AUDIO_EXPOSURE_HEADPHONE - TIME_IN_DAYLIGHT - WALKING_STEADINESS - AUDIO_EXPOSURE_EVENT - RESPIRATORY_RATE - BODY_MASS_INDEX - LEAN_BODY_MASS - WALKING_HEART_RATE_AVERAGE - WALKING_ASYMMETRY - WALKING_DOUBLE_SUPPORT - WALKING_STEP_LENGTH - WALKING_SPEED - CARDIO_RECOVERY - WRIST_TEMPERATURE - FALL_COUNT - SIX_MINUTE_WALK_DISTANCE - STAIR_ASCENT_SPEED - STAIR_DESCENT_SPEED - BREATHING_DISTURBANCES - IRREGULAR_RHYTHM_NOTIFICATION - HIGH_HEART_RATE_EVENT - LOW_HEART_RATE_EVENT - WALKING_STEADINESS_EVENT - BREATHING_DISTURBANCE_EVENT - RECOVERY_SCORE - STRESS_SCORE - STRAIN_SCORE - HRV_RMSSD - DAY_STRAIN - WORKOUT_STRAIN - SLEEP_PERFORMANCE - SLEEP_EFFICIENCY - SLEEP_CONSISTENCY - SLEEP_NEED - ENERGY_EXPENDITURE_KJ - AVERAGE_HEART_RATE - MAX_HEART_RATE - SLEEP_DISTURBANCE_COUNT - ANS_CHARGE - CARDIO_LOAD - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION - RESILIENCE - PHQ9_SCORE - GAD7_SCORE - GRIP_STRENGTH - PAIN_NRS - WAIST_CIRCUMFERENCE - WAIST_TO_HEIGHT - WHO5_SCORE - SCI_SCORE - EXERCISE_MINUTES - STAND_MINUTES - STAND_HOURS - MINDFUL_MINUTES - STATE_OF_MIND_VALENCE - AFIB_BURDEN - INDOOR_CO2 - INDOOR_TEMPERATURE - INDOOR_HUMIDITY description: DB-stored measurement category. v1.4.23 added 7 Apple Health values (HRV, resting HR, active energy, flights, walking/running distance, VO2 max, body temperature). InsightsSettingsPutRequest: type: object properties: privacyMode: description: The only field this endpoint writes. Changing it CLEARS the cached insight text and its timestamp, so the next read regenerates under the new mode rather than serving text produced under the old one. type: string enum: - aggregated - raw description: "Partial settings update. The body is read key by key rather than parsed as a whole: unknown keys are IGNORED, not refused, and a body that changes nothing recognised is refused with 422 rather than answered as a no-op. `privacyMode` is the only writable field today." EcgIngestRequest: type: object properties: externalRecordingId: type: string minLength: 1 maxLength: 120 description: "The recording's stable id AS THE CLIENT KNOWS IT — for an Apple Health client, the `HKSample.uuid`. It is the source's own identity, not a content hash: re-posting the same id overwrites that recording in place. It does not have to agree with the id the `export.zip` importer derives for the same strip; the server reconciles the two doors itself (see `status: duplicate`)." recordedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ description: "When the strip was recorded on-device, with offset. Part of the recording's identity — see `status: duplicate`." samplingFrequency: type: number minimum: 1 maximum: 10000 description: Signal sampling rate in Hz (an Apple Watch ECG is 512). A fractional value is accepted and rounded to the nearest whole hertz before it is stored. Part of the recording's identity, and the divisor for the server-derived `durationSeconds`. samples: minItems: 1 maxItems: 32768 type: array items: type: integer minimum: -1000000 maximum: 1000000 description: The waveform as INTEGER MICRO-VOLTS, in recording order — the same unit the `export.zip` CSV parser produces, so convert from HealthKit's Volts before sending. Capped at 32 768 samples (a clean factor of two above a 30 s / 512 Hz strip). Stored AES-256-GCM encrypted; never persisted as plaintext. lead: description: Recording lead label when the device reports one (e.g. `I`). anyOf: - type: string minLength: 1 maxLength: 40 - type: "null" averageHeartRate: description: The device's average heart rate (BPM) for the strip. HealthKit reports it with a fraction; it is accepted as sent and rounded to whole beats per minute before it is stored. anyOf: - type: number minimum: 1 maximum: 300 - type: "null" classification: description: The RECORDING DEVICE's own verdict, stored verbatim. Only the three ECG values are accepted here. `RhythmClassification` in the database has six members because walking-steadiness and neutral event verdicts share it, and those appear on GET /api/insights/rhythm-events — a decoder generated from this schema must NOT be reused there. HealthLog never reads the waveform to produce or revise a verdict. anyOf: - type: string enum: - IRREGULAR - NOT_DETECTED - INCONCLUSIVE - type: "null" source: type: string enum: - APPLE_HEALTH description: "The client's own source. `APPLE_HEALTH` only: `WITHINGS` rows are minted by the OAuth sync and `COMPUTED` ones by the server, so neither is client-assertable — the same posture POST /api/measurements/batch takes." required: - externalRecordingId - recordedAt - samplingFrequency - samples - source description: One Apple Watch ECG recording. Unknown keys are REJECTED with a 422 naming them rather than silently dropped, because every field here is load-bearing metadata for a stored recording. `sampleCount` and `durationSeconds` are derived server-side and must not be sent. UpdateCorrelationPatternRequest: type: object properties: dismissed: type: boolean required: - dismissed additionalProperties: false BulkMoodRequest: type: object properties: entries: minItems: 1 maxItems: 500 type: array items: $ref: "#/components/schemas/BulkMoodEntry" required: - entries description: iOS SyncMode bulk mood backfill. 1–500 entries per call; per-entry UPSERT keyed by `externalId` so re-runs are idempotent. BulkMoodEntry: type: object properties: mood: type: string enum: - SUPER_GUT - GUT - OKAY - SCHLECHT - LAUSIG tags: maxItems: 20 type: array items: type: string maxLength: 50 tagKeys: description: Structured-tag keys from the catalog; unknown keys dropped. maxItems: 30 type: array items: type: string maxLength: 60 ratedFactors: description: Rated mood factors; an out-of-scale rating marks THIS entry skipped, never the batch. maxItems: 30 type: array items: type: object properties: key: type: string maxLength: 60 rating: type: integer minimum: 1 maximum: 5 required: - key - rating note: type: string maxLength: 500 a1: description: "Pleasantness, 0-10. Optional: the server DERIVES it from `mood` when the entry does not carry one, so an older build that sends only the five-point label still writes a complete row. A value sent here wins over the derivation." anyOf: - type: integer minimum: 0 maximum: 10 - type: "null" a2: description: 'Stress, 0-10, INVERSE-oriented: a higher value is more stress. Stored exactly as set; a client that wants "up is better" flips the sign itself.' anyOf: - type: integer minimum: 0 maximum: 10 - type: "null" a3: description: Energy, 0-10. anyOf: - type: integer minimum: 0 maximum: 10 - type: "null" a4: description: Connectedness, 0-10. anyOf: - type: integer minimum: 0 maximum: 10 - type: "null" a5: description: Stability, 0-10. anyOf: - type: integer minimum: 0 maximum: 10 - type: "null" context: description: The day's context. Absent leaves a stored context untouched on a re-post; an empty one removes it. anyOf: - $ref: "#/components/schemas/MoodContext" - type: "null" moodLoggedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ description: ISO instant the entry was logged. source: description: Defaults to MANUAL. type: string enum: - MANUAL - MOODLOG - WEB - TELEGRAM - DAYLIO - NOTIFICATION externalId: description: "iOS-side source-stable id for idempotent dedup on the `(userId, source, externalId)` key. Must be stable across app restarts — an object description or memory address (``, `0x12ab34cd`) is a new value on every launch; such an entry is `skipped` with reason `unstable_external_id` while the rest of the batch still lands." type: string minLength: 1 maxLength: 120 required: - mood - moodLoggedAt description: One bulk mood entry (UPSERT keyed by `externalId`). MoodContext: type: object properties: workStatus: description: Shape of the working day. anyOf: - type: string enum: - off - partial - regular - overtime - type: "null" workMinutes: anyOf: - type: integer minimum: 0 maximum: 1440 - type: "null" overtimeMinutes: anyOf: - type: integer minimum: 0 maximum: 1440 - type: "null" workLoad: description: "0-10, INVERSE-oriented: a higher value is a heavier day, not a better one." anyOf: - type: integer minimum: 0 maximum: 10 - type: "null" workSatisfaction: anyOf: - type: integer minimum: 0 maximum: 10 - type: "null" contactCircles: description: "Who the day's contact was with. Multi-select. Never scored: the count of people is not a good or a bad number." anyOf: - type: array items: type: string enum: - partner - child - family - friends - colleagues - acquaintances - professional - other - type: "null" contactForm: anyOf: - type: string enum: - inPerson - phone - video - written - type: "null" contactExtent: anyOf: - type: string enum: - brief - medium - extended - type: "null" contactQuality: anyOf: - type: integer minimum: 0 maximum: 10 - type: "null" contactSupport: anyOf: - type: integer minimum: 0 maximum: 10 - type: "null" leisureCategories: anyOf: - type: array items: type: string enum: - film - reading - gaming - music - creative - outing - garden - cooking - timeWithChild - relaxation - other - type: "null" leisureMinutes: anyOf: - type: integer minimum: 0 maximum: 1440 - type: "null" leisureJoy: anyOf: - type: integer minimum: 0 maximum: 10 - type: "null" leisureRecovery: anyOf: - type: integer minimum: 0 maximum: 10 - type: "null" eventType: description: One notable event. One per entry, not a list. anyOf: - type: string enum: - goodNews - badNews - success - disappointment - conflict - appointment - familyEvent - change - unexpected - type: "null" eventValence: description: -5 (clearly bad) to +5 (clearly good). anyOf: - type: integer minimum: -5 maximum: 5 - type: "null" eventAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" note: description: One free-text note for the whole context. AES-256-GCM at rest; the ciphertext never rides the wire. anyOf: - type: string maxLength: 500 - type: "null" description: "The day around a mood entry: work, contacts, leisure and one notable event. Every field optional. Sleep, activity, vitals and body symptoms are deliberately ABSENT — they are owned by their own modules and are read from there, never captured a second time here." CreateMoodTagRequest: type: object properties: label: type: string minLength: 1 maxLength: 40 icon: type: - string - "null" categoryKey: type: string minLength: 1 maxLength: 80 required: - label description: Create a per-user custom mood tag (BINARY only). `label` is encrypted at rest. `icon` must come from the curated allowlist (unknown name → 422). `categoryKey` picks the home group — any seeded category key or one of the caller's own `customcat:` group keys; omitted → the seeded `custom` category. Capped at 50 active custom tags per user (422). UpdateMoodTagRequest: type: object properties: label: type: string minLength: 1 maxLength: 40 icon: type: - string - "null" isActive: type: boolean categoryKey: type: string minLength: 1 maxLength: 80 description: Partial custom-tag edit; at least one field required. `isActive:false` archives (history intact), `isActive:true` restores. `categoryKey` moves the tag to another group (a real categoryId move). HideMoodTagRequest: type: object properties: hidden: type: boolean required: - hidden description: Hide / show a CATALOGUE tag for the calling user. Custom tags are hidden via their own `isActive` flag on the custom PATCH instead (this route 400s a `custom:` key). CreateMoodTagGroupRequest: type: object properties: label: type: string minLength: 1 maxLength: 40 icon: type: - string - "null" required: - label description: Create a per-user custom mood-tag group. `label` is encrypted at rest; `icon` must come from the curated allowlist. Capped at 12 active custom groups per user (422). UpdateMoodTagGroupRequest: type: object properties: label: type: string minLength: 1 maxLength: 40 icon: type: - string - "null" isActive: type: boolean description: Partial custom-group edit; at least one field required. `isActive:false` retires the group without touching its tags. MoodTagLayoutPutRequest: type: object properties: groupOrder: maxItems: 50 type: array items: type: string minLength: 1 maxLength: 80 placements: type: object propertyNames: type: string minLength: 1 maxLength: 80 additionalProperties: type: array items: type: string minLength: 1 maxLength: 80 baseUpdatedAt: description: "Optimistic-concurrency base token: the `updatedAt` the client last read for this resource. Omit it for the legacy unconditional write (older clients are unaffected). When present, the write is guarded on the stored row still carrying this exact value — a stale token fails with 409 and changes nothing. A present-but-unparseable value fails with 422 and `meta.errorCode` = `invalid_base_updated_at` — note that this is NOT the unconditional write: sending `null` or a malformed string is rejected rather than treated as an omitted token. Treat as opaque: only ever echo a server-returned value, never parse or synthesise it." type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ description: Mood-tag layout to merge (preserve-when-absent), plus the optional optimistic-concurrency `baseUpdatedAt` token. The two manage cards write different fields of the same blob; echo the token to have a stale write refused with 409 instead of resurrecting the other card's overwritten field. Omit it for the legacy unconditional write. CreateMoodEntryRequest: type: object properties: mood: type: string enum: - SUPER_GUT - GUT - OKAY - SCHLECHT - LAUSIG tags: maxItems: 20 type: array items: type: string maxLength: 50 tagKeys: maxItems: 30 type: array items: type: string maxLength: 60 ratedFactors: maxItems: 30 type: array items: type: object properties: key: type: string maxLength: 60 rating: type: integer minimum: 1 maximum: 5 required: - key - rating a1: anyOf: - type: integer minimum: 0 maximum: 10 - type: "null" a2: anyOf: - type: integer minimum: 0 maximum: 10 - type: "null" a3: anyOf: - type: integer minimum: 0 maximum: 10 - type: "null" a4: anyOf: - type: integer minimum: 0 maximum: 10 - type: "null" a5: anyOf: - type: integer minimum: 0 maximum: 10 - type: "null" note: type: string maxLength: 500 context: anyOf: - type: object properties: workStatus: anyOf: - type: string enum: - off - partial - regular - overtime - type: "null" workMinutes: anyOf: - type: integer minimum: 0 maximum: 1440 - type: "null" overtimeMinutes: anyOf: - type: integer minimum: 0 maximum: 1440 - type: "null" workLoad: anyOf: - type: integer minimum: 0 maximum: 10 - type: "null" workSatisfaction: anyOf: - type: integer minimum: 0 maximum: 10 - type: "null" contactCircles: anyOf: - maxItems: 8 type: array items: type: string enum: - partner - child - family - friends - colleagues - acquaintances - professional - other - type: "null" contactForm: anyOf: - type: string enum: - inPerson - phone - video - written - type: "null" contactExtent: anyOf: - type: string enum: - brief - medium - extended - type: "null" contactQuality: anyOf: - type: integer minimum: 0 maximum: 10 - type: "null" contactSupport: anyOf: - type: integer minimum: 0 maximum: 10 - type: "null" leisureCategories: anyOf: - maxItems: 11 type: array items: type: string enum: - film - reading - gaming - music - creative - outing - garden - cooking - timeWithChild - relaxation - other - type: "null" leisureMinutes: anyOf: - type: integer minimum: 0 maximum: 1440 - type: "null" leisureJoy: anyOf: - type: integer minimum: 0 maximum: 10 - type: "null" leisureRecovery: anyOf: - type: integer minimum: 0 maximum: 10 - type: "null" eventType: anyOf: - type: string enum: - goodNews - badNews - success - disappointment - conflict - appointment - familyEvent - change - unexpected - type: "null" eventValence: anyOf: - type: integer minimum: -5 maximum: 5 - type: "null" eventAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" note: anyOf: - type: string maxLength: 500 - type: "null" - type: "null" moodLoggedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ source: default: MANUAL type: string enum: - MANUAL - MOODLOG - WEB - TELEGRAM - DAYLIO - NOTIFICATION externalId: type: string minLength: 1 maxLength: 120 required: - mood - moodLoggedAt description: "Single mood-entry create. `mood` is the five-point label; the server derives `score` (1-5) and pleasantness `a1` from it, so a one-tap check-in writes a complete entry. `a1`-`a5` are the level-A self-state values (0-10): absent says nothing, a number is the answer, an explicit null takes an answer back (`a1` falls back to the derivation instead of clearing). With `externalId` the create UPSERTS on `(userId, source, externalId)` — the idempotent re-post iOS drives over Bearer; without it a same-`(date, moodLoggedAt)` re-post 409s. `moodLoggedAt` is bounded: no future instants beyond a 5-min clock-skew tolerance, nothing before 1900." UpdateMoodEntryRequest: type: object properties: mood: type: string enum: - SUPER_GUT - GUT - OKAY - SCHLECHT - LAUSIG tags: anyOf: - maxItems: 20 type: array items: type: string maxLength: 50 - type: "null" tagKeys: anyOf: - maxItems: 30 type: array items: type: string maxLength: 60 - type: "null" ratedFactors: anyOf: - maxItems: 30 type: array items: type: object properties: key: type: string maxLength: 60 rating: type: integer minimum: 1 maximum: 5 required: - key - rating - type: "null" a1: anyOf: - type: integer minimum: 0 maximum: 10 - type: "null" a2: anyOf: - type: integer minimum: 0 maximum: 10 - type: "null" a3: anyOf: - type: integer minimum: 0 maximum: 10 - type: "null" a4: anyOf: - type: integer minimum: 0 maximum: 10 - type: "null" a5: anyOf: - type: integer minimum: 0 maximum: 10 - type: "null" note: anyOf: - type: string maxLength: 500 - type: "null" context: anyOf: - type: object properties: workStatus: anyOf: - type: string enum: - off - partial - regular - overtime - type: "null" workMinutes: anyOf: - type: integer minimum: 0 maximum: 1440 - type: "null" overtimeMinutes: anyOf: - type: integer minimum: 0 maximum: 1440 - type: "null" workLoad: anyOf: - type: integer minimum: 0 maximum: 10 - type: "null" workSatisfaction: anyOf: - type: integer minimum: 0 maximum: 10 - type: "null" contactCircles: anyOf: - maxItems: 8 type: array items: type: string enum: - partner - child - family - friends - colleagues - acquaintances - professional - other - type: "null" contactForm: anyOf: - type: string enum: - inPerson - phone - video - written - type: "null" contactExtent: anyOf: - type: string enum: - brief - medium - extended - type: "null" contactQuality: anyOf: - type: integer minimum: 0 maximum: 10 - type: "null" contactSupport: anyOf: - type: integer minimum: 0 maximum: 10 - type: "null" leisureCategories: anyOf: - maxItems: 11 type: array items: type: string enum: - film - reading - gaming - music - creative - outing - garden - cooking - timeWithChild - relaxation - other - type: "null" leisureMinutes: anyOf: - type: integer minimum: 0 maximum: 1440 - type: "null" leisureJoy: anyOf: - type: integer minimum: 0 maximum: 10 - type: "null" leisureRecovery: anyOf: - type: integer minimum: 0 maximum: 10 - type: "null" eventType: anyOf: - type: string enum: - goodNews - badNews - success - disappointment - conflict - appointment - familyEvent - change - unexpected - type: "null" eventValence: anyOf: - type: integer minimum: -5 maximum: 5 - type: "null" eventAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" note: anyOf: - type: string maxLength: 500 - type: "null" - type: "null" moodLoggedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ description: "Per-field mood-entry edit. Omitted fields keep the stored value. `tagKeys`, `ratedFactors` and `context` replace their whole set when present (`null` clears). `a2`-`a5`: explicit null clears; `a1` null re-derives from the label. Editing `mood` without `a1` re-derives pleasantness the same way it re-derives `score`. Editing `moodLoggedAt` re-anchors the row's `date` (and `tz`) to the account's display timezone." MoodEntryIdsRequest: type: object properties: ids: minItems: 1 maxItems: 200 type: array items: type: string minLength: 1 required: - ids description: "1-200 mood-entry ids, scoped to the resolved record. A forged / foreign / non-matching id is a silent no-op, never a 404 existence leak: the mutation `where` pins the record owner's id." NtfySettingsPutRequest: anyOf: - $ref: "#/components/schemas/NotificationChannelEnabledRequest" - $ref: "#/components/schemas/NtfySettingsRequest" description: Either the toggle-only body or the full ntfy config. The toggle arm is tried first and matches only an object with `enabled` and no other key. NotificationChannelEnabledRequest: type: object properties: enabled: type: boolean required: - enabled additionalProperties: false description: "The toggle-only arm of a channel PUT: exactly `{ enabled }` and nothing else. Any additional key falls through to the channel's full config body instead. Enabling a channel whose stored config is incomplete fails with 422." NtfySettingsRequest: type: object properties: serverUrl: anyOf: - type: string maxLength: 200 format: uri - type: string const: "" topic: anyOf: - type: string maxLength: 100 - type: string const: "" authToken: anyOf: - type: string maxLength: 200 - type: string const: "" enabled: type: boolean required: - enabled description: "ntfy channel config. `serverUrl` must pass the SSRF floor — a URL resolving into a private range is refused at input time unless the operator listed its exact origin in `NOTIFICATION_PRIVATE_ORIGINS`. An EMPTY OR OMITTED `authToken` preserves the stored one rather than clearing it: the GET never returns the secret, so a client round-tripping the form would otherwise wipe it on every unrelated save. Sending a non-empty value replaces it. There is no way to clear a stored auth token through this endpoint." TelegramSettingsPutRequest: anyOf: - $ref: "#/components/schemas/NotificationChannelEnabledRequest" - $ref: "#/components/schemas/TelegramSettingsRequest" description: Either the toggle-only body or the full Telegram config. The toggle arm is tried first and matches only an object with `enabled` and no other key. TelegramSettingsRequest: type: object properties: botToken: type: string maxLength: 100 chatId: type: string maxLength: 50 enabled: type: boolean required: - enabled description: "Telegram channel config. Both credential fields are optional and behave by key PRESENCE, not by value: omit `botToken` to keep the stored one, send an empty string to clear it. Same for `chatId`. Enabling the channel registers the Telegram webhook against `APP_URL` as part of the write, so a deployment whose `APP_URL` is not a public HTTPS host on port 80/88/443/8443 cannot enable Telegram at all." WebhookSettingsPutRequest: anyOf: - $ref: "#/components/schemas/NotificationChannelEnabledRequest" - $ref: "#/components/schemas/WebhookSettingsRequest" description: Either the toggle-only body or the full webhook config. The toggle arm is tried first and matches only an object with `enabled` and no other key. WebhookSettingsRequest: type: object properties: url: anyOf: - type: string maxLength: 500 format: uri - type: string const: "" headerName: anyOf: - type: string maxLength: 100 - type: string const: "" headerValue: anyOf: - type: string maxLength: 500 - type: string const: "" format: description: Body shape. `generic` sends HealthLog's own JSON envelope (`title`, `message`, `eventType`, `priority`, plus `content` and `text` so a Discord or Slack webhook URL accepts it as is); `gotify` sends the body Gotify's `POST /message` expects, with an integer priority. Omitted keeps the stored choice, which is `generic` for a config saved before the choice existed. type: string enum: - generic - gotify enabled: type: boolean required: - enabled description: "Generic-webhook channel config — one channel covering Gotify, Discord, Slack, a Matrix bridge, Home Assistant, or any relay accepting an inbound JSON POST. `url` must pass the SSRF floor at input time and is re-checked at dispatch time; a private origin passes both only when the operator listed it in `NOTIFICATION_PRIVATE_ORIGINS`. An EMPTY OR OMITTED `headerValue` preserves the stored one, for the same reason as ntfy's `authToken`: the GET never returns it. There is no way to clear a stored header value through this endpoint. An omitted `format` keeps the stored body shape." DeleteAccountRequest: type: object properties: confirm: type: string const: DELETE_ACCOUNT description: The literal string `DELETE_ACCOUNT`. Any other value, and any non-string, is refused with 422. required: - confirm description: Typed confirmation for permanent account deletion. There is no second step and no undo. EmailChannelPutRequest: anyOf: - type: object properties: enabled: type: boolean required: - enabled additionalProperties: false - type: object properties: recipient: anyOf: - type: string maxLength: 254 format: email pattern: ^(?:[A-Za-z0-9_'+\-]+\.)*[A-Za-z0-9_'+\-]*[A-Za-z0-9_+-]@(?:[A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$ - type: string const: "" enabled: type: boolean required: - enabled description: Two accepted shapes. `{ enabled }` alone toggles the existing channel without restating the address — and is refused with 422 when no recipient has ever been saved, because enabling a channel with nowhere to send is not a state worth storing. `{ recipient, enabled }` upserts both; `recipient` may be an empty string only while `enabled` is false. DataWipeRequest: type: object properties: confirm: type: string const: DELETE description: The literal string `DELETE`. Note it is NOT the `DELETE_ACCOUNT` string the account-deletion route wants — the two confirmations are deliberately different so one cannot be pasted into the other. required: - confirm description: Typed confirmation for erasing the record. No undo. ModuleListLayoutPutBody: type: object properties: version: type: number const: 1 list: type: string enum: - labs - illness - vorsorge view: type: string enum: - cards - list order: maxItems: 500 type: array items: type: string minLength: 1 maxLength: 64 sortDir: type: string enum: - recentDesc - recentAsc - alphaAsc - alphaDesc - manual required: - version - list additionalProperties: false ConsentPostBody: type: object properties: kind: type: string enum: - ai_full - ai_insights_only - ai_coach - ai_extraction confirm: type: boolean artefact: type: string minLength: 1 signedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - kind - artefact - signedAt description: "Explicit AI-consent grant. `artefact` is an opaque signed receipt (base64 PDF or JWT, ≤ 64 KB UTF-8 bytes); `signedAt` is an ISO-8601 instant. `confirm` must be `true` (v1.43.2): send this body only from the person's own tap on an agree control, never from a launch or sync path. Without it nothing is written and the route answers 400 `consent.ai.confirmRequired`. Always appends a fresh row — re-granting after a revoke mints a new receipt." WebConsentGrantBody: type: object properties: confirm: type: boolean description: "`confirm` must be `true`: the person pressed the agree control. Any other body, or none, mints nothing and answers 400 `consent.ai.confirmRequired` (v1.43.2; before, an empty body was a silent heal sent on every AI-settings visit)." JsonImportRequest: type: object properties: measurements: maxItems: 10000 type: array items: $ref: "#/components/schemas/JsonImportMeasurement" moodEntries: maxItems: 10000 type: array items: $ref: "#/components/schemas/JsonImportMoodEntry" customMoodTagCategories: maxItems: 10000 type: array items: $ref: "#/components/schemas/JsonImportMoodTagCategory" customMoodTags: maxItems: 10000 type: array items: $ref: "#/components/schemas/JsonImportMoodTag" description: "JSON import payload: the measurements and mood entries of the JSON export or the portable full backup, and the backup's own mood vocabulary the entries link to. Every section optional; up to 10 000 rows each." JsonImportMeasurement: type: object properties: type: $ref: "#/components/schemas/MeasurementType" value: type: number description: Checked against the type's server-side plausibility range; an out-of-range value fails the whole request with 422. valueMin: description: Lower bound of a range reading (an hourly heart-rate statistic). Not above `valueMax`. type: number valueMax: description: Upper bound of a range reading. type: number unit: type: string measuredAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ description: "Bounded: no future instants beyond a 5-min clock-skew tolerance, nothing before 1900." glucoseContext: description: Optional even on a BLOOD_GLUCOSE row — a sensor export classifies nothing per sample. type: string enum: - FASTING - POSTPRANDIAL - RANDOM - BEDTIME sleepStage: description: A sleep row's stage. type: string enum: - IN_BED - ASLEEP - AWAKE - CORE - DEEP - REM rhythmClassification: description: The device's own verdict on an ECG, a rhythm alert or a steadiness reading. type: string enum: - IRREGULAR - NOT_DETECTED - INCONCLUSIVE - LOW - VERY_LOW - FIRED deviceType: description: The writer of the reading (watch, phone). type: string minLength: 1 maxLength: 32 notes: description: Encrypted at rest. type: string externalId: description: Source-stable id making re-import idempotent (upsert on `(userId, type, source=IMPORT, externalId)`; a re-import updates the row in place). Absent → first-write-wins create with a NULL key, which DOES duplicate on re-import. An unstable id (an object description or memory address that rotates per launch) skips THIS row, never the file. type: string minLength: 1 maxLength: 120 required: - type - value - unit - measuredAt description: One measurement row of a JSON import file. JsonImportMoodEntry: type: object properties: date: type: string pattern: ^\d{4}-\d{2}-\d{2}$ description: Local-day label, YYYY-MM-DD. mood: type: string enum: - SUPER_GUT - GUT - OKAY - SCHLECHT - LAUSIG score: type: integer minimum: 1 maximum: 5 description: Stored as sent — the export format's own field. Pleasantness (`a1`) is never taken from this. a1: description: Pleasantness, 0–10. Absent → derived from `mood`. type: integer minimum: 0 maximum: 10 a2: description: Stress, 0–10. type: integer minimum: 0 maximum: 10 a3: description: Energy, 0–10. type: integer minimum: 0 maximum: 10 a4: description: Connection, 0–10. type: integer minimum: 0 maximum: 10 a5: description: Stability, 0–10. `a2` to `a5` absent leave a row the import updates as it was. type: integer minimum: 0 maximum: 10 tags: description: JSON-encoded array of tag strings, stored verbatim. type: string note: description: The entry's note. Encrypted at rest. type: string maxLength: 500 loggedAt: description: Bounded like `measuredAt`. Absent → the server derives a stable per-row instant within the `date` day, so a re-import of the same file dedups instead of duplicating. type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ tz: description: IANA zone the `date` is anchored to. Absent → the legacy reading. type: string maxLength: 64 factors: description: Rated factors by key (the catalogue's, or the account's own as for `structuredTags`); replaces the entry's rated links. A key the account cannot resolve is dropped and named in `droppedFactorKeys`; a rating outside the factor's own scale skips the entry. maxItems: 30 type: array items: type: object properties: key: type: string maxLength: 60 rating: type: integer minimum: 1 maximum: 5 required: - key - rating structuredTags: description: Ticked tags by key (the catalogue, the account's own, or one the file's `customMoodTags` brings); replaces the entry's tag links. An unresolvable key is dropped and named in `droppedTagKeys`. maxItems: 30 type: array items: type: string maxLength: 60 context: description: The day context; replaces the entry's context. `contactCircles` and `leisureCategories` are also read as the backup's JSON-encoded string. type: object properties: workStatus: description: Shape of the working day. anyOf: - type: string enum: - off - partial - regular - overtime - type: "null" workMinutes: anyOf: - type: integer minimum: 0 maximum: 1440 - type: "null" overtimeMinutes: anyOf: - type: integer minimum: 0 maximum: 1440 - type: "null" workLoad: description: "0-10, INVERSE-oriented: a higher value is a heavier day, not a better one." anyOf: - type: integer minimum: 0 maximum: 10 - type: "null" workSatisfaction: anyOf: - type: integer minimum: 0 maximum: 10 - type: "null" contactCircles: description: "Who the day's contact was with. Multi-select. Never scored: the count of people is not a good or a bad number." anyOf: - type: array items: type: string enum: - partner - child - family - friends - colleagues - acquaintances - professional - other - type: "null" contactForm: anyOf: - type: string enum: - inPerson - phone - video - written - type: "null" contactExtent: anyOf: - type: string enum: - brief - medium - extended - type: "null" contactQuality: anyOf: - type: integer minimum: 0 maximum: 10 - type: "null" contactSupport: anyOf: - type: integer minimum: 0 maximum: 10 - type: "null" leisureCategories: anyOf: - type: array items: type: string enum: - film - reading - gaming - music - creative - outing - garden - cooking - timeWithChild - relaxation - other - type: "null" leisureMinutes: anyOf: - type: integer minimum: 0 maximum: 1440 - type: "null" leisureJoy: anyOf: - type: integer minimum: 0 maximum: 10 - type: "null" leisureRecovery: anyOf: - type: integer minimum: 0 maximum: 10 - type: "null" eventType: description: One notable event. One per entry, not a list. anyOf: - type: string enum: - goodNews - badNews - success - disappointment - conflict - appointment - familyEvent - change - unexpected - type: "null" eventValence: description: -5 (clearly bad) to +5 (clearly good). anyOf: - type: integer minimum: -5 maximum: 5 - type: "null" eventAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" note: description: One free-text note for the whole context. AES-256-GCM at rest; the ciphertext never rides the wire. anyOf: - type: string maxLength: 500 - type: "null" externalId: description: "Source-stable id (e.g. a Daylio row id) making re-import idempotent (upsert on `(userId, source=IMPORT, externalId)`). Same stability rule as the measurement `externalId`: an unstable id skips the row." type: string minLength: 1 maxLength: 120 required: - date - mood - score description: One mood-entry row of a JSON import file. JsonImportMoodTagCategory: type: object properties: id: description: The file's own id, read only to find the group a tag names in `categoryId`. type: string maxLength: 120 key: type: string minLength: 1 maxLength: 80 labelKey: type: string minLength: 1 maxLength: 120 icon: type: string maxLength: 60 sortOrder: type: integer minimum: -9007199254740991 maximum: 9007199254740991 isActive: type: boolean label: description: The group's own name. type: string maxLength: 200 labelEncrypted: description: A disaster-recovery file's sealed name, used only when this server's keys open it. type: string maxLength: 2000 required: - key - labelKey description: "A mood-tag group the account made itself. Matched by `key`: a group the account has is used as it is, a missing one is created." JsonImportMoodTag: type: object properties: key: type: string minLength: 1 maxLength: 80 labelKey: type: string minLength: 1 maxLength: 120 categoryId: type: string minLength: 1 maxLength: 120 description: The group's id in the file; a group the file does not carry falls back to the seeded `custom` group. kind: type: string enum: - BINARY - RATED isActive: type: boolean icon: type: string maxLength: 60 sortOrder: type: integer minimum: -9007199254740991 maximum: 9007199254740991 label: description: The tag's own name. type: string maxLength: 200 labelEncrypted: description: A disaster-recovery file's sealed name, used only when this server's keys open it. type: string maxLength: 2000 scaleMin: type: integer minimum: 0 maximum: 10 scaleMax: type: integer minimum: 0 maximum: 10 inverse: type: boolean required: - key - labelKey - categoryId - kind description: "A mood tag the account made itself, a rated factor included. Matched by `key`: the account's own tag of that key is used and never overwritten (a different label is named in `keptMoodTagKeys`); a missing one is created, within the account's custom-tag cap; a key another account of this server holds comes over under a key derived from the account and the file's key, so a second import of the same file finds it." MeasurementReminderCreate: type: object properties: label: type: string minLength: 1 maxLength: 120 measurementType: anyOf: - $ref: "#/components/schemas/MeasurementReminderType" - type: "null" intervalDays: anyOf: - type: integer minimum: 1 maximum: 3650 - type: "null" rrule: anyOf: - type: string minLength: 1 maxLength: 512 - type: "null" anchorDate: anyOf: - anyOf: - type: string format: date pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" notifyHour: default: 9 type: integer minimum: 0 maximum: 23 location: anyOf: - type: string maxLength: 200 - type: "null" enabled: default: true type: boolean required: - label description: Create a Vorsorge reminder. Exactly one of intervalDays (rolling, every N days) or rrule (RFC-5545, e.g. FREQ=YEARLY) is required. measurementType enables auto-resolve from an incoming reading; omit it for a free-text checklist reminder. MeasurementReminderType: type: string enum: - WEIGHT - BLOOD_PRESSURE_SYS - PULSE - BLOOD_GLUCOSE - OXYGEN_SATURATION - BODY_TEMPERATURE - BODY_FAT - FAT_MASS - FAT_FREE_MASS - MUSCLE_MASS - LEAN_BODY_MASS - BONE_MASS - TOTAL_BODY_WATER - VISCERAL_FAT - BODY_MASS_INDEX - WAIST_CIRCUMFERENCE - PHQ9_SCORE - GAD7_SCORE - WHO5_SCORE - SCI_SCORE description: Auto-resolve target metric (vitals + body-composition family + the PHQ-9 / GAD-7 / WHO-5 / SCI screenings). BP resolves on BLOOD_PRESSURE_SYS (the SYS row is the 'a BP was measured' sentinel); a screening reminder resolves on the server-written *_SCORE row when a check-in completes. Omit for a free-text Vorsorge that resolves only on a manual satisfy or a matching lab result. MeasurementReminderUpdate: type: object properties: label: type: string minLength: 1 maxLength: 120 measurementType: anyOf: - $ref: "#/components/schemas/MeasurementReminderType" - type: "null" intervalDays: anyOf: - type: integer minimum: 1 maximum: 3650 - type: "null" rrule: anyOf: - type: string minLength: 1 maxLength: 512 - type: "null" anchorDate: anyOf: - anyOf: - type: string format: date pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" notifyHour: type: integer minimum: 0 maximum: 23 location: anyOf: - type: string maxLength: 200 - type: "null" enabled: type: boolean description: Partial edit of a Vorsorge reminder. Omitted fields are left untouched; nextDueAt is recomputed server-side when the cadence changes and otherwise kept (a notifyHour edit moves only the hour on the same due day). MeasurementReminderSnooze: type: object properties: until: type: string format: date pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ required: - until description: "Push a reminder's current due date back to a calendar day (YYYY-MM-DD). The server resolves the day to the reminder's notifyHour in the profile timezone. Must be at least tomorrow and at most five years out. The regular cadence is untouched: lastSatisfiedAt and the anchor stay where they are, and the reminder resumes its normal interval after the snoozed cycle." CreateLabResultRequest: type: object properties: biomarkerId: type: string minLength: 1 maxLength: 64 panel: anyOf: - type: string minLength: 1 maxLength: 120 - type: string const: "" analyte: type: string minLength: 1 maxLength: 120 value: type: number valueText: type: string minLength: 1 maxLength: 120 unit: type: string minLength: 1 maxLength: 40 referenceLow: type: number referenceHigh: type: number sourceReferenceLow: type: number sourceReferenceHigh: type: number sourceReferenceText: anyOf: - type: string maxLength: 120 - type: string const: "" takenAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z))$ note: anyOf: - type: string maxLength: 2000 - type: string const: "" source: type: string enum: - MANUAL - OCR encounterId: type: string minLength: 1 maxLength: 40 required: - takenAt description: 'Record a single biomarker reading (HbA1c, LDL, ferritin, TSH, …). A reading is EITHER numeric (`value` + `unit`, optional reference bounds) OR qualitative (`valueText`, e.g. "negativ" / "positiv") — exactly one, never both, never neither. `analyte` + `unit` are free-form (a lab prints its own naming); a qualitative reading needs no unit / bounds. When both numeric bounds are present `referenceLow` must not exceed `referenceHigh`. `takenAt` is a backdatable ISO instant (no future, ≤ 50 years past). The optional `note` is encrypted at rest. The optional `source` records provenance: `"OCR"` for an on-device-OCR capture (the raw image never reaches the server on this path), defaulting to `"MANUAL"` when omitted. The optional `encounterId` files the reading against a visit the caller owns — always optional, and a link that could not be made never fails the reading.' UpdateLabResultRequest: type: object properties: panel: anyOf: - anyOf: - type: string minLength: 1 maxLength: 120 - type: string const: "" - type: "null" analyte: type: string minLength: 1 maxLength: 120 value: type: number valueText: type: string minLength: 1 maxLength: 120 unit: type: string minLength: 1 maxLength: 40 referenceLow: type: - number - "null" referenceHigh: type: - number - "null" sourceReferenceLow: type: - number - "null" sourceReferenceHigh: type: - number - "null" sourceReferenceText: anyOf: - anyOf: - type: string maxLength: 120 - type: string const: "" - type: "null" takenAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z))$ note: anyOf: - anyOf: - type: string maxLength: 2000 - type: string const: "" - type: "null" description: Partial edit of a lab result. An omitted key leaves the column untouched; an explicit `null` on `panel` / `note` / a reference bound clears it. Edit the matching value field for the row's type — `value` for a numeric row, `valueText` for a qualitative one; a request must not carry both, and a cross-type edit (the wrong field for the row) is rejected. RestoreLabResultsRequest: type: object properties: ids: minItems: 1 maxItems: 200 type: array items: type: string required: - ids OcrTextExtractRequest: type: object properties: mode: type: string const: text text: type: string description: The in-browser-OCR'd lab-report text. The raw image never reaches the server in this mode. required: - mode - text OcrCommitRequest: type: object properties: rows: minItems: 1 maxItems: 400 type: array items: type: object properties: analyte: type: string minLength: 1 maxLength: 120 panel: anyOf: - type: string minLength: 1 maxLength: 120 - type: string const: "" value: type: number valueText: type: string minLength: 1 maxLength: 120 unit: type: string minLength: 1 maxLength: 40 referenceLow: type: number referenceHigh: type: number referenceText: anyOf: - type: string maxLength: 120 - type: string const: "" takenAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z))$ biomarkerId: type: string minLength: 1 maxLength: 64 rememberAlias: type: boolean required: - analyte - takenAt documentId: type: string minLength: 1 maxLength: 64 encounterId: type: string minLength: 1 maxLength: 40 required: - rows description: The rows a human confirmed on the Lab-OCR review screen. Each row is EITHER numeric (`value` + `unit`, optional reference bounds) OR qualitative (`valueText`) — exactly one. `analyte` drives a resolve-or-mint of the user-scoped biomarker; `takenAt` is a backdatable ISO instant. No `userId` field — it is narrowed from the session. 1..400 rows (`analyte` and `valueText` up to 120 characters, `unit` up to 40). The route skips a row that duplicates a live reading (same analyte + day + value). The optional `encounterId` files the panel against a visit the caller owns — ONE link per written result row, so a marker re-run on a different day belongs to its own visit; always optional, and a link that could not be made never fails the commit. CreateBiomarkerRequest: type: object properties: name: type: string minLength: 1 maxLength: 120 unit: type: string minLength: 1 maxLength: 40 lowerBound: type: number upperBound: type: number context: type: string maxLength: 2000 panel: anyOf: - type: string minLength: 1 maxLength: 120 - type: string const: "" required: - name - unit description: "Define a user-scoped biomarker ONCE: canonical `name`, `unit`, optional reference bounds (`lowerBound` / `upperBound`; when both present `lowerBound` must not exceed `upperBound`), an optional encrypted `context` note, and an optional `panel` grouping. The name is unique per user (no second 'LDL'). Recording a value later just picks this marker — its unit + range are never re-entered." UpdateBiomarkerRequest: type: object properties: name: type: string minLength: 1 maxLength: 120 unit: type: string minLength: 1 maxLength: 40 lowerBound: type: - number - "null" upperBound: type: - number - "null" context: anyOf: - type: string maxLength: 2000 - type: "null" panel: anyOf: - anyOf: - type: string minLength: 1 maxLength: 120 - type: string const: "" - type: "null" hidden: type: boolean aliases: maxItems: 50 type: array items: type: string minLength: 1 maxLength: 120 description: Partial edit of a biomarker. An omitted key leaves the column untouched; an explicit `null` on `context` / `panel` / a bound clears it. Set `hidden` to drop / restore the marker in the active list + lab-entry pickers. A rename that collides with another of the caller's markers is rejected 409. BiomarkerMergeRequest: type: object properties: sourceIds: minItems: 1 maxItems: 10 type: array items: type: string minLength: 1 maxLength: 64 dryRun: default: false type: boolean required: - sourceIds additionalProperties: false description: Fold `sourceIds` (1..10, unique, without the target) into the marker in the path. `dryRun` (default false) answers the same result and writes nothing; a client shows it before asking to confirm, because a merge cannot be undone. CreateEncounterRequest: type: object properties: occurredAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ status: default: DONE type: string enum: - PLANNED - DONE - CANCELLED - NO_SHOW kind: default: OTHER type: string enum: - ROUTINE - ACUTE - SPECIALIST - PREVENTIVE - EMERGENCY - HOSPITAL - THERAPY - OTHER - PROCEDURE practitionerId: anyOf: - type: string minLength: 1 maxLength: 40 - type: "null" reason: anyOf: - type: string maxLength: 2000 - type: "null" outcome: anyOf: - type: string maxLength: 4000 - type: "null" bodySite: anyOf: - type: string maxLength: 200 - type: "null" laterality: anyOf: - type: string enum: - LEFT - RIGHT - BOTH - type: "null" reminderId: anyOf: - type: string minLength: 1 maxLength: 40 - type: "null" documentIds: maxItems: 100 type: array items: type: string minLength: 1 maxLength: 40 labResultIds: maxItems: 100 type: array items: type: string minLength: 1 maxLength: 40 episodeIds: maxItems: 100 type: array items: type: string minLength: 1 maxLength: 40 required: - occurredAt additionalProperties: false description: "File a visit, or book one. `occurredAt` is the only required field — a visit saves with a date and nothing else. A future instant with `status: PLANNED` books an appointment and mints exactly one reminder for it. `reason`, `outcome` and `bodySite` are encrypted at rest. `kind: PROCEDURE` marks a procedure or surgery; `bodySite` (free text) and `laterality` (LEFT / RIGHT / BOTH, null when not stated) say where, and are accepted on any kind. The three id arrays pre-link documents, lab results and condition episodes; an id naming nothing the caller owns is dropped rather than refused, so a link never blocks a save." UpdateEncounterRequest: type: object properties: occurredAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ status: type: string enum: - PLANNED - DONE - CANCELLED - NO_SHOW kind: type: string enum: - ROUTINE - ACUTE - SPECIALIST - PREVENTIVE - EMERGENCY - HOSPITAL - THERAPY - OTHER - PROCEDURE practitionerId: anyOf: - type: string minLength: 1 maxLength: 40 - type: "null" reason: anyOf: - type: string maxLength: 2000 - type: "null" outcome: anyOf: - type: string maxLength: 4000 - type: "null" bodySite: anyOf: - type: string maxLength: 200 - type: "null" laterality: anyOf: - type: string enum: - LEFT - RIGHT - BOTH - type: "null" reminderId: anyOf: - type: string minLength: 1 maxLength: 40 - type: "null" documentIds: maxItems: 100 type: array items: type: string minLength: 1 maxLength: 40 labResultIds: maxItems: 100 type: array items: type: string minLength: 1 maxLength: 40 episodeIds: maxItems: 100 type: array items: type: string minLength: 1 maxLength: 40 additionalProperties: false description: "Partial edit of a visit; an omitted key leaves the column untouched, and a body naming nothing is a 422. Moving `occurredAt` re-anchors the appointment reminder the visit already owns and never mints a second. Moving `status` to DONE closes the checkup named by `reminderId`, on the transition only. CANCELLED and NO_SHOW stop the reminder without deleting the row. A present id array replaces that link family, empty array included. Switching an existing visit to `kind: PROCEDURE` is an ordinary edit of this kind; nothing has to be entered again." EncounterLinkRequest: type: object properties: targetKind: type: string enum: - document - labResult - conditionEpisode - medication - vaccination targetIds: minItems: 1 maxItems: 100 type: array items: type: string minLength: 1 maxLength: 40 required: - targetKind - targetIds additionalProperties: false description: Targets to file against, or unfile from, a visit. Idempotent in both directions and capped at 100 ids. Ids naming nothing the caller owns come back in `unknown` rather than failing the request. CreatePractitionerRequest: type: object properties: name: type: string minLength: 1 maxLength: 160 specialty: anyOf: - type: string maxLength: 120 - type: "null" practice: anyOf: - type: string maxLength: 160 - type: "null" location: anyOf: - type: string maxLength: 300 - type: "null" phone: anyOf: - type: string maxLength: 60 - type: "null" note: anyOf: - type: string maxLength: 2000 - type: "null" required: - name additionalProperties: false description: Add a doctor or practice to the caller's own address book. `name` is the only required field and is stored as queryable plaintext so the picker can search and sort it; `note` is encrypted at rest. `specialty` is free text rather than an enum. Nothing is unique across accounts. UpdatePractitionerRequest: type: object properties: name: type: string minLength: 1 maxLength: 160 specialty: anyOf: - type: string maxLength: 120 - type: "null" practice: anyOf: - type: string maxLength: 160 - type: "null" location: anyOf: - type: string maxLength: 300 - type: "null" phone: anyOf: - type: string maxLength: 60 - type: "null" note: anyOf: - type: string maxLength: 2000 - type: "null" additionalProperties: false description: Partial edit; an omitted key leaves the column untouched, an explicit null clears it, and a body naming nothing is a 422. CreateCustomVaccineRequest: type: object properties: name: type: string minLength: 1 maxLength: 100 components: minItems: 1 maxItems: 28 type: array items: type: string enum: - tetanus - diphtheria - pertussis - polio - measles - mumps - rubella - varicella - hib - hepatitis-a - hepatitis-b - influenza - pneumococcal - herpes-zoster - rsv - covid-19 - fsme - hpv - meningococcal-acwy - meningococcal-b - rotavirus - typhoid - rabies - yellow-fever - japanese-encephalitis - cholera - dengue - tuberculosis typicalSeriesDoses: anyOf: - type: integer minimum: 1 maximum: 10 - type: "null" boosterIntervalMonths: anyOf: - type: integer minimum: 1 maximum: 600 - type: "null" required: - name - components additionalProperties: false description: A new definition. `components` lists one to all of the catalogue's antigen slugs, each once — a disease outside that list is logged as a free-text dose instead. `typicalSeriesDoses` (1–10) and `boosterIntervalMonths` (1–600) are optional; the interval prefills the booster offer after a dose. UpdateCustomVaccineRequest: type: object properties: name: type: string minLength: 1 maxLength: 100 components: minItems: 1 maxItems: 28 type: array items: type: string enum: - tetanus - diphtheria - pertussis - polio - measles - mumps - rubella - varicella - hib - hepatitis-a - hepatitis-b - influenza - pneumococcal - herpes-zoster - rsv - covid-19 - fsme - hpv - meningococcal-acwy - meningococcal-b - rotavirus - typhoid - rabies - yellow-fever - japanese-encephalitis - cholera - dengue - tuberculosis typicalSeriesDoses: anyOf: - type: integer minimum: 1 maximum: 10 - type: "null" boosterIntervalMonths: anyOf: - type: integer minimum: 1 maximum: 600 - type: "null" additionalProperties: false description: Partial edit; an omitted key is left untouched and an empty body is a 422. The change applies to every dose logged against the definition, because the series is derived on read; it never re-runs booster satisfaction. CreateVaccinationRequest: type: object properties: occurredAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ antigenSlug: anyOf: - type: string minLength: 1 maxLength: 64 - type: "null" vaccineName: anyOf: - type: string minLength: 1 maxLength: 200 - type: "null" doseNumber: anyOf: - type: integer minimum: 1 maximum: 20 - type: "null" seriesDoses: anyOf: - type: integer minimum: 1 maximum: 10 - type: "null" lotNumber: anyOf: - type: string minLength: 1 maxLength: 64 - type: "null" site: anyOf: - type: string enum: - LEFT_ARM - RIGHT_ARM - LEFT_THIGH - RIGHT_THIGH - ORAL - NASAL - OTHER - type: "null" practitionerId: anyOf: - type: string minLength: 1 maxLength: 40 - type: "null" encounterId: anyOf: - type: string minLength: 1 maxLength: 40 - type: "null" customVaccineId: anyOf: - type: string minLength: 1 maxLength: 40 - type: "null" note: anyOf: - type: string maxLength: 2000 - type: "null" documentIds: maxItems: 100 type: array items: type: string minLength: 1 maxLength: 40 required: - occurredAt additionalProperties: false description: "Log one administered dose. `occurredAt` is required and must not be in the future — a vaccination is recorded after the fact, and a planned booster is a reminder rather than a record. At least one identity arm is required: `antigenSlug` (a slug the catalogue offers), `customVaccineId` (one of the record's own vaccine definitions, see `/api/vaccinations/custom`; an id the record does not hold live is a 404 `vaccination.custom-vaccine-not-found`) or `vaccineName` (whatever the person's own record says, verbatim, which may be a trade name). When both a slug and a definition are given, the catalogue is the answer. Everything else is optional, so a decades-old paper entry with nothing but a date and a name is loggable. `note` is encrypted at rest. `documentIds` pre-links the scanned pages; an id naming nothing the caller owns is dropped rather than refused, so a link never blocks a save. Logging a dose satisfies any booster reminder keyed to one of its component antigens, the antigens of an own definition included." UpdateVaccinationRequest: type: object properties: occurredAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ antigenSlug: anyOf: - type: string minLength: 1 maxLength: 64 - type: "null" vaccineName: anyOf: - type: string minLength: 1 maxLength: 200 - type: "null" doseNumber: anyOf: - type: integer minimum: 1 maximum: 20 - type: "null" seriesDoses: anyOf: - type: integer minimum: 1 maximum: 10 - type: "null" lotNumber: anyOf: - type: string minLength: 1 maxLength: 64 - type: "null" site: anyOf: - type: string enum: - LEFT_ARM - RIGHT_ARM - LEFT_THIGH - RIGHT_THIGH - ORAL - NASAL - OTHER - type: "null" practitionerId: anyOf: - type: string minLength: 1 maxLength: 40 - type: "null" encounterId: anyOf: - type: string minLength: 1 maxLength: 40 - type: "null" customVaccineId: anyOf: - type: string minLength: 1 maxLength: 40 - type: "null" note: anyOf: - type: string maxLength: 2000 - type: "null" documentIds: maxItems: 100 type: array items: type: string minLength: 1 maxLength: 40 additionalProperties: false description: "Partial edit of a dose; an omitted key leaves the column untouched, and a body naming nothing is a 422. `occurredAt` is editable because a transcription typo is the common case — and editing it deliberately does NOT re-run the booster satisfaction, so correcting a date can never move a reminder's due date. An edit that would leave the record with no identity arm (slug, own definition or name) is refused. `customVaccineId: null` unlinks the dose from its own definition. A present `documentIds` array replaces the links, empty array included." VaccinationBoosterRequest: type: object properties: intervalMonths: type: integer minimum: 1 maximum: 600 label: type: string minLength: 1 maxLength: 120 notifyHour: type: integer minimum: 0 maximum: 23 required: - intervalMonths - label additionalProperties: false description: "The confirm body for a booster reminder — the person's own values, accepted or edited from the catalogue's prefill. `intervalMonths` is bounded at 600 (a decade booster is 120); `label` is composed client-side so its locale is the person's own. There is no antigen field: the server reads the antigen from the dose's catalogue entry, never from the request, so a client cannot key a reminder onto an antigen the dose does not contain." VaccinationLinkRequest: type: object properties: targetKind: type: string enum: - document - encounter targetIds: minItems: 1 maxItems: 100 type: array items: type: string minLength: 1 maxLength: 40 required: - targetKind - targetIds additionalProperties: false description: Documents to file against, or unfile from, a dose. Idempotent in both directions and capped at 100 ids. Ids naming nothing the caller owns come back in `unknown` rather than failing the request. CreateIllnessEpisodeRequest: type: object properties: label: type: string minLength: 1 maxLength: 120 type: type: string enum: - INFECTION - ALLERGY - INJURY - MENTAL_HEALTH - AUTOIMMUNE - CHRONIC - OTHER lifecycle: default: ACUTE type: string enum: - ACUTE - CHRONIC_ONGOING - RECURRING - FLARE onsetAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ resolvedAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" parentConditionId: anyOf: - type: string minLength: 1 maxLength: 40 - type: "null" note: anyOf: - type: string maxLength: 2000 - type: "null" bodySite: anyOf: - type: string maxLength: 200 - type: "null" laterality: anyOf: - type: string enum: - LEFT - RIGHT - BOTH - type: "null" required: - label - type description: Open an illness/condition episode. `label` is the user-facing name; `type` + `lifecycle` classify it. `onsetAt` defaults to 'now' server-side when omitted. `parentConditionId` threads a FLARE/RECURRING bout under a parent condition (must be an owned, live episode). The optional `note` is encrypted at rest. `bodySite` (free text, encrypted at rest) and `laterality` (LEFT / RIGHT / BOTH, null when not stated) say where on the body the condition sits; both are optional on every type. UpdateIllnessEpisodeRequest: type: object properties: label: type: string minLength: 1 maxLength: 120 type: type: string enum: - INFECTION - ALLERGY - INJURY - MENTAL_HEALTH - AUTOIMMUNE - CHRONIC - OTHER lifecycle: type: string enum: - ACUTE - CHRONIC_ONGOING - RECURRING - FLARE onsetAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ resolvedAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" parentConditionId: anyOf: - type: string minLength: 1 maxLength: 40 - type: "null" note: anyOf: - type: string maxLength: 2000 - type: "null" bodySite: anyOf: - type: string maxLength: 200 - type: "null" laterality: anyOf: - type: string enum: - LEFT - RIGHT - BOTH - type: "null" additionalProperties: false description: "Partial edit of an episode; an omitted key leaves the column untouched. A `null` `resolvedAt` re-opens a resolved episode; a `null` `parentConditionId` detaches it from its parent. `bodySite` and `laterality` follow the same rule: an omitted key keeps the stored value, `null` (or a blank site) clears it, so a client that does not know the fields never erases them. Rejects unknown keys." ResolveIllnessEpisodeRequest: type: object properties: resolvedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ description: Mark an episode recovered. `resolvedAt` defaults to 'now' when omitted, so the one-tap 'mark recovered' affordance can send an empty body. A CHRONIC_ONGOING episode has no recovery date by design and 422s. UpsertIllnessDayLogRequest: type: object properties: date: type: string pattern: ^\d{4}-\d{2}-\d{2}$ functionalImpact: anyOf: - type: integer minimum: 0 maximum: 3 - type: "null" feverC: anyOf: - type: number minimum: 30 maximum: 45 - type: "null" symptoms: maxItems: 40 type: array items: type: object properties: key: type: string minLength: 1 maxLength: 80 severity: type: integer minimum: 1 maximum: 3 required: - key note: anyOf: - type: string maxLength: 2000 - type: "null" loggedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - date description: Upsert one day's symptom / functional-impact / fever row for an episode (keyed on `(episodeId, date)`). `date` is a `YYYY-MM-DD` tz-anchored day. `functionalImpact` (0–3) + `feverC` are queryable plaintext; `symptoms` carry an optional 0–3 Jackson/WURSS severity per link; the optional `note` is encrypted at rest. CreateMentalHealthAssessmentRequest: type: object properties: instrument: type: string enum: - PHQ9 - GAD7 - WHO5 - SCI items: minItems: 1 maxItems: 12 type: array items: type: integer minimum: 0 maximum: 5 functionalDifficulty: type: integer minimum: 0 maximum: 3 takenAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z))$ tz: type: string maxLength: 64 locale: type: string maxLength: 16 englishOriginal: type: boolean source: default: WEB type: string enum: - WEB - IOS externalId: type: string minLength: 1 maxLength: 120 required: - instrument - items description: "Record one completed screener administration: PHQ-9 (9 items, 0–3), GAD-7 (7 items, 0–3), WHO-5 (5 items, 0–5) or SCI (8 items, 0–4). The array length and per-item ceiling must match the instrument. The item answers are encrypted at rest and never returned. The server computes the total (WHO-5 reports raw-sum × 4 as 0–100) + severity band + item-9 safety flag. `locale` is the reader's app language; set `englishOriginal` when the reader answered in the English original instead of their language's text, and the server records the resulting `wording`. Opt-in, beside mood tracking — this is a screening surface, not a diagnosis." CreateAllergyRequest: type: object properties: substance: type: string minLength: 1 maxLength: 160 category: default: OTHER type: string enum: - FOOD - MEDICATION - ENVIRONMENT - BIOLOGIC - OTHER type: default: ALLERGY type: string enum: - ALLERGY - INTOLERANCE severity: anyOf: - type: string enum: - MILD - MODERATE - SEVERE - type: "null" status: default: ACTIVE type: string enum: - ACTIVE - INACTIVE - RESOLVED onsetAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" reaction: anyOf: - type: string maxLength: 2000 - type: "null" note: anyOf: - type: string maxLength: 2000 - type: "null" required: - substance description: Record an allergy/intolerance. `substance` is the user-facing allergen name (the FHIR AllergyIntolerance `code.text` anchor — never a machine-guessed code). The free-text `reaction` + `note` are encrypted at rest. `onsetAt` is optional (unknown when omitted). Patient-reported. UpdateAllergyRequest: type: object properties: substance: type: string minLength: 1 maxLength: 160 category: type: string enum: - FOOD - MEDICATION - ENVIRONMENT - BIOLOGIC - OTHER type: type: string enum: - ALLERGY - INTOLERANCE severity: anyOf: - type: string enum: - MILD - MODERATE - SEVERE - type: "null" status: type: string enum: - ACTIVE - INACTIVE - RESOLVED onsetAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" reaction: anyOf: - type: string maxLength: 2000 - type: "null" note: anyOf: - type: string maxLength: 2000 - type: "null" additionalProperties: false description: Partial edit of an allergy; an omitted key leaves the column untouched. A `null` `severity`/`onsetAt`/`reaction`/`note` clears that field. Rejects unknown keys. CreateFamilyHistoryRequest: type: object properties: relationship: type: string enum: - MOTHER - FATHER - SISTER - BROTHER - DAUGHTER - SON - GRANDMOTHER_MATERNAL - GRANDFATHER_MATERNAL - GRANDMOTHER_PATERNAL - GRANDFATHER_PATERNAL - AUNT - UNCLE - COUSIN - HALF_SIBLING - OTHER condition: type: string minLength: 1 maxLength: 160 ageAtOnset: anyOf: - type: integer minimum: 0 maximum: 120 - type: "null" note: anyOf: - type: string maxLength: 2000 - type: "null" required: - relationship - condition description: Record one condition for one relative (a FHIR FamilyMemberHistory with a single condition). `condition` is the user-facing label (the `condition.code.text` anchor — never a machine-guessed code); `ageAtOnset` is the relative's age (years) when it began. The free-text `note` is encrypted at rest. Patient-reported. UpdateFamilyHistoryRequest: type: object properties: relationship: type: string enum: - MOTHER - FATHER - SISTER - BROTHER - DAUGHTER - SON - GRANDMOTHER_MATERNAL - GRANDFATHER_MATERNAL - GRANDMOTHER_PATERNAL - GRANDFATHER_PATERNAL - AUNT - UNCLE - COUSIN - HALF_SIBLING - OTHER condition: type: string minLength: 1 maxLength: 160 ageAtOnset: anyOf: - type: integer minimum: 0 maximum: 120 - type: "null" note: anyOf: - type: string maxLength: 2000 - type: "null" additionalProperties: false description: Partial edit of a family-history entry; an omitted key leaves the column untouched. A `null` `ageAtOnset`/`note` clears that field. Rejects unknown keys. DocumentsLayoutPutBody: type: object properties: version: type: number const: 1 view: type: string enum: - cards - list arrangement: type: string enum: - stacked - flow required: - version additionalProperties: false description: Either field may be omitted; the stored value is kept for the one left out. DocumentBulkRequest: type: object properties: ids: minItems: 1 maxItems: 100 type: array items: type: string minLength: 1 maxLength: 40 action: type: string enum: - setKind - linkEpisode - unlinkEpisode - linkEncounter - unlinkEncounter - delete - restore kind: type: string enum: - DOCTOR_REPORT - DISCHARGE_LETTER - LAB_RESULT - IMAGING - PRESCRIPTION - REFERRAL - INSURANCE - VACCINATION - SICK_NOTE - OTHER episodeId: type: string minLength: 1 maxLength: 40 encounterId: type: string minLength: 1 maxLength: 40 required: - ids - action description: "One bulk action over up to 100 owner-scoped documents: `setKind` (requires `kind`), `linkEpisode` / `unlinkEpisode` (require `episodeId`), `linkEncounter` / `unlinkEncounter` (require `encounterId`), `delete` (tombstone, undo-able for 30 days), `restore` (clear tombstone). Partial failures never abort the batch — see the per-id result array." DocumentUpdateRequest: type: object properties: title: anyOf: - type: string maxLength: 200 - type: "null" kind: type: string enum: - DOCTOR_REPORT - DISCHARGE_LETTER - LAB_RESULT - IMAGING - PRESCRIPTION - REFERRAL - INSURANCE - VACCINATION - SICK_NOTE - OTHER documentDate: anyOf: - type: string pattern: ^\d{4}-\d{2}-\d{2}$ - type: "null" episodeIds: maxItems: 20 type: array items: type: string minLength: 1 maxLength: 40 encounterIds: maxItems: 20 type: array items: type: string minLength: 1 maxLength: 40 vaccinationIds: maxItems: 100 type: array items: type: string minLength: 1 maxLength: 40 description: "Metadata edit for a stored document: `title` (user label; null clears it), `kind` (category), `documentDate` (user filing date, YYYY-MM-DD; null clears it), `episodeIds` (REPLACE-SET of condition links — the document's links become exactly this set; an empty array unlinks everything; every id must be a live episode of the caller or the whole request answers 404), `encounterIds` (the same replace-set semantics over the caller's visits), `vaccinationIds` (the same replace-set semantics over the caller's vaccination doses, up to 100 so one childhood record can be filed against a whole primary schedule). At least one field required. No `userId` field — narrowed from the session and fed to the Prisma `where` with the row id." InboundExtractRequest: anyOf: - type: object properties: mode: type: string const: text text: type: string kind: type: string enum: - DOCTOR_REPORT - DISCHARGE_LETTER - LAB_RESULT - IMAGING - PRESCRIPTION - REFERRAL - INSURANCE - VACCINATION - SICK_NOTE - OTHER required: - mode - text - type: object properties: mode: type: string const: stored required: - mode DocumentChatRequest: type: object properties: conversationId: type: string minLength: 1 maxLength: 64 message: type: string minLength: 1 maxLength: 4000 locale: type: string enum: - en - de required: - message InboundFactEditRequest: oneOf: - type: object properties: factType: type: string const: CONDITION label: type: string minLength: 1 maxLength: 300 code: anyOf: - type: string maxLength: 64 - type: "null" codeSystem: anyOf: - type: string enum: - SNOMED - ICD10 - LOINC - RXNORM - ATC - type: "null" clinicalStatus: anyOf: - type: string maxLength: 64 - type: "null" verificationStatus: anyOf: - type: string maxLength: 64 - type: "null" onsetDate: anyOf: - type: string pattern: ^\d{4}-\d{2}-\d{2}$ - type: "null" required: - factType - label - type: object properties: factType: type: string const: OBSERVATION label: type: string minLength: 1 maxLength: 300 code: anyOf: - type: string maxLength: 64 - type: "null" codeSystem: anyOf: - type: string enum: - SNOMED - ICD10 - LOINC - RXNORM - ATC - type: "null" value: type: - number - "null" valueText: anyOf: - type: string maxLength: 300 - type: "null" unit: anyOf: - type: string maxLength: 80 - type: "null" referenceLow: type: - number - "null" referenceHigh: type: - number - "null" referenceText: anyOf: - type: string maxLength: 120 - type: "null" effectiveDate: anyOf: - type: string pattern: ^\d{4}-\d{2}-\d{2}$ - type: "null" required: - factType - label - type: object properties: factType: type: string const: MEDICATION_STATEMENT name: type: string minLength: 1 maxLength: 200 dose: anyOf: - type: string maxLength: 120 - type: "null" rxNormCode: anyOf: - type: string maxLength: 20 - type: "null" atcCode: anyOf: - type: string maxLength: 16 - type: "null" statusStated: anyOf: - type: string maxLength: 64 - type: "null" effectiveDate: anyOf: - type: string pattern: ^\d{4}-\d{2}-\d{2}$ - type: "null" required: - factType - name description: A correction to a staged fact before approval (fixes OCR / units / dates / codes). Discriminated by `factType` so the edit cannot change the fact's resource type. A successful edit clears `needsReview` — the values become user-asserted. type: object InboundConfirmRequest: type: object properties: decisions: minItems: 1 maxItems: 400 type: array items: type: object properties: factId: type: string minLength: 1 maxLength: 40 action: type: string enum: - approve - reject biomarkerId: type: string minLength: 1 maxLength: 64 rememberAlias: type: boolean required: - factId - action required: - decisions description: The approve/reject decisions a human made on the review screen. Each names a staged fact by id. Approved facts are committed to the structured stores (labs / conditions / medications) through their normal create paths; rejected facts are discarded. A fact still flagged `needsReview` (below the confidence floor, never edited) cannot be approved — it is reported back as `needsReview`. No `userId` field — it is narrowed from the session; every fact id is re-scoped to the document + caller. CreateCustomMetricRequest: type: object properties: name: type: string minLength: 1 maxLength: 120 unit: type: string minLength: 1 maxLength: 40 targetLow: type: number targetHigh: type: number decimals: type: integer minimum: 0 maximum: 6 description: type: string maxLength: 2000 correlationEnabled: default: false type: boolean required: - name - unit description: "Define a user-scoped custom metric ONCE: free-text `name` + `unit`, an optional target window (`targetLow` / `targetHigh`; when both present `targetLow` must not exceed `targetHigh`), optional display `decimals`, an optional `description`, and explicit `correlationEnabled` opt-in. The name is unique per user. Logging a value later snapshots its unit." UpdateCustomMetricRequest: type: object properties: name: type: string minLength: 1 maxLength: 120 unit: type: string minLength: 1 maxLength: 40 targetLow: type: - number - "null" targetHigh: type: - number - "null" decimals: anyOf: - type: integer minimum: 0 maximum: 6 - type: "null" description: anyOf: - type: string maxLength: 2000 - type: "null" correlationEnabled: type: boolean description: Partial edit of a custom metric. An omitted key leaves the column untouched; an explicit `null` on a target bound / `decimals` / `description` clears it. `correlationEnabled` explicitly controls use as a bounded behaviour channel. A conflicting rename is rejected 409. CreateCustomMetricEntryRequest: type: object properties: value: type: number measuredAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z))$ note: anyOf: - type: string maxLength: 2000 - type: string const: "" required: - value - measuredAt description: "Log a value against a custom metric: numeric `value`, ISO 8601 `measuredAt`, optional free-text `note`. The metric's current unit is snapshotted onto the entry server-side." UpdateCustomMetricEntryRequest: type: object properties: value: type: number measuredAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z))$ note: anyOf: - anyOf: - type: string maxLength: 2000 - type: string const: "" - type: "null" description: Partial edit of a logged value. An omitted key leaves the column untouched; an explicit `null` on `note` clears it. RestoreCustomMetricEntriesRequest: type: object properties: ids: minItems: 1 maxItems: 200 type: array items: type: string minLength: 1 required: - ids OnboardingAnswerRequest: anyOf: - type: object properties: step: type: string const: who status: type: string const: done recordTarget: type: string enum: - me - someone-else - both required: - step - recordTarget additionalProperties: false - type: object properties: step: type: string const: areas status: type: string const: done areas: maxItems: 9 type: array items: type: string enum: - blood-pressure - weight-body - glucose - sleep - mood - cycle - activity - labs - illness required: - step - areas additionalProperties: false - type: object properties: step: type: string const: medication status: type: string const: done medication: type: string enum: - yes - no - sometimes required: - step - medication additionalProperties: false - type: object properties: step: type: string const: sources status: type: string const: done sources: maxItems: 10 type: array items: type: string enum: - manual - apple-health - withings - oura - whoop - polar - fitbit - strava - nightscout - file required: - step - sources additionalProperties: false - type: object properties: step: type: string const: visit status: type: string const: done visit: type: string enum: - within-a-month - later - no required: - step - visit additionalProperties: false - type: object properties: step: type: string const: units status: type: string const: done units: type: object properties: glucoseUnit: anyOf: - type: string enum: - mg/dL - mmol/L - type: "null" unitPreference: anyOf: - type: string enum: - metric - imperial - type: "null" additionalProperties: false required: - step - units additionalProperties: false - type: object properties: step: type: string const: confirm status: type: string const: done required: - step additionalProperties: false - type: object properties: step: type: string const: first-result status: type: string const: done firstResult: type: object properties: task: type: string enum: - connect-source - add-medication - log-reading target: anyOf: - type: string minLength: 1 maxLength: 64 - type: "null" completed: type: boolean required: - task additionalProperties: false required: - step - firstResult additionalProperties: false - type: object properties: step: type: string const: done status: type: string const: done required: - step additionalProperties: false - type: object properties: step: type: string const: areas status: type: string const: skipped required: - step - status additionalProperties: false - type: object properties: step: type: string const: medication status: type: string const: skipped required: - step - status additionalProperties: false - type: object properties: step: type: string const: sources status: type: string const: skipped required: - step - status additionalProperties: false - type: object properties: step: type: string const: visit status: type: string const: skipped required: - step - status additionalProperties: false - type: object properties: step: type: string const: units status: type: string const: skipped required: - step - status additionalProperties: false - type: object properties: step: type: string const: first-result status: type: string const: skipped required: - step - status additionalProperties: false description: "One step, answered or deliberately passed. The union has an arm per step id plus a skip arm for the six steps that may be passed — `who` is the one required question, and `confirm` / `done` are acknowledgements rather than questions, so none of the three has a skip arm. Every arm is strict: an unknown key is a 422, and a skip may never carry an answer." OnboardingRestartRequest: type: object properties: {} additionalProperties: false description: No input beyond the session. Send `{}`. OnboardingCompleteRequest: type: object properties: managedRecordId: description: The managed record the setup answers were given FOR — the profile "someone I look after" created on the confirm screen. When present, the module map and the dashboard order derived from the caller's answers are applied to THAT record and its own setup row is written as finished; the caller's own record is stamped complete and its derivation latched without any module being touched. Honoured only for a managed profile the caller actively guards (otherwise 404 `onboarding.complete.recordNotFound`) and only when the answer to Q1 was `someone-else` (otherwise 422 `onboarding.complete.recordTargetMismatch`); nothing is stamped on a refusal. Absent with Q1 = `someone-else`, the flow completes without deriving anything anywhere. type: string minLength: 1 maxLength: 64 displayName: type: string maxLength: 80 heightCm: type: number minimum: 50 maximum: 300 dateOfBirth: type: string gender: type: string enum: - MALE - FEMALE - OTHER description: "The completion stamp's body. `managedRecordId` (v1.39) names the managed record the answers were given for — see the field. The profile fields are the legacy half: every one optional, only a truthy value is written — sending `heightCm: 0` or an empty `displayName` leaves the column alone rather than clearing it, and there is no way to CLEAR a field through this endpoint. `dateOfBirth` is a free string parsed with `new Date(...)`: an unparseable value is silently ignored, not refused. The web flow writes its profile through `PUT /api/auth/profile` instead." DisclaimerAckRequest: type: object properties: version: type: string minLength: 1 maxLength: 64 description: The disclaimer copy version the client rendered. A freshness signal only — the server pins and persists its own canonical version. required: - version description: Acknowledge the one-time medical disclaimer shown at onboarding. TourUpdateRequest: type: object properties: completed: description: Flip the coarse completion flag. `false` is a replay reset and clears the stored progress point. type: boolean outcome: description: Informational — distinguishes reaching the end from a skip. type: string enum: - completed - skipped progress: description: Mid-tour resume checkpoint. May arrive alone or with `completed`. $ref: "#/components/schemas/TourProgress" description: Update the module-tour state. Provide `completed` and/or `progress`. TourProgress: type: object properties: lastStopId: anyOf: - type: string minLength: 1 maxLength: 64 - type: "null" completedStopIds: default: [] maxItems: 64 type: array items: type: string minLength: 1 maxLength: 64 status: type: string enum: - in_progress - completed - skipped updatedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z))$ required: - lastStopId - status - updatedAt description: Resumable module-tour progress point. `lastStopId` seeds the resume index; `status` is the running/terminal state. NutrientBatchRequest: type: object properties: entries: minItems: 1 maxItems: 500 type: array items: $ref: "#/components/schemas/NutrientIntakeEntry" required: - entries description: Bulk day-total upsert, max 500 entries. Upsert key is the composite primary key (userId, day, nutrient, source); this route always writes the APPLE_HEALTH source row, so a manual water entry is never clobbered. A re-post replaces the stored total (last-writer-wins day-total contract). NutrientIntakeEntry: type: object properties: day: type: string pattern: ^\d{4}-\d{2}-\d{2}$ nutrient: $ref: "#/components/schemas/SyncableNutrientCode" unit: type: string minLength: 1 maxLength: 10 amount: type: number minimum: 0 externalSourceVersion: type: string minLength: 1 maxLength: 120 required: - day - nutrient - unit - amount description: One nutrient day total. day = YYYY-MM-DD in the user's IANA timezone; unit must equal the catalog's canonical unit for the code (mg | ug | ml) or the entry is skipped unit_mismatch. SyncableNutrientCode: type: string enum: - vitamin_a - thiamin - riboflavin - niacin - pantothenic_acid - vitamin_b6 - biotin - folate - vitamin_b12 - vitamin_c - vitamin_d - vitamin_e - vitamin_k - calcium - iron - magnesium - phosphorus - zinc - copper - manganese - selenium - chromium - molybdenum - iodine - water - caffeine description: "A nutrient code HealthKit may send: the 24 Dietary vitamin/mineral types plus water and caffeine. Never `dha` or `epa`." NutrientCode: type: string enum: - vitamin_a - thiamin - riboflavin - niacin - pantothenic_acid - vitamin_b6 - biotin - folate - vitamin_b12 - vitamin_c - vitamin_d - vitamin_e - vitamin_k - calcium - iron - magnesium - phosphorus - zinc - copper - manganese - selenium - chromium - molybdenum - iodine - water - caffeine - dha - epa description: Closed nutrient catalog code — 24 HealthKit Dietary vitamin/mineral types plus water and caffeine, and (v1.43, responses only) `dha` and `epa`, which only a supplement's composition produces. Energy, macros and sodium/potassium are out of scope. Decode unknown values leniently. NutrientWaterWriteRequest: type: object properties: amountMl: type: number minimum: 1 maximum: 20000 mode: type: string enum: - add - set day: type: string pattern: ^\d{4}-\d{2}-\d{2}$ required: - amountMl - mode description: Manual water quick-add against the MANUAL source row. add increments today's manual total; set overwrites it. Never touches the APPLE_HEALTH row. HealthKitConfigPatchRequest: type: object properties: entries: maxItems: 50 type: array items: type: object properties: id: type: string minLength: 1 maxLength: 64 kind: type: string minLength: 1 maxLength: 64 direction: type: string enum: - bidirectional - readOnly - writeOnly - disabled description: Per-metric sync direction. enabled: type: boolean required: - id - direction required: - entries description: Merge-by-`id` update of the HealthKit metric config. Unknown ids are silently ignored; omitted fields fall back to the stored (or default) value for that entry. Up to 50 entries per call. NightscoutConnectRequest: type: object properties: url: type: string minLength: 1 maxLength: 2048 token: default: "" type: string maxLength: 512 allowPrivateHost: default: false type: boolean required: - url description: "Instance URL and optional API token. Both are trimmed before use — a trailing space or newline from a copy button reaches the instance verbatim and answers as an opaque DNS failure. `token` may be omitted or empty for a fully public instance (`AUTH_DEFAULT_ROLES=readable`). `allowPrivateHost` is accepted for older clients and ignored: only the server-side `NIGHTSCOUT_PRIVATE_ORIGINS` allowlist can authorise a private origin." WhoopCredentialsRequest: type: object properties: clientId: type: string minLength: 1 maxLength: 200 clientSecret: type: string minLength: 1 maxLength: 200 required: - clientId - clientSecret description: "Per-user WHOOP BYO-key credentials. Each self-hoster registers their own WHOOP developer app — the per-app authorized-user cap makes one shared app unworkable for a multi-operator product. Both values are trimmed (a trailing space from the portal's copy button reaches WHOOP verbatim and answers as 'unknown client') and stored encrypted at rest. Write-only: no endpoint reads them back." WithingsCredentialsRequest: type: object properties: clientId: type: string minLength: 1 maxLength: 200 clientSecret: type: string minLength: 1 maxLength: 200 required: - clientId - clientSecret description: Per-user Withings BYO-key credentials, 1–200 characters each, stored encrypted at rest and never read back. NOT trimmed. SyncTriggerRequest: type: object properties: fullSync: description: "`true` walks full history; `false` or omitted runs the incremental sync. A non-boolean here is refused with 422 — it is NOT read as `false`." type: boolean description: 'Optional flag-only body for a manual sync. Omit the body entirely (or send `{}`) for an incremental run — that is the documented shape and it keeps working. A body that IS present must be valid: unparseable JSON is 400, and a body failing this schema is 422 with the multi-issue envelope. This matters because it used to be neither: `{ "fullSync": "true" }` was silently read as `false` and answered 200, so a client that asked for full history and mistyped the value was told the run it never requested had succeeded. Unknown keys are still ignored.' FitbitCredentialsRequest: type: object properties: clientId: type: string minLength: 1 maxLength: 200 clientSecret: type: string minLength: 1 maxLength: 200 required: - clientId - clientSecret description: "Per-user Fitbit BYO-key credentials, 1–200 characters each, stored encrypted at rest and never read back. NOT trimmed: a trailing space or newline from the developer portal's copy button is stored verbatim and reaches Fitbit as part of the id." GoogleHealthCredentialsRequest: type: object properties: clientId: type: string minLength: 1 maxLength: 200 clientSecret: type: string minLength: 1 maxLength: 200 required: - clientId - clientSecret description: Per-user Google Health BYO-key credentials — each self-hoster registers their own Google Cloud OAuth client, because the Restricted-scope brand verification and CASA assessment are per-client. 1–200 characters each, stored encrypted, never read back. NOT trimmed. OuraCredentialsRequest: type: object properties: clientId: type: string minLength: 1 maxLength: 200 clientSecret: type: string minLength: 1 maxLength: 200 required: - clientId - clientSecret description: "Per-user Oura BYO-key credentials. Trimmed on the way in, because a trailing space from the portal's copy button reaches Oura verbatim and answers as 'unknown client'. Stored encrypted, never read back. Leaving these unset is valid: the integration then falls back to the operator's shared env app." PolarCredentialsRequest: type: object properties: clientId: type: string minLength: 1 maxLength: 200 clientSecret: type: string minLength: 1 maxLength: 200 required: - clientId - clientSecret description: Per-user Polar AccessLink BYO-key credentials. Trimmed on the way in. Stored encrypted, never read back. Leaving these unset falls back to the operator's shared env app. StravaCredentialsRequest: type: object properties: clientId: type: string minLength: 1 maxLength: 200 clientSecret: type: string minLength: 1 maxLength: 200 required: - clientId - clientSecret description: Per-user Strava BYO-key credentials. Strava caps every newly-created API app at athlete capacity 1, so one shared app cannot serve several self-hosters and each registers their own. Trimmed on the way in. Stored encrypted, never read back. Leaving these unset falls back to the operator's shared env app. GoogleHealthTestRequest: type: object properties: probe: description: Send `"structure"` for the per-data-type diagnostic. Anything else, including a malformed body, runs the plain connection check — the body is not validated and a typo is silently ignored rather than refused. type: string const: structure ChartOverlayPrefsPutBody: type: object properties: chartKey: type: string enum: - bp - weight - bmi - pulse - bodyFat - mood - medications - sleep - steps - vo2Max - hrv - restingHr - oxygenSaturation - bodyTemperature - activeEnergy - bloodGlucose - totalBodyWater - boneMass - flightsClimbed - walkingRunningDistance - fatFreeMass - fatMass - muscleMass - skinTemperature - pulseWaveVelocity - vascularAge - visceralFat - audioExposureEnv - audioExposureHeadphone - timeInDaylight - walkingSteadiness - audioExposureEvent - respiratoryRate - leanBodyMass - walkingHeartRateAverage - walkingAsymmetry - walkingDoubleSupport - walkingStepLength - walkingSpeed - cardioRecovery - wristTemperature - fallCount - sixMinuteWalkDistance - stairAscentSpeed - stairDescentSpeed - breathingDisturbances - ansCharge - dayStrain - workoutStrain - cardioLoad - averageHeartRate - maxHeartRate - energyExpenditureKj - gripStrength - painNrs - waistCircumference - waistToHeight - scoreHealth - scoreReadiness - scoreRecovery - scoreSleep - scoreStress - scoreStrain description: Which chart's overlays are being written. Closed enum. prefs: type: object properties: showTrendIndicator: type: boolean showTrendArrow: type: boolean showTargetRange: type: boolean comparisonBaseline: description: Defaults to `none` when omitted. type: string enum: - none - lastMonth - lastYear rangePoints: description: Persisted range-tab selection. Optional so a client on an older bundle that never sends it does not 422; omitting it stores the slot WITHOUT the field rather than storing a default. anyOf: - type: number const: 0 - type: number const: 7 - type: number const: 30 - type: number const: 90 required: - showTrendIndicator - showTrendArrow - showTargetRange description: The complete overlay state for that chart. The three booleans are REQUIRED — this replaces the chart's slot wholesale rather than merging into it, so a body that names only the toggle being flipped silently clears the other two. required: - chartKey - prefs description: One chart's overlay preferences. Partial across CHARTS (only the named `chartKey` is touched, every other chart's slot is preserved) and total WITHIN a chart (the named slot is replaced). DashboardLayoutPutBody: type: object properties: version: type: number const: 1 widgets: minItems: 1 maxItems: 50 type: array items: type: object properties: id: type: string enum: - weight - bp - pulse - bodyFat - mood - medications - sleep - steps - glucose - totalBodyWater - boneMass - muscleMass - bpInTarget - oxygenSaturation - hrv - respiratoryRate - achievements - vo2Max - recentWorkouts - cardioRecovery - sixMinuteWalk - stairAscentSpeed - stairDescentSpeed - breathingDisturbances - wristTemperature - falls - walkingSteadiness - vorsorge - waterIntake - restingHeartRate - walkingSpeed - walkingAsymmetry - walkingStepLength - bmi - bodyTemperature - walkingDoubleSupport - audioExposureEnvironment - audioExposureHeadphone - gripStrength - painNRS - waistCircumference - waistToHeight visible: type: boolean tileVisible: type: boolean order: type: integer minimum: 0 maximum: 99 required: - id - visible - order comparisonBaseline: type: string enum: - none - lastMonth - lastYear chartOverlayPrefs: type: object propertyNames: type: string enum: - bp - weight - bmi - pulse - bodyFat - mood - medications - sleep - steps - vo2Max - hrv - restingHr - oxygenSaturation - bodyTemperature - activeEnergy - bloodGlucose - totalBodyWater - boneMass - flightsClimbed - walkingRunningDistance - fatFreeMass - fatMass - muscleMass - skinTemperature - pulseWaveVelocity - vascularAge - visceralFat - audioExposureEnv - audioExposureHeadphone - timeInDaylight - walkingSteadiness - audioExposureEvent - respiratoryRate - leanBodyMass - walkingHeartRateAverage - walkingAsymmetry - walkingDoubleSupport - walkingStepLength - walkingSpeed - cardioRecovery - wristTemperature - fallCount - sixMinuteWalkDistance - stairAscentSpeed - stairDescentSpeed - breathingDisturbances - ansCharge - dayStrain - workoutStrain - cardioLoad - averageHeartRate - maxHeartRate - energyExpenditureKj - gripStrength - painNrs - waistCircumference - waistToHeight - scoreHealth - scoreReadiness - scoreRecovery - scoreSleep - scoreStress - scoreStrain additionalProperties: type: object properties: showTrendIndicator: type: boolean showTrendArrow: type: boolean showTargetRange: type: boolean comparisonBaseline: type: string enum: - none - lastMonth - lastYear rangePoints: anyOf: - type: number const: 0 - type: number const: 7 - type: number const: 30 - type: number const: 90 required: - showTrendIndicator - showTrendArrow - showTargetRange selectedScoreRings: maxItems: 3 type: array items: type: string enum: - READINESS - RECOVERY_SCORE - SLEEP_SCORE - MED_COMPLIANCE heroRingOrder: maxItems: 4 type: array items: type: string enum: - HEALTH_SCORE - READINESS - RECOVERY_SCORE - SLEEP_SCORE - MED_COMPLIANCE enabledHeroItemKinds: description: Today-highlight item kinds in catalogue order. Missing means every current kind is enabled; an empty array disables all kinds. maxItems: 9 type: array items: type: string enum: - coach_checkin - dose_window - preventive_care - sync_issue - milestone - ecg_new_recording - tension_window - same_time_baseline - upcoming_visit hero: description: 'Primary hero-card content on the dashboard: "score" (the health-score read, default) or "reminders" (the Today-highlight rail promoted into the hero slot). Missing resolves to "score"; the stored default is omitted from responses.' type: string enum: - score - reminders todayCardVisible: description: v1.42 — whether the dashboard shows its top card (the Today card above the tiles). Missing resolves to `true`; only an explicit `false` hides it. The stored default is omitted. A layout reset (DELETE) shows it again. Additive. type: boolean baseUpdatedAt: description: "Optimistic-concurrency base token: the `updatedAt` the client last read for this resource. Omit it for the legacy unconditional write (older clients are unaffected). When present, the write is guarded on the stored row still carrying this exact value — a stale token fails with 409 and changes nothing. A present-but-unparseable value fails with 422 and `meta.errorCode` = `invalid_base_updated_at` — note that this is NOT the unconditional write: sending `null` or a malformed string is rejected rather than treated as an omitted token. Treat as opaque: only ever echo a server-returned value, never parse or synthesise it." type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - version description: PUT body for the dashboard layout — the layout fields plus the optional optimistic-concurrency base token (`baseUpdatedAt`). Omit the token for the legacy unconditional write. CreateManagedProfileHandoverRequest: type: object properties: expiresInDays: default: 7 anyOf: - type: number const: 1 - type: number const: 7 - type: number const: 14 proposals: default: [] maxItems: 50 type: array items: type: object properties: grantId: type: string minLength: 1 maxLength: 64 proposal: type: string enum: - end - read - manage required: - grantId - proposal additionalProperties: false additionalProperties: false description: "`expiresInDays` is 1, 7 (default) or 14. `proposals` names at most one entry per active guardian grant; a guardian left out is proposed `read`, and a grant that is not an active guardian of this profile is refused with 422." HandoverDecisionRequest: type: object properties: decisions: maxItems: 50 type: array items: type: object properties: grantId: type: string minLength: 1 maxLength: 64 access: type: string enum: - end - read - manage required: - grantId - access additionalProperties: false required: - decisions additionalProperties: false description: The new owner's decision per former guardian, named by the guardian's grant id at the claim. A guardian left out keeps the access the claim gave them. AccountGrantInvite: type: object properties: identifier: type: string minLength: 1 maxLength: 255 expiresAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" access: default: READ description: "READ can read the record and change nothing. WRITE can additionally ADD entries (readings, results, observations, a medication, a marked dose) and can still edit or delete nothing, including its own. MANAGE can additionally change and remove entries, including ones the owner wrote, read the generated insights, and record the record's own health background (allergies, family history). The boundary is record content versus account configuration: MANAGE never reaches login, the second factor, provider connections, API tokens, notification routing, module and threshold configuration, or who else has access. Omitted means READ, so a client that predates the field keeps working. The level is fixed when the invitation is written: no endpoint raises a live grant, because that would widen what the delegate accepted without asking them again. The way up is a new invitation the delegate accepts." type: string enum: - READ - WRITE - MANAGE scope: description: "The sections of the record this invitation opens. Omitted or null means the entire record, which is what every grant written before v1.37.0 means and what an owner who does not narrow still gets. A non-null value is a non-empty array of section keys from the closed vocabulary; an empty array, an unknown key and the whole-record sentinel `record` are each refused. A stored scope never grows: a section added by a later release is not in a set an owner ticked before it existed." anyOf: - minItems: 1 type: array items: type: string enum: - measurements - medications - labs - profile - illness - mind - cycle - documents - type: "null" required: - identifier description: "Offer access to another account on this instance. Every sentence here describes an INVITED grant on this endpoint, and nothing else: a Guardian of a managed profile also holds MANAGE over a whole record, but that grant is minted by `POST /api/managed-profiles/{id}/guardians` under its own gate and answers with its own shape, so none of the rules below — the step-up, the transport, the refusals — may be carried across to it. `identifier` is the invitee's username or e-mail, matched case-insensitively — the same identifier they sign in with. `expiresAt` is optional; omitted or null means the grant runs until somebody ends it. `access` is READ when omitted, so a client that does not know about the field keeps working: READ can read the record and change nothing, WRITE can additionally ADD entries (readings, results, observations, a medication, a marked dose) and can still edit or delete nothing, including its own, and MANAGE can additionally change and remove entries, including ones the owner wrote, read the generated insights, and record the record's own health background (allergies, family history). The boundary is record content versus account configuration: MANAGE never reaches login, the second factor, provider connections, API tokens, notification routing, module and threshold configuration, or who else has access. `scope` narrows a READ or WRITE grant to named sections; omitted or null is the entire record, which is what every grant written before v1.37.0 means. An empty array, an unknown key, and any `scope` at all beside `access: MANAGE` are each 422 — management is whole-record by construction. Offering MANAGE additionally requires a fresh second factor when the account has one enrolled, which makes it cookie-only: a Bearer caller is refused with 403 `sharing.invite.manage_browser_only` and should send the person to a browser. The level and the scope are fixed when the invitation is written — no endpoint raises a live grant, because that would widen what the delegate accepted without asking them again. The way up is a new invitation the delegate accepts." CreateManagedProfileRequest: type: object properties: displayName: type: string minLength: 1 maxLength: 80 dateOfBirth: anyOf: - type: string format: date pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ - type: "null" locale: type: string enum: - de - en - fr - es - it - pl - ko - nl - pt-BR timezone: type: string minLength: 1 maxLength: 64 gender: anyOf: - type: string enum: - MALE - FEMALE - OTHER - type: "null" required: - displayName - locale - timezone additionalProperties: false description: "Create a health record for somebody who has no login of their own — a child, a dependent adult, an animal. The caller becomes its first Guardian in the same transaction, through an ordinary MANAGE grant with the new record as grantor, so the profile appears in `accountAccess.accounts` on the next `GET /api/auth/me` and is switched into like any other shared record. `displayName` is the only required text; `dateOfBirth` is an optional `YYYY-MM-DD` and is never synthesised from a year. `gender` is optional and takes the same three values `PATCH /api/auth/me` takes for an ordinary account (`MALE`, `FEMALE`, `OTHER`) or null; absent and null both mean not recorded, and the value is what the cycle module derives its default from, so a record created for a child who does not need it starts with it off. `locale` and `timezone` belong to the RECORD and not to the Guardian reading it: they decide how the profile's own reminders are worded and when its day starts. Strict — an unexpected field is a 422 rather than an ignored key. Cookie transport only and step-up gated (`requireFreshMfa`, unconditional): a Bearer caller cannot mint a credential-less person and a permanent management relationship, and an account with no second factor enrolled is refused rather than waved through. A primary passkey counts as that factor here: a passkey sign-in or a passkey re-proof at POST /api/auth/reproof stamps the session. Rate-limited to ten an hour per caller." UpdateManagedProfileRequest: type: object properties: displayName: type: string minLength: 1 maxLength: 80 dateOfBirth: anyOf: - type: string format: date pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ - type: "null" locale: type: string enum: - de - en - fr - es - it - pl - ko - nl - pt-BR timezone: type: string minLength: 1 maxLength: 64 gender: anyOf: - type: string enum: - MALE - FEMALE - OTHER - type: "null" additionalProperties: false description: "Change what a managed record is, from the Guardian's own panel. The same five fields creation takes, every one of them optional, and at least one required — an empty body is a 422 rather than a no-op that audits a change nobody made. Absence means leave the field alone; an explicit null on `dateOfBirth` or `gender` clears it, and the two are different answers. Nothing else on the record is reachable here: credentials, provider connections, notification routing and the health profile are not identity, and the wider configuration surface is `PATCH /api/record-settings/{family}`, which is guardian-fenced and answers only while switched into the record. Strict, cookie-only and step-up gated, like every act in this family." InviteManagedProfileGuardianRequest: type: object properties: identifier: type: string minLength: 1 maxLength: 255 expiresAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" required: - identifier additionalProperties: false description: Offer somebody else a share in looking after this profile. `identifier` is their username or e-mail, matched case-insensitively. `expiresAt` is an ISO instant WITH an offset — a bare `YYYY-MM-DD` is a 422 — or null for an invitation that runs until somebody ends it. The grant is created PENDING at MANAGE over the whole record; the invitee accepts it on their own invitations card exactly as they would any other, and until they do they do not satisfy the last-Guardian floor. Strict, cookie-only, step-up gated and rate-limited to ten an hour, like every act in this family. AccountSwitchRequest: type: object properties: accountId: anyOf: - type: string minLength: 1 maxLength: 64 - type: "null" expectedEpoch: description: "The record epoch the caller believes this browser session is at, as published by GET /api/auth/me or a previous switch. Present makes the write conditional: if another tab moved the selector first the switch is refused with 409 sharing.session.changed and the caller reconciles through /api/auth/me. Omitted keeps the unconditional write, which is what a client predating the record-session fence sends. Cookie transport only." type: integer minimum: 0 maximum: 9007199254740991 required: - accountId description: "Point this browser session at an account, or (with `accountId: null`) back at its own. Cookie transport only." WebPushSubscribeRequest: type: object properties: endpoint: type: string maxLength: 500 format: uri keys: type: object properties: p256dh: type: string minLength: 1 auth: type: string minLength: 1 required: - p256dh - auth required: - endpoint - keys description: A browser Push subscription, exactly as `PushSubscription.toJSON()` produces it. `endpoint` must be HTTPS and must resolve to a public host — the push library dials it later with its own fetch that bypasses the shared egress wrapper, so the SSRF floor is enforced here at input time and again at send time. The `p256dh` and `auth` keys are stored encrypted at rest. WebPushUnsubscribeRequest: type: object properties: endpoint: type: string format: uri description: The endpoint of the subscription to forget. required: - endpoint description: Identifies a subscription to remove. Scoped to the caller, so an endpoint belonging to another account matches nothing. NotificationPreferenceRequest: type: object properties: channelId: type: string minLength: 1 eventType: type: string enum: - MEDICATION_REMINDER - MEASUREMENT_ANOMALY - COMPLIANCE_LOW - WITHINGS_SYNC_FAILED - SYSTEM_ALERT - PERSONAL_RECORD - MOOD_REMINDER - CYCLE_PERIOD_SOON - CYCLE_PERIOD_CONFIRM - CYCLE_FERTILE_SOON - COACH_NUDGE - MEDICATION_LOW_STOCK - MEASUREMENT_REMINDER - MEDICATION_INTAKE_SYNC - SECURITY_ALERT - DAILY_BRIEFING - SHARED_RECORD_INTAKE - BACKUP_HEALTH enabled: type: boolean required: - channelId - eventType - enabled description: "Set one per-(channel, event) override. `channelId` must belong to the caller — a channel id owned by somebody else answers 404, not 403. Upsert: the row is created when absent." ReEnableNotificationChannelRequest: type: object properties: channelId: type: string minLength: 1 maxLength: 64 required: - channelId description: Clear the auto-disable on one of the caller's own channels. A channel id the caller does not own answers 404. CreateMcpTokenRequest: type: object properties: name: type: string minLength: 1 maxLength: 100 expiresInDays: type: integer minimum: 1 maximum: 365 scope: default: read type: string enum: - read - read_write required: - name description: "Mint a connector Bearer. `scope` is a closed two-value choice, not a permission list: `read` (the default) grants `health:read`, `read_write` adds `health:write`. The permission array is built field by field from that choice, so no request shape can widen the grant. `expiresInDays` defaults to 90." RecordSettingsPatchRequest: anyOf: - $ref: "#/components/schemas/RecordProfileSettingsPatch" - $ref: "#/components/schemas/RecordModuleSettingsPatch" - $ref: "#/components/schemas/RecordNotificationSettingsPatch" - $ref: "#/components/schemas/RecordThresholdSettingsPatch" - $ref: "#/components/schemas/RecordCoachSettingsPatch" - $ref: "#/components/schemas/RecordInsightsSettingsPatch" description: The body for the family named in the path. Only that family's variant is accepted — sending the profile body to `/thresholds` is a 422, because every variant is strict. OpenAPI cannot tie the variant to the path parameter, so the union is the honest publication of six separate contracts sharing one URL shape. RecordProfileSettingsPatch: type: object properties: displayName: anyOf: - type: string minLength: 1 maxLength: 100 - type: "null" heightCm: anyOf: - type: number minimum: 30 maximum: 300 - type: "null" dateOfBirth: anyOf: - type: string format: date pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ - type: "null" gender: anyOf: - type: string enum: - MALE - FEMALE - OTHER - type: "null" locale: anyOf: - type: string enum: - de - en - fr - es - it - pl - ko - nl - pt-BR - type: "null" timezone: type: string minLength: 1 maxLength: 64 unitPreference: type: string enum: - metric - imperial timeFormat: type: string enum: - AUTO - H12 - H24 dateFormat: type: string enum: - AUTO - DMY - MDY - YMD additionalProperties: false description: Partial profile edit. Every key is optional and an omitted key is untouched; a `null` on a nullable field clears it. Strict — an unknown key is a 422, which is what keeps the allowlist an allowlist. RecordModuleSettingsPatch: type: object properties: modulePreferences: type: object properties: mood: type: boolean sleep: type: boolean glucose: type: boolean workouts: type: boolean recovery: type: boolean labs: type: boolean illness: type: boolean achievements: type: boolean insights: type: boolean medications: type: boolean doctorReport: type: boolean environment: type: boolean mcp: type: boolean inboundDocuments: type: boolean mentalHealth: type: boolean nutrients: type: boolean vaccinations: type: boolean timeline: type: boolean additionalProperties: false cycleTrackingEnabled: type: boolean additionalProperties: false description: "Partial module toggle. `modulePreferences` is merged onto the record's current preferences; omitted keys keep their value. `cycleTrackingEnabled` is separate because `cycle` is a DELEGATED module: its state lives in the record's own cycle profile and `modulePreferences.cycle` is refused, so this is the field that turns cycle tracking on or off for the record — an explicit boolean overrides the default the record's sex would derive. Both fields are optional and at least one is required; an empty body is a 422 rather than a save that changed nothing." RecordNotificationSettingsPatch: type: object properties: moodReminderEnabled: type: boolean notificationPreferences: type: object properties: medication: type: object properties: lowStockRunwayDays: anyOf: - type: integer minimum: 1 maximum: 60 - type: "null" reorderLeadDays: type: integer minimum: 0 maximum: 60 additionalProperties: false mood: type: object properties: reminderHour: type: integer minimum: 0 maximum: 23 additionalProperties: false additionalProperties: false additionalProperties: false description: Reminder timing and low-stock thresholds. No delivery-channel field exists here by design. RecordThresholdSettingsPatch: type: object properties: overrides: type: object properties: WEIGHT: type: object properties: min: type: number minimum: 30 maximum: 300 max: type: number minimum: 30 maximum: 300 required: - min - max BLOOD_PRESSURE_SYS: type: object properties: min: type: number minimum: 80 maximum: 220 max: type: number minimum: 80 maximum: 220 required: - min - max BLOOD_PRESSURE_DIA: type: object properties: min: type: number minimum: 40 maximum: 140 max: type: number minimum: 40 maximum: 140 required: - min - max PULSE: type: object properties: min: type: number minimum: 30 maximum: 220 max: type: number minimum: 30 maximum: 220 required: - min - max BODY_FAT: type: object properties: min: type: number minimum: 3 maximum: 60 max: type: number minimum: 3 maximum: 60 required: - min - max SLEEP_DURATION: type: object properties: min: type: number minimum: 3 maximum: 14 max: type: number minimum: 3 maximum: 14 required: - min - max ACTIVITY_STEPS: type: object properties: min: type: number minimum: 0 maximum: 50000 max: type: number minimum: 0 maximum: 50000 required: - min - max BLOOD_GLUCOSE_FASTING: type: object properties: min: type: number minimum: 40 maximum: 400 max: type: number minimum: 40 maximum: 400 required: - min - max BLOOD_GLUCOSE_POSTPRANDIAL: type: object properties: min: type: number minimum: 40 maximum: 500 max: type: number minimum: 40 maximum: 500 required: - min - max BLOOD_GLUCOSE_RANDOM: type: object properties: min: type: number minimum: 40 maximum: 500 max: type: number minimum: 40 maximum: 500 required: - min - max BLOOD_GLUCOSE_BEDTIME: type: object properties: min: type: number minimum: 40 maximum: 400 max: type: number minimum: 40 maximum: 400 required: - min - max TOTAL_BODY_WATER: type: object properties: min: type: number minimum: 5 maximum: 100 max: type: number minimum: 5 maximum: 100 required: - min - max BONE_MASS: type: object properties: min: type: number minimum: 0.5 maximum: 8 max: type: number minimum: 0.5 maximum: 8 required: - min - max OXYGEN_SATURATION: type: object properties: min: type: number minimum: 50 maximum: 100 max: type: number minimum: 50 maximum: 100 required: - min - max additionalProperties: false required: - overrides additionalProperties: false description: "Per-metric range overrides, merged onto the stored set. Each range must sit inside the metric's physiological bounds with `min` strictly below `max`. There is no clear arm: sending a metric replaces its override, and omitting it keeps the stored one." RecordCoachSettingsPatch: type: object properties: disableCoach: type: boolean preferences: type: object properties: tone: default: warm type: string enum: - warm - neutral - concise verbosity: default: default type: string enum: - brief - default - detailed excludeMetrics: default: [] maxItems: 11 type: array items: type: string enum: - bp - weight - pulse - mood - compliance - hrv - sleep - resting_hr - steps - medications - anthropometrics defaultWindow: default: allTime type: string enum: - last7days - last30days - last90days - lastYear - allTime description: "How far back the Coach may read, in every answer: snapshot, retrieval tools and summaries alike. `allTime` (the default) is no limit. Formerly only the starting window; `lastYear` came with the limit." dataClusters: maxItems: 10 type: array items: type: string enum: - cardio - body - activity - workouts - sleep - mood - glucose - medication - mobility - environment additionalProperties: false additionalProperties: false description: The Coach kill switch and the prompt-tuning subset. `preferences` is merged onto the stored preferences, not replaced wholesale. RecordInsightsSettingsPatch: type: object properties: layout: type: object properties: version: anyOf: - type: number const: 1 - type: number const: 2 sections: maxItems: 50 type: array items: type: object properties: id: type: string enum: - wellness-scores - daily-briefing - vitals - trends - period-review - cycle-summary - signals - rhythm-events - health-status - breathing - labs-changes - ecg visible: type: boolean order: type: integer minimum: 0 maximum: 99 required: - id - visible - order additionalProperties: false tiles: minItems: 1 maxItems: 63 type: array items: type: object properties: id: type: string enum: - overview - blood-pressure - pulse - oxygen - body-temperature - respiratory-rate - pain - weight - bmi - body-fat - body-water - bone-mass - fat-free-mass - fat-mass - muscle-mass - visceral-fat - lean-body-mass - waist - waist-to-height - steps - active-energy - workouts - flights-climbed - walking-distance - walking-steadiness - walking-heart-rate - walking-asymmetry - double-support-time - step-length - walking-speed - cardio-fitness - falls - six-minute-walk - stair-ascent-speed - stair-descent-speed - grip-strength - sleep - breathing-disturbances - resting-pulse - hrv - pulse-wave-velocity - vascular-age - cardio-recovery - environmental-audio - headphone-audio - audio-events - daylight - blood-glucose - skin-temperature - wrist-temperature - mood - medications - nutrients - blutdruck - puls - sauerstoff - koerpertemperatur - gewicht - aktive-energie - schlaf - ruhepuls - stimmung - medikamente visible: type: boolean order: type: integer minimum: 0 maximum: 99 required: - id - visible - order additionalProperties: false required: - version additionalProperties: false required: - layout additionalProperties: false description: The full insights layout. Unlike the other five families this one is a replacement rather than a merge. EnvironmentPreferencesRequest: type: object properties: airQualityEnabled: type: boolean description: Fetch and show air quality, pollen and UV for this account. required: - airQualityEnabled additionalProperties: false EnvironmentHomeRequest: type: object properties: lat: type: number minimum: -90 maximum: 90 lon: type: number minimum: -180 maximum: 180 label: type: string minLength: 1 maxLength: 200 timezone: type: string minLength: 1 maxLength: 64 required: - lat - lon - label - timezone additionalProperties: false description: "The coarse home location to store. `timezone` is checked against the runtime IANA set rather than accepted as free text — a bogus zone would mis-key every stored environment day for the account. Strict: an unknown key is a 422." EnvironmentBackfillRequest: type: object properties: startDate: type: string pattern: ^\d{4}-\d{2}-\d{2}$ endDate: type: string pattern: ^\d{4}-\d{2}-\d{2}$ additionalProperties: false description: The span to fetch. Both bounds are optional — omitting them asks for the conservative `[homeSince .. today]` range rather than a fixed reach into the past under the current home. Strict. EnvironmentTravelRequest: type: object properties: startDate: type: string pattern: ^\d{4}-\d{2}-\d{2}$ endDate: type: string pattern: ^\d{4}-\d{2}-\d{2}$ lat: type: number minimum: -90 maximum: 90 lon: type: number minimum: -180 maximum: 180 label: type: string minLength: 1 maxLength: 200 required: - startDate - endDate - lat - lon - label additionalProperties: false description: A manual travel override covering an inclusive [startDate, endDate] range. A declared trip dominates the home fallback for every day it covers. Strict, and `startDate` must be on or before `endDate`. ExternalMedicationIntakeRequest: type: object properties: medicationName: type: string minLength: 1 maxLength: 200 takenAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ idempotencyKey: type: string maxLength: 128 required: - medicationName - idempotencyKey description: "An externally-observed dose. `medicationName` is matched case-insensitively against the token owner's ACTIVE medications — there is no id form on this surface. `takenAt` is optional and defaults to now, bounded by the same plausibility range the interactive create paths use. `idempotencyKey` is an identity, not a windowed replay token: it is stored on the row forever and matched by equality, so a key that rotates per client launch never matches its own earlier row and every re-sync logs the dose again." AdminBackupRestoreRequest: type: object properties: confirm: type: string const: RESTORE restoreInstanceSettings: type: boolean required: - confirm description: "`confirm` is the literal typed gate; anything else is refused with 422 and nothing is read. `restoreInstanceSettings` defaults to false: the restore then replaces the snapshot owner's data tables and leaves the singleton instance settings — registration, MFA requirement, default locale and timezone, module availability, notification and AI configuration, document cap and quota — exactly as they are. Send `true` only when this server's instance settings themselves need to go back to the ones in the copy, because those settings apply to every account on the installation. An account copy restores one existing account on this server; it does not rebuild a lost server, which takes a database dump and the encryption keys (`docs/ops/disaster-recovery.md`)." CreateSymptomDefinitionRequest: type: object properties: label: type: string minLength: 1 maxLength: 40 icon: type: - string - "null" required: - label description: Define a symptom of your own. `label` (1-40 characters) is encrypted at rest; `icon` is one of the cycle custom-symptom icon names, null for the default glyph. At most eight active definitions per account (422 `symptoms.definition.limitReached`). UpdateSymptomDefinitionRequest: type: object properties: label: type: string minLength: 1 maxLength: 40 icon: type: - string - "null" isActive: type: boolean sortOrder: type: integer minimum: 0 maximum: 1000 description: "Partial edit; an omitted key leaves the column untouched. `isActive: false` hides the symptom (history kept, slot freed); `isActive: true` shows it again and re-checks the cap." CreateSymptomEventRequest: type: object properties: definitionId: type: string minLength: 1 maxLength: 40 occurredAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ intensity: type: integer minimum: 0 maximum: 10 note: anyOf: - type: string maxLength: 500 - type: "null" episodeId: anyOf: - type: string minLength: 1 maxLength: 40 - type: "null" required: - definitionId - intensity description: Log one occurrence. `intensity` is 0-10 (0 none, 10 the worst imaginable). `occurredAt` defaults to now and may not lie in the future beyond five minutes. `episodeId`, when given, must name one of the record's own illness episodes that is still open, or a resolved one whose onset-to-recovery span holds `occurredAt` (422 `symptoms.episode.notOpen` otherwise). The note is encrypted at rest. UpdateSymptomEventRequest: type: object properties: occurredAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ intensity: type: integer minimum: 0 maximum: 10 note: anyOf: - type: string maxLength: 500 - type: "null" episodeId: anyOf: - type: string minLength: 1 maxLength: 40 - type: "null" description: "Partial edit of one occurrence. `note: null` clears the note, `episodeId: null` drops the link. A changed `episodeId` must name an open episode or a resolved one whose span holds the occurrence's time after the edit; an unchanged link to an episode that has since resolved is kept." CreateLifeEventRequest: type: object properties: category: type: string enum: - FAMILY - HOME - WORK - LOSS - OTHER startDate: type: string description: A local calendar date, `YYYY-MM-DD`. endDate: anyOf: - type: string description: A local calendar date, `YYYY-MM-DD`. - type: "null" precision: type: string enum: - DAY - MONTH - YEAR title: type: string minLength: 1 maxLength: 120 note: anyOf: - type: string maxLength: 2000 - type: "null" required: - category - startDate - precision - title additionalProperties: false UpdateLifeEventRequest: type: object properties: category: type: string enum: - FAMILY - HOME - WORK - LOSS - OTHER startDate: type: string description: A local calendar date, `YYYY-MM-DD`. endDate: anyOf: - type: string description: A local calendar date, `YYYY-MM-DD`. - type: "null" precision: type: string enum: - DAY - MONTH - YEAR title: type: string minLength: 1 maxLength: 120 note: anyOf: - type: string maxLength: 2000 - type: "null" additionalProperties: false AiUsagePutRequest: type: object properties: level: anyOf: - type: string enum: - frugal - standard - full - type: "null" required: - level additionalProperties: false description: Set the account's own AI usage level; `null` returns to the instance default. A level above `maxLevel` is refused, never capped silently. MedicationIngredientsPutRequest: type: object properties: ingredients: maxItems: 40 type: array items: type: object properties: nutrient: type: string minLength: 1 maxLength: 40 amount: type: number exclusiveMinimum: 0 unit: type: string minLength: 1 maxLength: 8 required: - nutrient - amount - unit additionalProperties: false source: default: MANUAL type: string enum: - MANUAL - LABEL_AI required: - ingredients additionalProperties: false description: "Replace the composition: 0..40 entries, one per nutrient code, `amount` > 0 in one of the code's `inputUnits` (`GET /api/nutrients/catalog`). `source` is `MANUAL` (default) or `LABEL_AI` after a label proposal was reviewed." LifeAiSectionsPatchRequest: type: object properties: sections: type: object propertyNames: type: string enum: - SMOKING_STATUS - ALCOHOL_PATTERN - SHIFT_SCHEDULE - ACTIVITY - WORK - HABITS - GOALS additionalProperties: type: boolean baseUpdatedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - sections additionalProperties: false LifeConfirmRequest: type: object properties: section: type: string enum: - tobacco - alcohol - activity - work - habits - goals required: - section additionalProperties: false AuditCRequest: type: object properties: items: type: array prefixItems: - type: integer minimum: 0 maximum: 4 - type: integer minimum: 0 maximum: 4 - type: integer minimum: 0 maximum: 4 items: false minItems: 3 maxItems: 3 takenAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ tz: type: string minLength: 1 maxLength: 64 locale: type: string minLength: 2 maxLength: 16 source: type: string enum: - WEB - IOS externalId: type: string minLength: 1 maxLength: 128 required: - items - source additionalProperties: false DawarichConnectRequest: type: object properties: url: type: string maxLength: 2048 format: uri apiKey: type: string minLength: 1 maxLength: 512 required: - url - apiKey additionalProperties: false description: "`url` (http or https, at most 2048) and `apiKey` (1..512); both sealed at rest. A private host needs its origin in `DAWARICH_PRIVATE_ORIGINS`." DawarichSettings: type: object properties: homePlaceId: anyOf: - type: integer exclusiveMinimum: 0 maximum: 9007199254740991 - type: "null" homeCountry: anyOf: - type: string pattern: ^[A-Z]{2}$ - type: "null" additionalProperties: false description: The home place (an id on the Dawarich instance) and the home country (ISO 3166-1 alpha-2). Without a home country the most frequent country of the last 365 days is used, and without one there is no travel day. MedicationLinkRequest: type: object properties: targetKind: type: string enum: - conditionEpisode - document - encounter targetIds: minItems: 1 maxItems: 100 type: array items: type: string minLength: 1 maxLength: 64 required: - targetKind - targetIds additionalProperties: false LabResultLinkRequest: type: object properties: targetKind: type: string enum: - document - encounter targetIds: minItems: 1 maxItems: 100 type: array items: type: string minLength: 1 maxLength: 64 required: - targetKind - targetIds additionalProperties: false MedicationTreatmentClass: type: string enum: - GENERIC - GLP1 description: Prisma-level treatment-class discriminator. `GLP1` unlocks the GLP-1 specialist surfaces (injection-site rotation, titration history, pen inventory, GLP-1-aware Coach). MedicationSchedule: type: object properties: id: type: string medicationId: type: string windowStart: type: string description: Legacy single-time-of-intake (HH:mm, user local). Preserved for backwards compatibility; the new `timesOfDay` array supersedes it. windowEnd: type: string description: Legacy reminder-window upper bound (HH:mm). Used to derive the late-classification grace span when `reminderGraceMinutes` is null. label: type: - string - "null" dose: description: Per-schedule dose override. NULL means the schedule inherits `Medication.dose`. type: - string - "null" unitsPerDose: description: v1.37.10 (#219) — per-slot inventory-units override (may be a split-pill fraction). NULL means the schedule inherits the medication-level `unitsPerDose`. Kept raw so an edit surface can distinguish an explicit value from inheritance; consumers wanting the effective figure read `resolvedUnitsPerDose`. type: - number - "null" resolvedUnitsPerDose: type: number description: v1.37.19 — the EFFECTIVE units one dose of this slot consumes, resolved server-side (`schedule.unitsPerDose ?? medication.unitsPerDose`, matching the intake consumption resolver). Always present; clients never re-derive the inheritance rule. daysOfWeek: description: Legacy persisted recurrence encoding (`null` | `1,3,5` | `i2;1,3,5`). v1.5 readers consult `rrule` first; the field is kept for pre-v1.5 rows. v1.6.0 drops the column. type: - string - "null" timesOfDay: type: array items: type: string description: v1.5 first-class points-in-time the dose is taken (HH:mm, user local). Backfilled to `[windowStart]` for every pre-v1.5 row. reminderGraceMinutes: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" description: Reminder grace window in minutes. NULL falls back to the legacy `windowEnd - windowStart` span. rrule: description: RFC 5545 RRULE string (subset). Used for calendar-anchored cadences. **Mutually exclusive with `rollingIntervalDays`** — exactly one of the two is non-null on any v1.5+ schedule (or both are null on legacy rows that haven't been touched since the migration). type: - string - "null" rollingIntervalDays: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" description: Flexible-rolling interval in days, counted forward from the latest `MedicationIntakeEvent.takenAt`. **Mutually exclusive with `rrule`.** scheduleType: type: string enum: - SCHEDULED - PRN - CYCLIC description: v1.7.0 schedule-type discriminator. SCHEDULED = rrule / rolling / legacy cadence. PRN = as-needed (never projected, reminded, or counted in compliance expected; still loggable via the intake route). CYCLIC = N weeks on / M weeks off, gating whichever inner cadence the rrule / legacy fields describe. cyclicOnWeeks: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" description: v1.7.0 cyclic "on" weeks. Only meaningful when `scheduleType` is CYCLIC; null otherwise. cyclicOffWeeks: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" description: v1.7.0 cyclic "off" weeks. Only meaningful when `scheduleType` is CYCLIC; null otherwise. doseWindows: description: Per-dose on-time windows as stored. Null when every dose time keeps the default derivation. anyOf: - type: array items: $ref: "#/components/schemas/DoseWindowEntry" - type: "null" required: - id - medicationId - windowStart - windowEnd - label - dose - unitsPerDose - resolvedUnitsPerDose - daysOfWeek - timesOfDay - reminderGraceMinutes - rrule - rollingIntervalDays - scheduleType - cyclicOnWeeks - cyclicOffWeeks description: Schedule entry attached to a medication. v1.5 promotes `timesOfDay` to first-class and introduces `rrule` (calendar-anchored cadences) and `rollingIntervalDays` (flexible-rolling cadences). The two recurrence primitives are mutually exclusive — enforced by the Zod refine on writes, the route layer, and a DB CHECK constraint (`medication_schedules_rrule_xor_rolling`). v1.7.0 adds `scheduleType` (SCHEDULED / PRN / CYCLIC) and the cyclic on/off-week fields. CycleCustomSymptomListEnvelope: type: object properties: data: type: object properties: symptoms: type: array items: $ref: "#/components/schemas/CycleCustomSymptom" required: - symptoms additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false CycleCustomSymptom: type: object properties: key: type: string description: The minted `custom:` key. Stable, and the value a day-log's symptom list carries for this symptom. label: description: The decrypted label. Null when the stored ciphertext could not be decrypted — one unreadable row fails soft rather than taking the whole catalogue read down, and the client falls back to the generic label. type: - string - "null" icon: description: Lucide icon name from the closed allow-list, or null. The allow-list is limited to names the iOS client maps to an SF Symbol, so a custom symptom never falls back to the generic glyph on one platform. type: - string - "null" custom: type: boolean const: true description: Always true. Lets a client merge this list into the seeded catalogue and still tell the two apart. required: - key - label - icon - custom additionalProperties: false description: One user-minted cycle symptom. The label is stored encrypted and decrypted on read; the key is what a day log references. ErrorEnvelope: type: object properties: data: type: "null" error: type: string details: description: "Present on a 422 raised by the multi-issue validator: every field the request got wrong, not only the first. Absent on every other status." type: object properties: issues: type: array items: $ref: "#/components/schemas/ValidationIssue" required: - issues additionalProperties: false meta: type: object properties: requestId: type: string errorCode: description: "Stable machine code for this refusal. Branch on it rather than on `error`, which is prose and may be reworded. Every code the API emits today is enumerated below, grouped by the surface that emits it; four naming conventions coexist and none of them will be renamed, because a code is a wire value a shipped client branches on. Every error envelope carries a code: when the route has no reason of its own to name, the code is the status's generic one from the `http` family (`http.not_found` for a 404, `http.internal` for a 500, `http.client_error` / `http.server_error` for a status without its own entry), so `http.*` says which kind of refusal it was and never why. Treat an unlisted code the way you would treat an unlisted enum member — as a refusal you do not recognise, not as a malformed response — since the list grows with the surfaces. Three families are outside it on purpose: `assistant.disabled.` is built from a template so the last segment is open — it is one of `coach`, `briefing`, `insightStatus`, `documentAi` and `enabled`, and `assistant.disabled.coach` is the one the native client names; the integration-probe classes (`credentials_rejected`, `rate_limited`, `upstream_error`, `timeout`, `connection_failed` and the per-provider additions) are enumerated in each `/test` operation instead, where the differences can be stated; and a 401 raised by a route checking a credential of its own carries the generic `http.unauthenticated`. account — account.deletion.no_guardian, account.deletion.not_pending; activity — activity.calendar.future_date, activity.calendar.too_old; ai — ai.budget.exceeded, ai.provider.none, ai.record.notPermitted, ai.unavailable, ai.usage.aboveCeiling, ai.usage.defaultAboveCeiling, ai.usage.invalid, ai.usage.levelTooLow, ai.usage.rateLimited; ai_provider — ai_provider.invalid, ai_provider.no_fields; aiRuns — aiRuns.failed, aiRuns.notFound, aiRuns.readerBusy, aiRuns.timedOut, aiRuns.workerUnavailable; allergy — allergy.invalid; analytics — analytics.invalid_query; anamnesis — anamnesis.auditC.invalid, anamnesis.auditC.notFound, anamnesis.auditC.rateLimited, anamnesis.fact.conflict, anamnesis.fact.currentExists, anamnesis.fact.invalidValue, anamnesis.lifeConfirm.invalid, anamnesis.lifeSections.invalid; auth — auth.account.deletion_pending, auth.admin.required, auth.already_authenticated, auth.mfa.code_invalid, auth.missing, auth.refresh.invalid, auth.refresh.reuse, auth.refresh.revoked, auth.reproof.failed, auth.reproof.required, auth.reproof.too_weak, auth.scope.insufficient, auth.session.insecure_transport, auth.session.unavailable, auth.stepup.required, auth.token.expired, auth.token.invalid; backup — backup.foreign_reference, backup.key.missing, backup.payload.undecryptable, backup.restore.active, backup.section.missing, backup.upload.too_large; biomarkers — biomarkers.unit.locked; body-site — body-site.invalid; coach — coach.budget.exceeded, coach.conversation.invalidTitle, coach.conversation.notFound, coach.fenced.attachmentConflict, coach.fenced.attachmentLimit, coach.fenced.attachmentNotFound, coach.fenced.attachmentNotIndexed, coach.fenced.attachmentRequired, coach.fenced.attachmentUnavailable, coach.fenced.invalid, coach.request.invalid; coach-prefs — coach-prefs.body.invalid_json, coach-prefs.body.invalid_shape; consent — consent.ai.confirmRequired, consent.ai.required; cycle — cycle.bulk.invalid, cycle.bulk.too_large, cycle.calendar.invalid, cycle.calendar.range, cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid, cycle.disabled, cycle.period.invalid, cycle.symptom.custom.invalid, cycle.symptom.custom.limit; cycle-prefs — cycle-prefs.body.invalid_json, cycle-prefs.invalid; disable-coach — disable-coach.body.invalid_json; data_wipe — data_wipe.copy_failed; dawarich — dawarich.credentials_rejected, dawarich.home_place_unknown, dawarich.invalid, dawarich.not_connected, dawarich.rate_limited, dawarich.unreachable; day — day.invalid; demo — demo.readOnly; documents — documents.chat.conversationNotFound, documents.chat.invalid, documents.inbound.alreadyConfirmed, documents.inbound.alreadyPartlyConfirmed, documents.inbound.budgetExceeded, documents.inbound.decryptFailed, documents.inbound.encounterNotFound, documents.inbound.episodeNotFound, documents.inbound.extractFailed, documents.inbound.factNotFound, documents.inbound.factNotPending, documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge, documents.inbound.fileType, documents.inbound.invalidMetadata, documents.inbound.invalidQuery, documents.inbound.invalidUpdate, documents.inbound.localOcrDisabled, documents.inbound.notDeleted, documents.inbound.notFound, documents.inbound.notIndexed, documents.inbound.outputLimit, documents.inbound.pdfNeedsAnthropic, documents.inbound.providerUnsupported, documents.inbound.quotaExceeded, documents.inbound.rateLimited, documents.inbound.restoreDuplicate, documents.inbound.restoreGone, documents.inbound.sourceAliasLimit, documents.inbound.uploadBusy, documents.inbound.uploadTimeout, documents.inbound.vaccinationNotFound, documents.sources.authRefused, documents.sources.badResponse, documents.sources.browserOnly, documents.sources.invalidAddress, documents.sources.linkTargetNotFound, documents.sources.notConnected, documents.sources.notFound, documents.sources.organizationRequired, documents.sources.originNotAllowed, documents.sources.permissionMissing, documents.sources.rateLimited, documents.sources.redirected, documents.sources.tokenRequired, documents.sources.unavailable, documents.sources.unreachable, documents.sources.versionTooOld; encounter — encounter.invalid, encounter.link.invalid, encounter.practitioner-not-found, encounter.reminder-conflict, encounter.reminder-not-found; encryption — encryption.key_mismatch; environment — environment.backfill_pending, environment.backfill_rate_limited, environment.invalid, environment.no_home, environment.range_too_large, environment.travel.not_found, environment.travel_suggestion.not_found; export — export.selection.empty, export.selection.unknown_leaf; familyHistory — familyHistory.invalid; feedback — feedback.body.invalid; health_score_config — health_score_config.invalid, health_score_config.too_narrow; http — http.bad_gateway, http.bad_request, http.client_error, http.conflict, http.forbidden, http.gateway_timeout, http.gone, http.internal, http.invalid, http.method_not_allowed, http.not_found, http.payload_too_large, http.rate_limited, http.server_error, http.unauthenticated, http.unavailable, http.unsupported_media_type; illness — illness.day-log.invalid, illness.disabled, illness.episode.chronic-no-resolve, illness.episode.invalid, illness.episode.invalid-window, illness.episode.parent-not-found, illness.insights.invalid; import — import.apple_health.busy, import.csv.content_type, import.csv.empty, import.csv.too_large, import.rate_limited, import.write_failed; ingest — ingest.rate_limited; insights — insights.generate.budgetExceeded, insights.generate.outboundScreened, insights.generate.profileScopeChanged, insights.generate.scopeChanged; labs — labs.biomarker.alias.taken, labs.biomarker.create.invalid, labs.biomarker.duplicate, labs.biomarker.merge.invalid, labs.biomarker.merge.unitMismatch, labs.biomarker.notFound, labs.biomarker.update.invalid, labs.biomarker.update.referenceRangeInvalid, labs.create.invalid, labs.link.invalid, labs.ocr.budgetExceeded, labs.ocr.extractFailed, labs.ocr.fileTooLarge, labs.ocr.fileType, labs.ocr.localOcrDisabled, labs.ocr.outputLimit, labs.ocr.pdfNeedsAnthropic, labs.ocr.providerUnsupported, labs.ocr.rateLimited, labs.ocr.tooLarge, labs.restore.invalid, labs.result.notFound, labs.unit.mismatch, labs.update.invalid, labs.update.linkedFieldsImmutable, labs.update.numericExpected, labs.update.qualitativeExpected, labs.update.referenceRangeInvalid, labs.update.sourceReferenceRangeInvalid; life_event — life_event.invalid, life_event.not_found; managed_profile — managed_profile.guardian.duplicate, managed_profile.guardian.managed_invitee, managed_profile.guardian.required, managed_profile.handover.no_pending, managed_profile.handover.unknown_guardian, managed_profile.not_found; maintenance — maintenance.pulseDayMean.busy, maintenance.pulseDayMean.dryRunRequired; measurement — measurement.batch.invalid, measurement.batch.source_not_permitted, measurement.batch.too_large, measurement.bulk-delete.invalid, measurement.create.invalid, measurement.create.source_not_permitted, measurement.delete.too_large, measurement.duplicate_timestamp, measurement.restore.invalid, measurement.update.server_owned_source; medication — medication.intake.import.invalid_format, medication.intake.notTracked, medication.link.invalid; medications — medications.category.invalid, medications.category.limitReached, medications.category.unknown, medications.course.currentOpen, medications.course.invalid, medications.course.invalidRange, medications.course.notFound, medications.course.notRunning, medications.course.oneShot, medications.course.overlap, medications.course.windowRequired, medications.ingredients.extract.budgetExceeded, medications.ingredients.extract.failed, medications.ingredients.extract.fileTooLarge, medications.ingredients.extract.fileType, medications.ingredients.extract.rateLimited, medications.ingredients.invalid, medications.ingredients.unitMismatch, medications.intake.bulk.apple_health_not_mirrored, medications.intake.bulk.invalid, medications.intake.bulk.too_large, medications.intake.force_slot.invalid, medications.intake.force_slot.occupied, medications.intake.injection_site.disallowed, medications.intake.taken_at.before_start, medications.mirror.limit_exceeded, medications.notFound; mentalHealth — mentalHealth.rateLimited; module — module.disabled; modules — modules.body.invalid_json, modules.invalid; mood — mood.bulk-delete.invalid, mood.bulk.invalid, mood.bulk.too_large, mood.create.external_id_not_delegable, mood.create.invalid, mood.duplicate_timestamp, mood.linkedContext.invalid, mood.list.invalid, mood.not_found, mood.ratedFactor.out_of_range, mood.restore.invalid, mood.update.invalid; notification-prefs — notification-prefs.body.invalid_json; notification — notification.action.already_used, notification.action.expired, notification.action.invalid, notification.action.module_disabled; nutrient — nutrient.batch.invalid, nutrient.batch.too_large, nutrient.daily.invalid, nutrient.read.invalid, nutrient.water.invalid, nutrient.water.invalid_day; onboarding — onboarding.answers.invalid, onboarding.answers.rateLimited, onboarding.complete.invalid, onboarding.complete.recordNotFound, onboarding.complete.recordTargetMismatch, onboarding.disclaimer.invalid, onboarding.restart.invalid, onboarding.restart.rateLimited, onboarding.tour.invalid; passkey — passkey.challenge.expired, passkey.response.invalid, passkey.unknown, passkey.verification.failed; personal_records — personal_records.invalid_query; practitioner — practitioner.invalid; profile — profile.update.emailInUse, profile.update.emailRateLimited, profile.update.emailSecondFactorRequired, profile.update.invalidBody, profile.update.nothingSaved; profile_claim — profile_claim.invalid, profile_claim.oidc_only_unsupported, profile_claim.sso_unavailable, profile_claim.taken; record_write — record_write.rate_limited; recovery — recovery.kit.stale; report-selection — report-selection.body.invalid_json, report-selection.body.invalid_shape, report-selection.leaves.unknown; request — request.cross_site; route — route.retired; search — search.invalid; security_activity — security_activity.invalid; share-link — share-link.document.decryptFailed, share-link.document.notFound, share-link.document.rateLimited, share-link.documents.invalid, share-link.report.notFound, share-link.report.rateLimited, share-link.selection.forbidden_leaf, share-link.selection.unknown_leaf; sharing — sharing.accept.expired, sharing.accept.not_found, sharing.accept.not_pending, sharing.accept.revoked, sharing.access.denied, sharing.invite.duplicate, sharing.invite.invalid, sharing.invite.invalid_scope, sharing.invite.manage_browser_only, sharing.invite.no_account, sharing.invite.refused, sharing.invite.self, sharing.not_permitted, sharing.renounce.already_ended, sharing.renounce.not_found, sharing.revoke.already_ended, sharing.revoke.not_found, sharing.session.changed, sharing.switch.invalid, sharing.switch.wrong_transport; source-priority — source-priority.body.invalid_json, source-priority.body.invalid_shape; symptoms — symptoms.definition.limitReached, symptoms.definition.notFound, symptoms.episode.notOpen, symptoms.event.notFound, symptoms.invalid; timeline — timeline.invalid; tokens — tokens.documents.ceiling_reached, tokens.measurements.ceiling_reached, tokens.mirror.not_measurement_token, tokens.workouts.ceiling_reached; vaccination — vaccination.booster-invalid, vaccination.booster-no-antigen, vaccination.booster-out-of-scope, vaccination.custom-vaccine-not-found, vaccination.custom.invalid, vaccination.custom.name-taken, vaccination.custom.not-found, vaccination.encounter-not-found, vaccination.identity-required, vaccination.invalid, vaccination.link.invalid, vaccination.not-found, vaccination.practitioner-not-found; workout — workout.batch.invalid, workout.batch.payload_too_large, workout.batch.source_not_permitted, workout.batch.too_large, workout.delete.synced_source; (unprefixed) — about_me_conflict, ai_response_truncated, coach_prefs_conflict, dashboard_layout_conflict, empty_file, health_score_config_conflict, insights_layout_conflict, invalid_base_updated_at, json_carries_no_intake_time, json_not_an_array, medication_layout_conflict, missing_required_columns, modules_conflict, mood_tag_layout_conflict, not_configured, notification_prefs_conflict, oidc_only, private_origin_not_approved, private_origin_not_grantable, provider_retired, rate_limited_self, sync_in_progress, too_many_rows, unreadable_json, upstream_invalid_json, vapid_not_configured" type: string capability: description: "On an AI refusal: the capability that is unavailable, one of the keys of `AiCapabilities` (`coach`, `briefing`, `periodNarrative`, `statusText`, `workoutInsights`, `reactionLines`, `aboutMeQuestions`, `documentAi`, `labsOcr`, `medicationExtract`, `supplementLabel`)." type: string reason: description: "On an AI refusal: the outermost reason, one of `AiUnavailableReason`. The code maps from it: `operator_disabled` → `assistant.disabled.` (or `module.disabled` with `module` when the operator's module availability closed it), `not_permitted_for_record` → `ai.record.notPermitted`, `module_disabled` and `user_disabled` → `module.disabled` with `module`, `no_provider` → the route's own typed code where it has one, otherwise `ai.provider.none` (422), `usage_level` → `ai.usage.levelTooLow` with `requiredLevel`, `consent_required` → `consent.ai.required`, `check_failed` → `ai.unavailable` (503)." type: string module: description: "Beside `module.disabled`: the module that is off. On an AI refusal it is the capability's owning module." type: string requiredLevel: description: "Beside `ai.usage.levelTooLow` (v1.43): the lowest AI usage level at which the capability is offered." type: string enum: - frugal - standard - full additionalProperties: {} required: - data - error additionalProperties: false description: "Standard error response: `data` is null, `error` is human prose. `meta.errorCode` always carries a stable machine code — the route's own where it names one, otherwise the status's generic `http.*` code — `meta.requestId` names the request for a support conversation, and `meta` may carry further per-refusal context. A 422 from the multi-issue validator additionally carries `details.issues` — every field the request got wrong, so a client can fix them in one round-trip." ValidationIssue: type: object properties: path: type: string description: Dot-joined path to the rejected field, e.g. `entries.3.measuredAt`. Empty string when the whole body was rejected. code: type: string description: Zod issue code, e.g. `invalid_type`, `too_small`, `unrecognized_keys`. Treat an unfamiliar code as a generic rejection of `path`. message: type: string description: English sentence describing the rejection. Safe to log; show the user your own wording keyed on `path`. keys: description: "Only on `unrecognized_keys`: the refused key names, bounded to 10 and 64 characters each. Zod reports every unknown key of one object in a single issue whose `path` is the object, so the names live here and nowhere in `path`." type: array items: type: string required: - path - code - message additionalProperties: false description: "One rejected field in a multi-issue 422. Sanitised: no rejected VALUE is echoed back." CycleCustomSymptomCreatedEnvelope: type: object properties: data: $ref: "#/components/schemas/CycleCustomSymptom" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false CycleCustomSymptomUpdatedEnvelope: type: object properties: data: type: object properties: key: type: string description: The minted `custom:` key. Stable, and the value a day-log's symptom list carries for this symptom. label: description: The decrypted label. Null when the stored ciphertext could not be decrypted — one unreadable row fails soft rather than taking the whole catalogue read down, and the client falls back to the generic label. type: - string - "null" icon: description: Lucide icon name from the closed allow-list, or null. The allow-list is limited to names the iOS client maps to an SF Symbol, so a custom symptom never falls back to the generic glyph on one platform. type: - string - "null" custom: type: boolean const: true description: Always true. Lets a client merge this list into the seeded catalogue and still tell the two apart. isActive: type: boolean required: - key - label - icon - custom - isActive additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false CycleCustomSymptomDeletedEnvelope: type: object properties: data: type: object properties: key: type: string purged: type: boolean required: - key - purged additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false CycleDayLogReadEnvelope: type: object properties: data: anyOf: - $ref: "#/components/schemas/CycleDayLogDTO" - type: "null" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false CycleDayLogDTO: type: object properties: id: type: string date: type: string cycleId: type: - string - "null" flow: anyOf: - $ref: "#/components/schemas/FlowLevel" - type: "null" intermenstrualBleeding: type: boolean basalBodyTempC: type: - number - "null" temperatureExcluded: type: boolean ovulationTest: anyOf: - $ref: "#/components/schemas/OvulationTest" - type: "null" cervicalMucus: anyOf: - $ref: "#/components/schemas/CervicalMucus" - type: "null" cervixPosition: anyOf: - type: string enum: - LOW - HIGH - type: "null" cervixFirmness: anyOf: - type: string enum: - FIRM - SOFT - type: "null" cervixOpening: anyOf: - type: string enum: - CLOSED - OPEN - type: "null" sexualActivity: type: boolean protectedSex: type: - boolean - "null" pregnancyTest: anyOf: - $ref: "#/components/schemas/HomeTestResult" - type: "null" progesteroneTest: anyOf: - $ref: "#/components/schemas/HomeTestResult" - type: "null" contraceptive: type: - string - "null" symptoms: type: array items: type: object properties: key: type: string severity: anyOf: - type: integer minimum: 1 maximum: 4 - type: "null" required: - key - severity additionalProperties: false note: type: - string - "null" source: type: string externalId: type: - string - "null" syncVersion: type: integer minimum: -9007199254740991 maximum: 9007199254740991 updatedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ deletedAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" required: - id - date - cycleId - flow - intermenstrualBleeding - basalBodyTempC - temperatureExcluded - ovulationTest - cervicalMucus - cervixPosition - cervixFirmness - cervixOpening - sexualActivity - protectedSex - pregnancyTest - progesteroneTest - contraceptive - symptoms - note - source - externalId - syncVersion - updatedAt - deletedAt additionalProperties: false description: The canonical day-log row iOS mirrors. `note` is decrypted on read. Soft-deleted rows ride `/api/sync/changes` as tombstones. FlowLevel: type: string enum: - NONE - SPOTTING - LIGHT - MEDIUM - HEAVY description: Menstrual-flow intensity. OvulationTest: type: string enum: - NEGATIVE - POSITIVE_LH_SURGE - ESTROGEN_SURGE - INDETERMINATE description: Ovulation predictor-kit (OPK) reading. CervicalMucus: type: string enum: - DRY - STICKY - CREAMY - WATERY - EGG_WHITE description: Cervical-mucus quality. HomeTestResult: type: string enum: - NEGATIVE - POSITIVE - INDETERMINATE description: At-home test result (pregnancy / progesterone). CycleDayLogEnvelope: type: object properties: data: $ref: "#/components/schemas/CycleDayLogDTO" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false CycleDayLogCreatedEnvelope: type: object properties: data: $ref: "#/components/schemas/CycleDayLogDTO" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false CycleDayLogPatchEnvelope: type: object properties: data: allOf: - $ref: "#/components/schemas/CycleDayLogDTO" - type: object properties: restored: description: v1.43 — starts given back because this edit cleared the flow on a start day; empty when it gave back none. Absent on older servers. type: array items: $ref: "#/components/schemas/CycleRestoredStart" additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false CycleRestoredStart: type: object properties: cycleId: type: string startDate: type: string required: - cycleId - startDate additionalProperties: false description: A cycle start that was folded into another start, or given back after the start it was folded into went away. CycleDeleteOutcomeEnvelope: type: object properties: data: $ref: "#/components/schemas/CycleDeleteOutcome" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false CycleDeleteOutcome: type: object properties: removedCycleId: type: - string - "null" restored: type: array items: $ref: "#/components/schemas/CycleRestoredStart" required: - removedCycleId - restored additionalProperties: false description: "What a delete changed, answered with 200 when the request sent `Prefer: return=representation` (RFC 7240; the response carries `Preference-Applied`). Without the header the delete answers 204 as before." CycleBulkEnvelope: type: object properties: data: type: object properties: processed: type: integer minimum: -9007199254740991 maximum: 9007199254740991 inserted: type: integer minimum: -9007199254740991 maximum: 9007199254740991 updated: type: integer minimum: -9007199254740991 maximum: 9007199254740991 duplicates: type: integer minimum: -9007199254740991 maximum: 9007199254740991 skipped: type: integer minimum: -9007199254740991 maximum: 9007199254740991 entries: type: array items: type: object properties: index: type: integer minimum: -9007199254740991 maximum: 9007199254740991 status: type: string enum: - inserted - duplicate - updated - skipped id: type: string externalId: type: string reason: description: "On a `skipped` entry. `unstable_external_id` is a verdict on the entry and terminal: resending it unchanged is skipped again. `constraint` means the write met a database constraint (a key another write claimed at the same moment, or a reference that no longer exists). `upsert_failed` is a write failure on the server's side: nothing was stored, the entry should be sent again, and the response carrying it is `Cache-Control: no-store`, so a retry under the same `Idempotency-Key` writes again rather than replaying the failure." type: string required: - index - status additionalProperties: false required: - processed - inserted - updated - duplicates - skipped - entries additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false CyclePeriodEnvelope: type: object properties: data: type: object properties: cycle: anyOf: - $ref: "#/components/schemas/MenstrualCycleDTO" - type: "null" dayLog: anyOf: - $ref: "#/components/schemas/CycleDayLogDTO" - type: "null" absorbed: description: 'v1.43 — a later start within the period length that this start folded in (it stops being a start of its own), or null. Present on every `action: "start"` answer; null on `end`.' anyOf: - $ref: "#/components/schemas/CycleRestoredStart" - type: "null" required: - cycle - dayLog additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MenstrualCycleDTO: type: object properties: id: type: string startDate: type: string endDate: type: - string - "null" periodEndDate: type: - string - "null" lengthDays: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" ovulationDate: type: - string - "null" ovulationConfirmed: type: boolean isPredicted: type: boolean syncVersion: type: integer minimum: -9007199254740991 maximum: 9007199254740991 updatedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - id - startDate - endDate - periodEndDate - lengthDays - ovulationDate - ovulationConfirmed - isPredicted - syncVersion - updatedAt additionalProperties: false description: One menstrual cycle (observed or forward-predicted). CycleCalendarEnvelope: type: object properties: data: type: object properties: profile: type: object properties: goal: $ref: "#/components/schemas/CycleTrackingGoal" rawChartMode: type: boolean predictionEnabled: type: boolean cyclesObserved: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - goal - rawChartMode - predictionEnabled - cyclesObserved additionalProperties: false prediction: anyOf: - $ref: "#/components/schemas/CyclePredictionDTO" - type: "null" verdict: $ref: "#/components/schemas/CycleVerdictDTO" stillLearning: type: boolean days: type: array items: type: object properties: date: type: string phase: anyOf: - $ref: "#/components/schemas/CyclePhase" - type: "null" isPredictedPeriod: type: boolean isFertileWindow: type: boolean isPredictedOvulation: type: boolean isPeriodLogged: type: boolean isCycleStart: type: boolean description: Whether a logged cycle opens on this day. Deleting this day's log removes that cycle start with it. cycleDay: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" description: The 1-based day of the logged cycle this date belongs to. Null before the first logged start, after today, or once an open cycle has run past the point where the verdict stops counting. For today it equals verdict.dayOfCycle. periodEndable: type: boolean description: Whether a one-tap period end (POST /api/cycle/period, action end) on this date lands inside the first days of a logged cycle. Resolved per date, independent of today's phase. flow: anyOf: - $ref: "#/components/schemas/FlowLevel" - type: "null" hasSymptoms: type: boolean confidence: type: number basalBodyTempC: type: - number - "null" temperatureExcluded: type: boolean ovulationTest: anyOf: - $ref: "#/components/schemas/OvulationTest" - type: "null" cervicalMucus: anyOf: - $ref: "#/components/schemas/CervicalMucus" - type: "null" cervixPosition: anyOf: - type: string enum: - LOW - HIGH - type: "null" cervixFirmness: anyOf: - type: string enum: - FIRM - SOFT - type: "null" cervixOpening: anyOf: - type: string enum: - CLOSED - OPEN - type: "null" intermenstrualBleeding: type: boolean description: Spotting or bleeding outside the period logged on this day. sexualActivity: type: boolean description: Intercourse logged on this day. Resolved from the plaintext column or the encrypted envelope, exactly as the day-log read resolves it, and served under the same cycle grant. pregnancyTest: anyOf: - $ref: "#/components/schemas/HomeTestResult" - type: "null" progesteroneTest: anyOf: - $ref: "#/components/schemas/HomeTestResult" - type: "null" contraceptive: type: - string - "null" hasNote: type: boolean description: Whether the day carries a note. The note text is only on the day-log read. required: - date - phase - isPredictedPeriod - isFertileWindow - isPredictedOvulation - isPeriodLogged - isCycleStart - cycleDay - periodEndable - flow - hasSymptoms - confidence - basalBodyTempC - temperatureExcluded - ovulationTest - cervicalMucus - cervixPosition - cervixFirmness - cervixOpening - intermenstrualBleeding - sexualActivity - pregnancyTest - progesteroneTest - contraceptive - hasNote additionalProperties: false meta: type: object properties: generatedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - generatedAt additionalProperties: false required: - profile - prediction - verdict - stillLearning - days - meta additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false CycleTrackingGoal: type: string enum: - GENERAL_HEALTH - AVOID_PREGNANCY - TRYING_TO_CONCEIVE - PERIMENOPAUSE - OFF description: Drives cycle copy + fertile-window gating. CyclePredictionDTO: type: object properties: method: $ref: "#/components/schemas/CyclePredictionMethod" nextPeriodStart: type: string nextPeriodStartLow: type: string nextPeriodStartHigh: type: string fertileWindowStart: type: - string - "null" fertileWindowEnd: type: - string - "null" predictedOvulation: type: - string - "null" ovulationConfirmed: type: boolean confidence: type: number cyclesObserved: type: integer minimum: -9007199254740991 maximum: 9007199254740991 stillLearning: type: boolean disclaimer: type: string required: - method - nextPeriodStart - nextPeriodStartLow - nextPeriodStartHigh - fertileWindowStart - fertileWindowEnd - predictedOvulation - ovulationConfirmed - confidence - cyclesObserved - stillLearning - disclaimer additionalProperties: false description: The materialised forecast. Fertile-window fields (and predictedOvulation/ovulationConfirmed) are server-suppressed (null/false) unless the goal is TRYING_TO_CONCEIVE or AVOID_PREGNANCY. CyclePredictionMethod: type: string enum: - CALENDAR - SYMPTOTHERMAL - TEMPERATURE_TREND - BLENDED CycleVerdictDTO: type: object properties: state: type: string enum: - IN_CYCLE - OVERDUE - INSUFFICIENT_DATA dayOfCycle: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" description: 1-based day of the current cycle. Null in OVERDUE and INSUFFICIENT_DATA — beyond the typical length plus the server's grace window the count is no longer an observed fact. cycleLength: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" description: "Days the ring represents: the observed labelled run, or the profile-derived idealized cycle for a low-data tracker." phase: anyOf: - $ref: "#/components/schemas/CyclePhase" - type: "null" spans: type: array items: type: object properties: phase: $ref: "#/components/schemas/CyclePhase" fraction: type: number required: - phase - fraction additionalProperties: false cycleStartDate: description: First day of the current cycle (YYYY-MM-DD). Still set while OVERDUE — the last logged period start remains a fact once the count stops. type: - string - "null" overdueDays: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" description: How many days past the profile's typical cycle length the open cycle has run. Set only in OVERDUE. daysUntilNext: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" description: Days from today to the predicted next period start. Null when no prediction ran and null once that start is in the past. fertileWindow: type: object properties: start: type: - string - "null" end: type: - string - "null" active: type: boolean required: - start - end - active additionalProperties: false required: - state - dayOfCycle - cycleLength - phase - spans - cycleStartDate - overdueDays - daysUntilNext - fertileWindow additionalProperties: false description: The resolved cycle verdict for the user's own timezone day. Render these values; do not recompute them. The grace window that decides OVERDUE is deliberately not published — read `state` and `overdueDays`. CyclePhase: type: string enum: - MENSTRUAL - FOLLICULAR - OVULATORY - LUTEAL CycleHistoryEnvelope: type: object properties: data: type: object properties: cycles: type: array items: $ref: "#/components/schemas/MenstrualCycleDTO" stats: type: object properties: avgLengthDays: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" lengthVariabilityDays: type: - number - "null" avgPeriodLengthDays: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" regularity: type: string enum: - REGULAR - IRREGULAR - LEARNING required: - avgLengthDays - lengthVariabilityDays - avgPeriodLengthDays - regularity additionalProperties: false required: - cycles - stats additionalProperties: false error: type: "null" meta: $ref: "#/components/schemas/ListCapMeta" required: - data - error - meta additionalProperties: false ListCapMeta: type: object properties: requestId: type: string truncated: type: boolean description: "True when more rows exist than this response carries: the list was cut at `limit`, newest or most relevant first as the operation states. False means the list is complete. Show the person that the list is partial rather than letting the last row read as the oldest there is." limit: type: integer minimum: 1 maximum: 10000 description: The most rows this response could carry. required: - truncated - limit additionalProperties: false description: How a capped list was cut. Present on every response of an operation that declares it. CycleInsightsEnvelope: type: object properties: data: type: object properties: rows: type: array items: $ref: "#/components/schemas/CyclePhaseCrosstabRow" headline: anyOf: - $ref: "#/components/schemas/CyclePhaseCrosstabRow" - type: "null" lagged: type: object properties: discovered: type: array items: $ref: "#/components/schemas/CyclePhaseLaggedPair" pairsTested: type: integer minimum: -9007199254740991 maximum: 9007199254740991 fdrQ: type: number minPairs: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - discovered - pairsTested - fdrQ - minPairs additionalProperties: false symptomPatterns: type: array items: $ref: "#/components/schemas/CycleSymptomPhaseRow" contrast: type: object properties: high: type: string const: LUTEAL low: type: string const: FOLLICULAR required: - high - low additionalProperties: false windowDays: type: integer minimum: -9007199254740991 maximum: 9007199254740991 cyclesObserved: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - rows - headline - lagged - symptomPatterns - contrast - windowDays - cyclesObserved additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false CyclePhaseCrosstabRow: type: object properties: metricKey: type: string enum: - restingHeartRate - heartRateVariability - sleepDuration - steps - weight - basalBodyTemp - wristTemperature - skinTemperature - bloodGlucose - mood display: type: string enum: - hours - steps - bpm - ms - kg - celsius - glucose - mood lutealDays: type: integer minimum: -9007199254740991 maximum: 9007199254740991 follicularDays: type: integer minimum: -9007199254740991 maximum: 9007199254740991 lutealAvg: type: number follicularAvg: type: number delta: type: number pValue: type: number qValue: type: number confidence: type: string enum: - low - medium - high required: - metricKey - display - lutealDays - follicularDays - lutealAvg - follicularAvg - delta - pValue - qValue - confidence additionalProperties: false description: One FDR-surviving luteal-vs-follicular contrast for an outcome metric. `delta = lutealAvg − follicularAvg`. Observational, never causal. CyclePhaseLaggedPair: type: object properties: behaviour: type: string outcome: type: string n: type: integer minimum: -9007199254740991 maximum: 9007199254740991 nEff: description: v1.42 — effective sample size after serial correlation, ≤ `n`; the p-value uses it. type: number r: type: number pValue: type: number qValue: type: number interpretation: type: string lagDays: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - behaviour - outcome - n - r - pValue - qValue - interpretation - lagDays additionalProperties: false description: One FDR-surviving lagged-Pearson pair from the continuous CYCLE_PHASE ordinal × outcome matrix (mechanism B). Descriptive, never causal. CycleSymptomPhaseRow: type: object properties: symptomKey: type: string counts: type: object properties: MENSTRUAL: type: integer minimum: -9007199254740991 maximum: 9007199254740991 FOLLICULAR: type: integer minimum: -9007199254740991 maximum: 9007199254740991 OVULATORY: type: integer minimum: -9007199254740991 maximum: 9007199254740991 LUTEAL: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - MENSTRUAL - FOLLICULAR - OVULATORY - LUTEAL additionalProperties: false total: type: integer minimum: -9007199254740991 maximum: 9007199254740991 topPhase: type: string enum: - MENSTRUAL - FOLLICULAR - OVULATORY - LUTEAL topShare: type: number required: - symptomKey - counts - total - topPhase - topShare additionalProperties: false description: Where a logged symptom clusters across the cycle phases. Surfaced only once logged on ≥3 phase-labelled days. Observational, never causal. CyclePurgeEnvelope: type: object properties: data: type: object properties: purged: type: boolean dayLogs: type: integer minimum: -9007199254740991 maximum: 9007199254740991 predictions: type: integer minimum: -9007199254740991 maximum: 9007199254740991 cycles: type: integer minimum: -9007199254740991 maximum: 9007199254740991 auditRows: type: integer minimum: -9007199254740991 maximum: 9007199254740991 pushRows: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - purged - dayLogs - predictions - cycles - auditRows - pushRows additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false CycleProfileEnvelope: type: object properties: data: $ref: "#/components/schemas/CycleProfileDTO" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false CycleProfileDTO: type: object properties: goal: $ref: "#/components/schemas/CycleTrackingGoal" cycleTrackingEnabled: type: boolean secondarySymptom: type: string enum: - MUCUS - CERVIX rawChartMode: type: boolean predictionEnabled: type: boolean discreetNotifications: type: boolean sensitiveCategoryEncryption: type: boolean typicalCycleLength: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" typicalPeriodLength: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" lutealPhaseLength: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" updatedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - goal - cycleTrackingEnabled - secondarySymptom - rawChartMode - predictionEnabled - discreetNotifications - sensitiveCategoryEncryption - typicalCycleLength - typicalPeriodLength - lutealPhaseLength - updatedAt additionalProperties: false description: The full per-user cycle settings row. CyclePrefsGetEnvelope: type: object properties: data: $ref: "#/components/schemas/CycleProfileDTO" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false CyclePrefsPatchEnvelope: type: object properties: data: $ref: "#/components/schemas/CycleProfileDTO" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MeasurementCategoriesEnvelope: type: object properties: data: $ref: "#/components/schemas/MeasurementCategoriesResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MeasurementCategoriesResponse: type: object properties: version: type: number const: 1 description: Additive schema marker; assignments never reshuffle. categories: type: array items: type: object properties: id: type: string description: Category id (e.g. `vitals`). labelKey: type: string description: i18n key for the category label (`categories.`). order: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Stable display order. required: - id - labelKey - order additionalProperties: false description: The ordered category list for the picker. assignments: type: object propertyNames: type: string additionalProperties: type: string description: MeasurementType → category id map. required: - version - categories - assignments additionalProperties: false description: "The measurement categorisation overlay: the ordered category list plus the MeasurementType → category assignments. Derived server-side from the canonical map; cached `public, max-age=600`." CapabilitiesEnvelope: type: object properties: data: $ref: "#/components/schemas/CapabilitiesResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false CapabilitiesResponse: type: object properties: apiContractVersion: type: string description: Running build version — mirrors GET /api/version `version`. derivedMetricIds: type: array items: type: string description: Closed derived-metric id set (GET /api/insights/derived). vitalsBaselineTypes: type: array items: type: string description: MeasurementTypes the typical-range baseline engine supports. layoutTileIds: type: array items: type: string description: Canonical insights layout tile-id set (English ids). metricStatusIds: type: array items: type: string description: Closed metric-status / assessment id set. ingest: type: object properties: quantityTypes: type: array items: type: object properties: type: type: string description: HealthLog MeasurementType. hk: type: string description: HealthKit identifier. unit: type: string description: Canonical DB unit. required: - type - hk - unit additionalProperties: false description: Accepted HealthKit quantity-sample mappings. eventTypes: type: array items: type: string description: MeasurementTypes for device-flagged EVENT-class HealthKit samples. computedScores: type: array items: type: string description: Server-owned nightly composite score types. writeAllowlist: type: array items: type: string description: MeasurementSources a client may attribute on a write (others are server-owned). required: - quantityTypes - eventTypes - computedScores - writeAllowlist additionalProperties: false description: Ingest vocabularies the batch / single-write paths accept. fhir: type: object properties: atcSystem: type: string description: WHO ATC CodeSystem URI. snomedRoute: type: string description: SNOMED CT CodeSystem URI. germanAtcDefaultLocales: type: array items: type: string description: App locales that default the additive BfArM ATC coding on. restBaseUrl: type: string description: Base path of the read-only FHIR R4 REST face. scopeMintable: type: boolean description: "Whether a narrow Bearer token for the FHIR face can be obtained. Always false: no mint path grants the scope, so the face is reachable by the owner's cookie session or a wildcard device token only." resourceTypes: type: array items: type: string description: FHIR resource types the REST face serves (read + search). operations: type: array items: type: string description: Whole-record operations exposed (e.g. $everything). searchParams: type: array items: type: string description: Search parameters honoured uniformly across the search routes. required: - atcSystem - snomedRoute - germanAtcDefaultLocales - restBaseUrl - scopeMintable - resourceTypes - operations - searchParams additionalProperties: false description: FHIR coding constants + the read-only REST face descriptor (v1.11). share: type: object properties: supported: type: boolean description: Whether clinician share links are served. maxDays: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Maximum lifetime of a share link, in days. No never-expiring share. reportDownload: type: array items: type: string description: Machine formats a share link serves as a download under its token, behind the same unlock as the page. selectionVersion: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Version of the leaf-selection grammar this build speaks. groups: type: array items: type: object properties: id: type: string description: Group id. leaves: type: array items: type: string description: Member leaf ids, in the order the panel renders them. sensitive: description: "Present and true only on the fenced tier: no single control may switch on more than one of its leaves." type: boolean required: - id - leaves additionalProperties: false description: Selection groups, in presentation order, each carrying its member leaf ids. leaves: type: array items: type: string description: Every leaf id a report selection may carry. Membership is inclusion; absence is exclusion. required: - supported - maxDays - reportDownload - selectionVersion - groups - leaves additionalProperties: false description: Clinician share-link surface descriptor. retiredRoutes: type: array items: type: object properties: path: type: string description: The path, as it was published. removedIn: type: string description: Release the removal shipped in, without the `v`. replacedBy: description: Where the capability went, or null when it went nowhere. type: - string - "null" reason: type: string description: Why it was removed, in one sentence, in English. methods: type: array items: type: string description: The verbs the route exported before it was removed. Every verb answers 410 regardless — the path was retired, not a selection of methods on it. required: - path - removedIn - replacedBy - reason - methods additionalProperties: false description: Paths this server used to serve and no longer does. Each answers 410 Gone with `meta.errorCode` = `route.retired`. Read on launch so a client can drop the surface that depended on one before it makes the call, rather than learning from the failure. required: - apiContractVersion - derivedMetricIds - vitalsBaselineTypes - layoutTileIds - metricStatusIds - ingest - fhir - share - retiredRoutes additionalProperties: false description: Live id vocabularies + contract version. Every list is derived server-side from the canonical registry it documents, so it cannot drift from the values the routes actually accept/emit. VersionEnvelope: type: object properties: data: $ref: "#/components/schemas/VersionResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false VersionResponse: type: object properties: version: type: string description: The running build's version. From the image build arg when present, falling back to package.json for a local dev run. buildSha: description: Short Git SHA baked at image build time. Null for a local dev run, which is how a client tells a released image from one. type: - string - "null" builtAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" description: ISO-8601 build timestamp. Null for a local dev run. license: type: string repository: type: string format: uri changelog: type: string format: uri docs: type: string format: uri offlineGeoEnabled: type: boolean description: A GeoLite2-City database is present and in use. When false, login-IP geolocation falls back to the online provider named below — every login IP then leaves the host. geoProviderHost: type: string description: Host of the online geolocation provider the fallback would use, so an operator surface names the real one instead of assuming the default. required: - version - buildSha - builtAt - license - repository - changelog - docs - offlineGeoEnabled - geoProviderHost additionalProperties: false description: The running build's identity plus the offline-geolocation state. Public — no authentication, no account data. AssistantFlagsEnvelope: type: object properties: data: $ref: "#/components/schemas/AssistantFlagsResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false AssistantFlagsResponse: type: object properties: assistant: type: object properties: enabled: type: boolean description: Master switch for every AI feature. coach: type: boolean description: The Coach. briefing: type: boolean description: The daily briefing and model-written period narratives. insightStatus: type: boolean description: "Status notes: per-metric notes, workout notes and reaction lines." documentAi: type: boolean description: "Reading documents: the document vault's AI reads, lab report scans and medication extraction." required: - enabled - coach - briefing - insightStatus - documentAi additionalProperties: false description: "The operator's switches, master applied: when `enabled` is false every sub-switch is false in this payload. A switch read that fails answers every switch false." required: - assistant additionalProperties: false description: "The operator's assistant switches, and nothing else. Deprecated: whether a capability is available also depends on the record's modules, the provider, consent and who is asking, and `ai` on GET /api/auth/me is that resolved answer." UpdateCheckEnvelope: type: object properties: data: $ref: "#/components/schemas/UpdateCheckResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false UpdateCheckResponse: type: object properties: status: type: string enum: - up_to_date - newer_available - unknown description: "`unknown` is a normal outcome, not an error — it means the release feed could not be reached or read." current: type: string description: The version this instance is running. latest_tag: description: The newest published release tag. Absent when `unknown`. type: string html_url: description: Release page to deep-link to. Only on `newer_available`. anyOf: - type: string format: uri - type: "null" published_at: description: When that release was published. Only on `newer_available`. anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" checked_at: description: When the check ran. Absent when `unknown`. type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ reason: description: "Only on `unknown`: `network_error`, or `github_status_` when the feed answered but not with a success." type: string required: - status - current additionalProperties: false description: Whether a newer release exists. The three states are carried in `status`; which of the optional fields are present follows from it. ShareLinkCreated: type: object properties: data: type: object properties: id: type: string label: type: string rangeStart: type: string rangeEnd: type: - string - "null" protected: type: boolean documentCount: type: number documentOnly: type: boolean needsReselection: type: boolean expiresAt: type: string createdAt: type: string revokedAt: type: - string - "null" lastAccessAt: type: - string - "null" accessCount: type: number active: type: boolean token: type: string description: Raw `hls_` token — returned ONCE and unrecoverable thereafter. passphrase: type: string description: Raw passphrase second factor — returned ONCE; stored only as an HMAC hash, unrecoverable. shareUrl: type: string description: Absolute `/c/` URL (no passphrase). qrUrl: type: string description: Absolute share URL with the passphrase in the URL FRAGMENT (`#k=`). Encode this as the QR payload; the fragment is never sent to the server. required: - id - label - rangeStart - rangeEnd - protected - documentCount - documentOnly - needsReselection - expiresAt - createdAt - revokedAt - lastAccessAt - accessCount - active - token - passphrase - shareUrl - qrUrl additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false ShareLinkList: type: object properties: data: type: object properties: shareLinks: type: array items: type: object properties: id: type: string label: type: string rangeStart: type: string rangeEnd: type: - string - "null" protected: type: boolean documentCount: type: number documentOnly: type: boolean needsReselection: type: boolean expiresAt: type: string createdAt: type: string revokedAt: type: - string - "null" lastAccessAt: type: - string - "null" accessCount: type: number active: type: boolean required: - id - label - rangeStart - rangeEnd - protected - documentCount - documentOnly - needsReselection - expiresAt - createdAt - revokedAt - lastAccessAt - accessCount - active additionalProperties: false required: - shareLinks additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false ShareLinkRevoked: type: object properties: data: type: object properties: id: type: string revoked: type: boolean required: - id - revoked additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false ShareLinkUnlock: type: object properties: data: type: object properties: unlocked: type: boolean const: true required: - unlocked additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false ExportJsonBody: type: object properties: data: type: object properties: measurements: description: Flattened measurement rows, streamed page by page. Present for `type=measurements` and `type=all`. type: array items: type: object propertyNames: type: string additionalProperties: {} medications: description: Flattened medications with their schedules. Present for `type=medications` and `type=all`. type: array items: type: object propertyNames: type: string additionalProperties: {} intakeEvents: description: Flattened intake events (tombstoned rows excluded), newest scheduled slot first. Present for `type=intake` and `type=all`. type: array items: type: object propertyNames: type: string additionalProperties: {} moodEntries: description: Flattened mood entries with the note decrypted. Present for `type=mood` and `type=all`. type: array items: type: object propertyNames: type: string additionalProperties: {} additionalProperties: false description: Only the sections the `type` parameter selected are present; the others are absent keys, not empty arrays. Row shapes are the export formatters' flat column sets (the same columns the CSV variant emits as headers), not the API resource shapes — they are deliberately typed loosely here rather than restated, because the formatter is the single source and a restatement would drift. required: - data additionalProperties: false description: "The JSON download body. NOTE: this is NOT the standard `{ data, error }` envelope — the route builds the response itself so it can stream, and it carries `data` alone with no `error` key. Do not decode it with the shared envelope decoder." FullBackupPayload: type: object properties: schemaVersion: default: "1" type: string minLength: 1 exportedAt: type: string minLength: 1 userId: type: string minLength: 1 appSettings: default: null anyOf: - type: object properties: id: type: string minLength: 1 registrationEnabled: type: boolean mfaRequired: type: boolean defaultLocale: type: string telegramGlobal: type: boolean ntfyGlobal: type: boolean webPushGlobal: type: boolean webPushVapidPublicKey: type: - string - "null" webPushVapidPrivateKeyEncrypted: type: - string - "null" webPushVapidSubject: type: - string - "null" apiGlobal: type: boolean umamiEnabled: type: boolean umamiScriptUrl: type: - string - "null" umamiWebsiteId: type: - string - "null" glitchtipEnabled: type: boolean glitchtipDsn: type: - string - "null" glitchtipEnvironment: type: - string - "null" reminderLateMinutes: type: integer minimum: -9007199254740991 maximum: 9007199254740991 reminderMissedMinutes: type: integer minimum: -9007199254740991 maximum: 9007199254740991 adminAiKeyEncrypted: type: - string - "null" adminAiModel: type: string adminAiBaseUrl: type: string adminCodexAccessTokenEncrypted: type: - string - "null" adminCodexRefreshTokenEncrypted: type: - string - "null" adminCodexAccountIdEncrypted: type: - string - "null" adminCodexTokenExpiresAt: anyOf: - type: string minLength: 1 - type: "null" adminCodexConnectedAt: anyOf: - type: string minLength: 1 - type: "null" adminCodexConnectionStatus: type: string adminAiInsightsFeedbackSummary: anyOf: - {} - type: "null" defaultUserTimezone: type: - string - "null" assistantEnabled: type: boolean assistantCoachEnabled: type: boolean assistantBriefingEnabled: type: boolean assistantInsightStatusEnabled: type: boolean assistantDocumentAiEnabled: type: boolean aiReasoningEnabled: type: boolean aiReasoningMaxEffort: type: string enum: - low - medium - high moduleAvailabilityJson: anyOf: - {} - type: "null" documentMaxFileBytes: type: integer minimum: -9007199254740991 maximum: 9007199254740991 documentQuotaBytes: type: string pattern: ^\d+$ required: - id - registrationEnabled - mfaRequired - defaultLocale - telegramGlobal - ntfyGlobal - webPushGlobal - webPushVapidPublicKey - webPushVapidPrivateKeyEncrypted - webPushVapidSubject - apiGlobal - umamiEnabled - umamiScriptUrl - umamiWebsiteId - glitchtipEnabled - glitchtipDsn - glitchtipEnvironment - reminderLateMinutes - reminderMissedMinutes - adminAiKeyEncrypted - adminAiModel - adminAiBaseUrl - adminCodexAccessTokenEncrypted - adminCodexRefreshTokenEncrypted - adminCodexAccountIdEncrypted - adminCodexTokenExpiresAt - adminCodexConnectedAt - adminCodexConnectionStatus - adminAiInsightsFeedbackSummary - defaultUserTimezone - assistantEnabled - assistantCoachEnabled - assistantBriefingEnabled - assistantInsightStatusEnabled - moduleAvailabilityJson - documentMaxFileBytes - documentQuotaBytes additionalProperties: {} - type: "null" accountSettings: default: null anyOf: - type: object properties: heightCm: type: - number - "null" dateOfBirth: anyOf: - type: string minLength: 1 - type: "null" gender: type: - string - "null" hasDiabetes: type: boolean displayName: type: - string - "null" fullName: type: - string - "null" insurerName: type: - string - "null" insuranceNumber: type: - string - "null" insuranceNumberEncrypted: type: - string - "null" insurerIkNumber: type: - string - "null" lastReportPracticeName: type: - string - "null" avatarBytes: anyOf: - type: string minLength: 1 - type: "null" avatarContentType: type: - string - "null" avatarUpdatedAt: anyOf: - type: string minLength: 1 - type: "null" homeLat: type: - number - "null" homeLon: type: - number - "null" homeLabel: type: - string - "null" homeTimezone: type: - string - "null" homeSince: anyOf: - type: string minLength: 1 - type: "null" homeLocationEncrypted: anyOf: - type: string minLength: 1 - type: "null" environmentAirQualityEnabled: type: boolean timezone: type: string locale: type: - string - "null" unitPreference: type: - string - "null" glucoseUnit: type: - string - "null" timeFormat: type: string dateFormat: type: string modulePreferencesJson: {} thresholdsJson: {} healthScoreConfigJson: {} sourcePriorityJson: {} dashboardWidgetsJson: {} insightsLayoutJson: {} medicationListLayoutJson: {} moodTagLayoutJson: {} documentsLayoutJson: {} moduleListLayoutJson: {} previewFeaturesJson: {} activityGoalsJson: {} reportSelectionJson: {} globalExcludedInjectionSites: type: array items: type: string healthKitConfigJson: {} notificationPrefs: {} moodReminderEnabled: type: boolean aiProvider: type: - string - "null" aiModel: type: - string - "null" aiBaseUrl: type: - string - "null" aiCompatBaseUrl: type: - string - "null" aiCompatModel: type: - string - "null" aiProviderChain: {} aiResponseTimeoutSeconds: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" useCentralCodex: type: boolean insightsPrivacyMode: type: string insightsExcludeMetrics: type: array items: type: string disableCoach: type: boolean aiUsageLevel: type: - string - "null" coachPrefsJson: {} documentsAutoAiRead: type: boolean labsLocalOcrEnabled: type: boolean storeWorkoutRoutes: type: boolean onboardingCompletedAt: anyOf: - type: string minLength: 1 - type: "null" onboardingTourCompleted: type: boolean onboardingTourProgressJson: {} disclaimerAcknowledgedAt: anyOf: - type: string minLength: 1 - type: "null" disclaimerAcknowledgedVersion: type: - string - "null" passkeyUpgradeNudgeDismissed: type: boolean additionalProperties: {} - type: "null" measurements: default: [] type: array items: type: object properties: id: type: string minLength: 1 type: type: string enum: - WEIGHT - BLOOD_PRESSURE_SYS - BLOOD_PRESSURE_DIA - PULSE - BODY_FAT - SLEEP_DURATION - ACTIVITY_STEPS - BLOOD_GLUCOSE - TOTAL_BODY_WATER - BONE_MASS - OXYGEN_SATURATION - HEART_RATE_VARIABILITY - RESTING_HEART_RATE - ACTIVE_ENERGY_BURNED - FLIGHTS_CLIMBED - WALKING_RUNNING_DISTANCE - VO2_MAX - BODY_TEMPERATURE - FAT_FREE_MASS - FAT_MASS - MUSCLE_MASS - SKIN_TEMPERATURE - PULSE_WAVE_VELOCITY - VASCULAR_AGE - VISCERAL_FAT - AUDIO_EXPOSURE_ENV - AUDIO_EXPOSURE_HEADPHONE - TIME_IN_DAYLIGHT - WALKING_STEADINESS - AUDIO_EXPOSURE_EVENT - RESPIRATORY_RATE - BODY_MASS_INDEX - LEAN_BODY_MASS - WALKING_HEART_RATE_AVERAGE - WALKING_ASYMMETRY - WALKING_DOUBLE_SUPPORT - WALKING_STEP_LENGTH - WALKING_SPEED - CARDIO_RECOVERY - WRIST_TEMPERATURE - FALL_COUNT - SIX_MINUTE_WALK_DISTANCE - STAIR_ASCENT_SPEED - STAIR_DESCENT_SPEED - BREATHING_DISTURBANCES - IRREGULAR_RHYTHM_NOTIFICATION - HIGH_HEART_RATE_EVENT - LOW_HEART_RATE_EVENT - WALKING_STEADINESS_EVENT - BREATHING_DISTURBANCE_EVENT - RECOVERY_SCORE - STRESS_SCORE - STRAIN_SCORE - HRV_RMSSD - DAY_STRAIN - WORKOUT_STRAIN - SLEEP_PERFORMANCE - SLEEP_EFFICIENCY - SLEEP_CONSISTENCY - SLEEP_NEED - ENERGY_EXPENDITURE_KJ - AVERAGE_HEART_RATE - MAX_HEART_RATE - SLEEP_DISTURBANCE_COUNT - ANS_CHARGE - CARDIO_LOAD - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION - RESILIENCE - PHQ9_SCORE - GAD7_SCORE - GRIP_STRENGTH - PAIN_NRS - WAIST_CIRCUMFERENCE - WAIST_TO_HEIGHT - WHO5_SCORE - SCI_SCORE - EXERCISE_MINUTES - STAND_MINUTES - STAND_HOURS - MINDFUL_MINUTES - STATE_OF_MIND_VALENCE - INDOOR_CO2 - INDOOR_TEMPERATURE - INDOOR_HUMIDITY - AFIB_BURDEN value: type: number valueMin: type: - number - "null" valueMax: type: - number - "null" unit: type: string minLength: 1 measuredAt: type: string minLength: 1 source: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT notes: type: - string - "null" notesEncrypted: anyOf: - type: string minLength: 1 - type: "null" externalId: type: - string - "null" externalSourceVersion: type: - string - "null" aggregationProvenance: anyOf: - type: string enum: - HEALTHKIT_STATISTICS - EXPORT_XML_SOURCE_MAX - LEGACY_UNKNOWN - type: "null" glucoseContext: anyOf: - type: string enum: - FASTING - POSTPRANDIAL - RANDOM - BEDTIME - type: "null" sleepStage: anyOf: - type: string enum: - IN_BED - AWAKE - ASLEEP - REM - CORE - DEEP - type: "null" rhythmClassification: anyOf: - type: string enum: - IRREGULAR - NOT_DETECTED - INCONCLUSIVE - LOW - VERY_LOW - FIRED - type: "null" deviceType: type: - string - "null" ingestTokenId: anyOf: - type: string maxLength: 64 - type: "null" syncVersion: type: integer minimum: -9007199254740991 maximum: 9007199254740991 deletedAt: anyOf: - type: string minLength: 1 - type: "null" createdAt: type: string minLength: 1 updatedAt: type: string minLength: 1 required: - type - value - unit - measuredAt additionalProperties: {} medications: default: [] type: array items: type: object properties: id: type: string minLength: 1 ref: type: string minLength: 1 name: type: string minLength: 1 dose: type: string treatmentClass: type: string enum: - GENERIC - GLP1 dosesPerUnit: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" unitsPerDose: type: string minLength: 1 active: type: boolean notificationsEnabled: type: boolean pausedAt: anyOf: - type: string minLength: 1 - type: "null" snoozedUntil: anyOf: - type: string minLength: 1 - type: "null" startsOn: anyOf: - type: string minLength: 1 - type: "null" endsOn: anyOf: - type: string minLength: 1 - type: "null" oneShot: type: boolean asNeeded: type: boolean trackIntake: type: boolean autoMarkTaken: type: boolean autoMarkTakenSince: anyOf: - type: string minLength: 1 - type: "null" category: type: string maxLength: 64 categoryUpdatedAt: anyOf: - type: string minLength: 1 - type: "null" deliveryForm: type: string enum: - ORAL - INJECTION - OTHER trackInjectionSites: type: boolean allowedInjectionSites: type: array items: type: string enum: - ABDOMEN_LEFT - ABDOMEN_RIGHT - ABDOMEN_UPPER_LEFT - ABDOMEN_UPPER_RIGHT - THIGH_LEFT - THIGH_RIGHT - UPPER_ARM_LEFT - UPPER_ARM_RIGHT liveActivityEnabled: type: boolean criticalAlarmEnabled: type: boolean atcCode: type: - string - "null" rxNormCode: type: - string - "null" lowStockNotifiedAt: anyOf: - type: string minLength: 1 - type: "null" lowStockNotifiedThresholdDays: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" reorderLeadDays: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" externalSource: anyOf: - type: string enum: - WEB - API - REMINDER - IMPORT - APPLE_HEALTH - type: "null" externalId: type: - string - "null" createdAt: type: string minLength: 1 updatedAt: type: string minLength: 1 schedules: default: [] type: array items: type: object properties: id: type: string minLength: 1 windowStart: type: string minLength: 1 windowEnd: type: string minLength: 1 label: type: - string - "null" dose: type: - string - "null" unitsPerDose: type: - string - number - "null" daysOfWeek: type: - string - "null" timesOfDay: type: array items: type: string reminderGraceMinutes: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" rrule: type: - string - "null" rollingIntervalDays: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" scheduleType: type: string enum: - SCHEDULED - PRN - CYCLIC cyclicOnWeeks: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" cyclicOffWeeks: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" doseWindows: anyOf: - {} - type: "null" required: - windowStart - windowEnd additionalProperties: {} sideEffects: default: [] type: array items: type: object properties: id: type: string minLength: 1 occurredAt: type: string minLength: 1 category: type: string enum: - GI - METABOLIC - INJECTION_SITE - COGNITIVE - GLP1_SPECIFIC entry: type: string enum: - NAUSEA - VOMITING - DIARRHEA - CONSTIPATION - ABDOMINAL_PAIN - HYPOGLYCEMIA_SYMPTOMS - DEHYDRATION - ANOREXIA - ELECTROLYTE_FATIGUE - INJECTION_REDNESS - INJECTION_SWELLING - INJECTION_BRUISING - INJECTION_INDURATION - BRAIN_FOG - DIZZINESS - LOW_MOOD - LOW_ENERGY - EARLY_SATIETY - GASTROPARESIS_LIKE - DYSGEUSIA - GALLBLADDER_DISCOMFORT severity: type: integer minimum: 1 maximum: 5 notes: type: - string - "null" notesEncrypted: anyOf: - type: string minLength: 1 - type: "null" createdAt: type: string minLength: 1 required: - occurredAt - category - entry - severity additionalProperties: {} pauseEras: default: [] type: array items: type: object properties: id: type: string minLength: 1 pausedAt: type: string minLength: 1 resumedAt: anyOf: - type: string minLength: 1 - type: "null" createdAt: type: string minLength: 1 required: - pausedAt - resumedAt additionalProperties: {} ingredients: default: [] type: array items: type: object properties: id: type: string minLength: 1 nutrient: type: string minLength: 1 maxLength: 40 amountPerUnit: type: number exclusiveMinimum: 0 unit: type: string minLength: 1 maxLength: 8 enteredAmount: type: - number - "null" enteredUnit: anyOf: - type: string maxLength: 8 - type: "null" source: default: MANUAL type: string enum: - MANUAL - LABEL_AI createdAt: type: string minLength: 1 updatedAt: type: string minLength: 1 required: - nutrient - amountPerUnit - unit - source additionalProperties: {} doseChanges: default: [] type: array items: type: object properties: id: type: string minLength: 1 effectiveFrom: type: string minLength: 1 doseValue: type: number doseUnit: type: string minLength: 1 note: type: - string - "null" noteEncrypted: anyOf: - type: string minLength: 1 - type: "null" createdAt: type: string minLength: 1 required: - effectiveFrom - doseValue - doseUnit additionalProperties: {} inventoryItems: default: [] type: array items: type: object properties: id: type: string minLength: 1 state: type: string enum: - ACTIVE - IN_USE - EXPIRED - USED_UP containerType: type: string enum: - PEN - AMPOULE - BLISTER - INHALER - BOTTLE - OTHER unitsTotal: type: string minLength: 1 unitsRemaining: type: string minLength: 1 firstUseAt: anyOf: - type: string minLength: 1 - type: "null" expiresAt: anyOf: - type: string minLength: 1 - type: "null" printedExpiry: anyOf: - type: string minLength: 1 - type: "null" purchasedAt: anyOf: - type: string minLength: 1 - type: "null" manufacturer: type: - string - "null" doseStrength: type: - string - "null" notes: type: - string - "null" notesEncrypted: anyOf: - type: string minLength: 1 - type: "null" createdAt: type: string minLength: 1 updatedAt: type: string minLength: 1 required: - unitsTotal - unitsRemaining additionalProperties: {} inventoryEvents: default: [] type: array items: type: object properties: id: type: string minLength: 1 delta: type: integer minimum: -9007199254740991 maximum: 9007199254740991 reason: type: string minLength: 1 occurredAt: type: string minLength: 1 required: - delta - reason - occurredAt additionalProperties: {} phaseConfig: anyOf: - type: object properties: id: type: string minLength: 1 greenValue: type: integer minimum: -9007199254740991 maximum: 9007199254740991 greenMode: type: string enum: - MINUTES - PERCENT yellowValue: type: integer minimum: -9007199254740991 maximum: 9007199254740991 yellowMode: type: string enum: - MINUTES - PERCENT orangeValue: type: integer minimum: -9007199254740991 maximum: 9007199254740991 orangeMode: type: string enum: - MINUTES - PERCENT redValue: type: integer minimum: -9007199254740991 maximum: 9007199254740991 redMode: type: string enum: - MINUTES - PERCENT additionalProperties: {} - type: "null" efficacyTargets: default: [] type: array items: type: object properties: id: type: string minLength: 1 measurementType: anyOf: - type: string enum: - WEIGHT - BLOOD_PRESSURE_SYS - BLOOD_PRESSURE_DIA - PULSE - BODY_FAT - SLEEP_DURATION - ACTIVITY_STEPS - BLOOD_GLUCOSE - TOTAL_BODY_WATER - BONE_MASS - OXYGEN_SATURATION - HEART_RATE_VARIABILITY - RESTING_HEART_RATE - ACTIVE_ENERGY_BURNED - FLIGHTS_CLIMBED - WALKING_RUNNING_DISTANCE - VO2_MAX - BODY_TEMPERATURE - FAT_FREE_MASS - FAT_MASS - MUSCLE_MASS - SKIN_TEMPERATURE - PULSE_WAVE_VELOCITY - VASCULAR_AGE - VISCERAL_FAT - AUDIO_EXPOSURE_ENV - AUDIO_EXPOSURE_HEADPHONE - TIME_IN_DAYLIGHT - WALKING_STEADINESS - AUDIO_EXPOSURE_EVENT - RESPIRATORY_RATE - BODY_MASS_INDEX - LEAN_BODY_MASS - WALKING_HEART_RATE_AVERAGE - WALKING_ASYMMETRY - WALKING_DOUBLE_SUPPORT - WALKING_STEP_LENGTH - WALKING_SPEED - CARDIO_RECOVERY - WRIST_TEMPERATURE - FALL_COUNT - SIX_MINUTE_WALK_DISTANCE - STAIR_ASCENT_SPEED - STAIR_DESCENT_SPEED - BREATHING_DISTURBANCES - IRREGULAR_RHYTHM_NOTIFICATION - HIGH_HEART_RATE_EVENT - LOW_HEART_RATE_EVENT - WALKING_STEADINESS_EVENT - BREATHING_DISTURBANCE_EVENT - RECOVERY_SCORE - STRESS_SCORE - STRAIN_SCORE - HRV_RMSSD - DAY_STRAIN - WORKOUT_STRAIN - SLEEP_PERFORMANCE - SLEEP_EFFICIENCY - SLEEP_CONSISTENCY - SLEEP_NEED - ENERGY_EXPENDITURE_KJ - AVERAGE_HEART_RATE - MAX_HEART_RATE - SLEEP_DISTURBANCE_COUNT - ANS_CHARGE - CARDIO_LOAD - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION - RESILIENCE - PHQ9_SCORE - GAD7_SCORE - GRIP_STRENGTH - PAIN_NRS - WAIST_CIRCUMFERENCE - WAIST_TO_HEIGHT - WHO5_SCORE - SCI_SCORE - EXERCISE_MINUTES - STAND_MINUTES - STAND_HOURS - MINDFUL_MINUTES - STATE_OF_MIND_VALENCE - INDOOR_CO2 - INDOOR_TEMPERATURE - INDOOR_HUMIDITY - AFIB_BURDEN - type: "null" biomarkerName: anyOf: - type: string minLength: 1 - type: "null" primary: type: boolean createdAt: type: string minLength: 1 updatedAt: type: string minLength: 1 additionalProperties: {} scheduleRevisions: default: [] type: array items: type: object properties: id: type: string minLength: 1 validFrom: type: string minLength: 1 validUntil: type: string minLength: 1 payload: {} source: type: string minLength: 1 supersededByIndex: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" createdAt: type: string minLength: 1 required: - validFrom - validUntil - payload additionalProperties: {} courses: type: array items: type: object properties: startsOn: type: string format: date pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ endsOn: anyOf: - type: string format: date pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ - type: "null" endedAt: anyOf: - type: string minLength: 1 - type: "null" note: anyOf: - type: string maxLength: 280 - type: "null" noteEncrypted: anyOf: - type: string minLength: 1 - type: "null" createdAt: type: string minLength: 1 updatedAt: type: string minLength: 1 required: - startsOn - endsOn additionalProperties: {} required: - name - dose - schedules - sideEffects - pauseEras - ingredients - doseChanges - inventoryItems - inventoryEvents - efficacyTargets - scheduleRevisions additionalProperties: {} customMedicationCategories: default: [] type: array items: type: object properties: key: type: string pattern: ^custom:[0-9a-f-]{36}$ label: anyOf: - type: string minLength: 1 maxLength: 40 - type: "null" labelEncrypted: type: string minLength: 1 sortOrder: default: 0 type: integer minimum: 0 maximum: 9007199254740991 isActive: default: true type: boolean createdAt: type: string minLength: 1 updatedAt: type: string minLength: 1 required: - key - sortOrder - isActive additionalProperties: {} intakeEvents: default: [] type: array items: type: object properties: id: type: string minLength: 1 medicationId: type: string minLength: 1 medication: type: string minLength: 1 scheduledFor: type: string minLength: 1 takenAt: anyOf: - type: string minLength: 1 - type: "null" skipped: type: boolean autoMissed: type: boolean autoRecorded: type: boolean attributionSource: type: string enum: - AUTO - USER_PIN source: type: string enum: - WEB - API - REMINDER - IMPORT - APPLE_HEALTH idempotencyKey: type: - string - "null" createdAt: type: string minLength: 1 injectionSite: anyOf: - type: string enum: - ABDOMEN_LEFT - ABDOMEN_RIGHT - ABDOMEN_UPPER_LEFT - ABDOMEN_UPPER_RIGHT - THIGH_LEFT - THIGH_RIGHT - UPPER_ARM_LEFT - UPPER_ARM_RIGHT - type: "null" doseTaken: type: - string - "null" inventoryConsumption: anyOf: - {} - type: "null" externalId: type: - string - "null" updatedAt: type: string minLength: 1 syncVersion: type: integer minimum: -9007199254740991 maximum: 9007199254740991 deletedAt: anyOf: - type: string minLength: 1 - type: "null" required: - medication - scheduledFor additionalProperties: {} moodEntries: default: [] type: array items: type: object properties: id: type: string minLength: 1 date: type: string minLength: 1 mood: type: string minLength: 1 score: type: integer minimum: 0 maximum: 10 a1: anyOf: - type: integer minimum: 0 maximum: 10 - type: "null" a2: anyOf: - type: integer minimum: 0 maximum: 10 - type: "null" a3: anyOf: - type: integer minimum: 0 maximum: 10 - type: "null" a4: anyOf: - type: integer minimum: 0 maximum: 10 - type: "null" a5: anyOf: - type: integer minimum: 0 maximum: 10 - type: "null" tags: type: - "null" - string note: type: - string - "null" noteEncrypted: anyOf: - type: string minLength: 1 - type: "null" source: type: string minLength: 1 externalId: type: - string - "null" loggedAt: type: string minLength: 1 tz: type: - string - "null" syncedAt: type: string minLength: 1 syncVersion: type: integer minimum: -9007199254740991 maximum: 9007199254740991 deletedAt: anyOf: - type: string minLength: 1 - type: "null" createdAt: type: string minLength: 1 updatedAt: type: string minLength: 1 factors: default: [] type: array items: type: object properties: key: type: string minLength: 1 rating: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - key - rating additionalProperties: {} structuredTags: default: [] type: array items: type: string minLength: 1 linkCreatedAt: type: array items: type: object properties: key: type: string minLength: 1 createdAt: type: string minLength: 1 required: - key - createdAt additionalProperties: false context: anyOf: - type: object properties: workStatus: type: - string - "null" workMinutes: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" overtimeMinutes: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" workLoad: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" workSatisfaction: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" contactCircles: type: - string - "null" contactForm: type: - string - "null" contactExtent: type: - string - "null" contactQuality: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" contactSupport: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" leisureCategories: type: - string - "null" leisureMinutes: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" leisureJoy: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" leisureRecovery: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" eventType: type: - string - "null" eventValence: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" eventAt: anyOf: - type: string minLength: 1 - type: "null" note: type: - string - "null" notesEncrypted: anyOf: - type: string minLength: 1 - type: "null" createdAt: type: string minLength: 1 updatedAt: type: string minLength: 1 additionalProperties: {} - type: "null" required: - date - mood - score - loggedAt - factors - structuredTags additionalProperties: {} cycleProfile: default: null anyOf: - type: object properties: id: type: string minLength: 1 goal: type: string enum: - GENERAL_HEALTH - AVOID_PREGNANCY - TRYING_TO_CONCEIVE - PERIMENOPAUSE - OFF cycleTrackingEnabled: type: - boolean - "null" typicalCycleLength: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" typicalPeriodLength: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" lutealPhaseLength: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" secondarySymptom: anyOf: - type: string enum: - MUCUS - CERVIX - type: "null" predictionEnabled: type: boolean rawChartMode: type: boolean discreetNotifications: type: boolean sensitiveCategoryEncryption: type: boolean createdAt: type: string minLength: 1 updatedAt: type: string minLength: 1 additionalProperties: {} - type: "null" cycles: default: [] type: array items: type: object properties: id: type: string minLength: 1 startDate: type: string minLength: 1 endDate: type: - string - "null" periodEndDate: type: - string - "null" lengthDays: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" ovulationDate: type: - string - "null" ovulationConfirmed: type: boolean isPredicted: type: boolean tz: type: - string - "null" syncVersion: type: integer minimum: -9007199254740991 maximum: 9007199254740991 deletedAt: anyOf: - type: string minLength: 1 - type: "null" absorbedIntoId: anyOf: - type: string minLength: 1 - type: "null" createdAt: type: string minLength: 1 updatedAt: type: string minLength: 1 required: - startDate additionalProperties: {} cycleDayLogs: default: [] type: array items: type: object properties: id: type: string minLength: 1 date: type: string minLength: 1 cycleId: type: - string - "null" flow: anyOf: - type: string enum: - NONE - SPOTTING - LIGHT - MEDIUM - HEAVY - type: "null" intermenstrualBleeding: type: boolean basalBodyTempC: type: - number - "null" temperatureExcluded: type: boolean ovulationTest: anyOf: - type: string enum: - NEGATIVE - POSITIVE_LH_SURGE - ESTROGEN_SURGE - INDETERMINATE - type: "null" cervicalMucus: anyOf: - type: string enum: - DRY - STICKY - CREAMY - WATERY - EGG_WHITE - type: "null" cervixPosition: anyOf: - type: string enum: - LOW - HIGH - type: "null" cervixFirmness: anyOf: - type: string enum: - FIRM - SOFT - type: "null" cervixOpening: anyOf: - type: string enum: - CLOSED - OPEN - type: "null" sexualActivity: type: boolean protectedSex: type: - boolean - "null" pregnancyTest: anyOf: - type: string enum: - NEGATIVE - POSITIVE - INDETERMINATE - type: "null" progesteroneTest: anyOf: - type: string enum: - NEGATIVE - POSITIVE - INDETERMINATE - type: "null" contraceptive: anyOf: - type: string enum: - NONE - UNSPECIFIED - IMPLANT - INJECTION - IUD - INTRAVAGINAL_RING - ORAL - PATCH - EMERGENCY - type: "null" sensitiveEncrypted: type: - string - "null" notesEncrypted: type: - string - "null" note: type: - string - "null" sensitive: anyOf: - anyOf: - type: object properties: sexualActivity: type: boolean protectedSex: type: - boolean - "null" pregnancyTest: anyOf: - type: string enum: - NEGATIVE - POSITIVE - INDETERMINATE - type: "null" progesteroneTest: anyOf: - type: string enum: - NEGATIVE - POSITIVE - INDETERMINATE - type: "null" contraceptive: anyOf: - type: string enum: - NONE - UNSPECIFIED - IMPLANT - INJECTION - IUD - INTRAVAGINAL_RING - ORAL - PATCH - EMERGENCY - type: "null" additionalProperties: false - type: string - type: "null" source: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT externalId: type: - string - "null" tz: type: - string - "null" syncVersion: type: integer minimum: -9007199254740991 maximum: 9007199254740991 deletedAt: anyOf: - type: string minLength: 1 - type: "null" createdAt: type: string minLength: 1 updatedAt: type: string minLength: 1 symptomKeys: default: [] type: array items: type: string symptomSeverities: default: [] type: array items: type: object properties: key: type: string minLength: 1 severity: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" required: - key additionalProperties: {} symptomLinkCreatedAt: type: array items: type: object properties: key: type: string minLength: 1 createdAt: type: string minLength: 1 required: - key - createdAt additionalProperties: false required: - date - symptomKeys - symptomSeverities additionalProperties: {} customSymptoms: default: [] type: array items: type: object properties: id: type: string minLength: 1 key: type: string minLength: 1 labelKey: type: string minLength: 1 categoryId: type: string minLength: 1 icon: type: - string - "null" sortOrder: default: 0 type: integer minimum: -9007199254740991 maximum: 9007199254740991 isActive: default: true type: boolean labelEncrypted: type: - string - "null" label: type: - string - "null" required: - key - labelKey - categoryId - sortOrder - isActive additionalProperties: {} customMoodTagCategories: default: [] type: array items: type: object properties: id: type: string minLength: 1 key: type: string minLength: 1 labelKey: type: string minLength: 1 icon: type: - string - "null" sortOrder: type: integer minimum: -9007199254740991 maximum: 9007199254740991 isActive: type: boolean labelEncrypted: type: - string - "null" label: type: - string - "null" required: - id - key - labelKey additionalProperties: {} hiddenMoodTags: default: [] type: array items: type: object properties: key: type: string minLength: 1 createdAt: type: string minLength: 1 required: - key additionalProperties: {} customMoodTags: default: [] type: array items: type: object properties: id: type: string minLength: 1 key: type: string minLength: 1 labelKey: type: string minLength: 1 categoryId: type: string minLength: 1 kind: type: string minLength: 1 isActive: default: true type: boolean icon: type: - string - "null" sortOrder: default: 0 type: integer minimum: -9007199254740991 maximum: 9007199254740991 labelEncrypted: type: - string - "null" label: type: - string - "null" scaleMin: default: 1 type: integer minimum: -9007199254740991 maximum: 9007199254740991 scaleMax: default: 5 type: integer minimum: -9007199254740991 maximum: 9007199254740991 inverse: default: false type: boolean required: - key - labelKey - categoryId - kind - isActive - sortOrder - scaleMin - scaleMax - inverse additionalProperties: {} labResults: default: [] type: array items: type: object properties: id: type: string minLength: 1 ref: type: string minLength: 1 panel: type: - string - "null" analyte: type: string minLength: 1 value: type: - number - "null" valueText: type: - string - "null" unit: type: string referenceLow: type: - number - "null" referenceHigh: type: - number - "null" sourceReferenceLow: type: - number - "null" sourceReferenceHigh: type: - number - "null" sourceReferenceText: type: - string - "null" sourceValue: type: - number - "null" sourceUnit: type: - string - "null" takenAt: type: string minLength: 1 source: type: string minLength: 1 biomarkerName: type: - string - "null" biomarkerId: type: - string - "null" note: type: - string - "null" createdAt: type: string minLength: 1 noteEncrypted: anyOf: - type: string minLength: 1 - type: "null" deletedAt: anyOf: - type: string minLength: 1 - type: "null" updatedAt: type: string minLength: 1 required: - analyte - unit - takenAt - source additionalProperties: {} biomarkers: default: [] type: array items: type: object properties: id: type: string minLength: 1 name: type: string minLength: 1 unit: type: string lowerBound: type: - number - "null" upperBound: type: - number - "null" panel: type: - string - "null" hidden: type: boolean analyteKey: type: - string - "null" context: type: - string - "null" contextEncrypted: anyOf: - type: string minLength: 1 - type: "null" aliases: type: array items: type: object properties: id: type: string minLength: 1 name: type: string minLength: 1 maxLength: 120 source: type: string enum: - MERGE - IMPORT_CHOICE - MANUAL createdAt: type: string minLength: 1 required: - name - source additionalProperties: {} createdAt: type: string minLength: 1 updatedAt: type: string minLength: 1 required: - name - unit additionalProperties: {} biomarkerMerges: default: [] type: array items: type: object properties: id: type: string minLength: 1 targetBiomarkerId: type: string minLength: 1 targetName: type: string minLength: 1 sourceBiomarkerId: type: string minLength: 1 sourceName: type: string minLength: 1 maxLength: 120 readingCount: type: integer minimum: 0 maximum: 9007199254740991 mergedAt: type: string minLength: 1 required: - targetName - sourceBiomarkerId - sourceName - readingCount - mergedAt additionalProperties: {} illnessEpisodes: default: [] type: array items: type: object properties: id: type: string minLength: 1 label: type: string minLength: 1 type: type: string enum: - INFECTION - ALLERGY - INJURY - MENTAL_HEALTH - AUTOIMMUNE - CHRONIC - OTHER lifecycle: type: string enum: - ACUTE - CHRONIC_ONGOING - RECURRING - FLARE onsetAt: type: string minLength: 1 resolvedAt: anyOf: - type: string minLength: 1 - type: "null" parentConditionId: type: - string - "null" note: type: - string - "null" createdAt: type: string minLength: 1 noteEncrypted: anyOf: - type: string minLength: 1 - type: "null" bodySite: anyOf: - type: string maxLength: 200 - type: "null" bodySiteEncrypted: anyOf: - type: string minLength: 1 - type: "null" laterality: anyOf: - type: string enum: - LEFT - RIGHT - BOTH - type: "null" deletedAt: anyOf: - type: string minLength: 1 - type: "null" updatedAt: type: string minLength: 1 dayLogs: default: [] type: array items: type: object properties: id: type: string minLength: 1 episodeId: type: string minLength: 1 date: type: string minLength: 1 functionalImpact: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" feverC: type: - number - "null" symptoms: default: [] type: array items: type: object properties: key: type: string minLength: 1 severity: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" createdAt: type: string minLength: 1 required: - key additionalProperties: {} note: type: - string - "null" updatedAt: type: string minLength: 1 noteEncrypted: anyOf: - type: string minLength: 1 - type: "null" tz: type: - string - "null" createdAt: type: string minLength: 1 deletedAt: anyOf: - type: string minLength: 1 - type: "null" required: - date - symptoms additionalProperties: {} required: - id - label - type - lifecycle - onsetAt - dayLogs additionalProperties: {} symptomDefinitions: default: [] type: array items: type: object properties: id: type: string minLength: 1 label: type: string minLength: 1 labelEncrypted: type: string minLength: 1 icon: type: - string - "null" sortOrder: type: integer minimum: -9007199254740991 maximum: 9007199254740991 isActive: type: boolean createdAt: type: string minLength: 1 updatedAt: type: string minLength: 1 events: default: [] type: array items: type: object properties: id: type: string minLength: 1 occurredAt: type: string minLength: 1 intensity: type: integer minimum: 0 maximum: 10 note: type: - string - "null" noteEncrypted: anyOf: - type: string minLength: 1 - type: "null" episodeId: anyOf: - type: string minLength: 1 - type: "null" createdAt: type: string minLength: 1 updatedAt: type: string minLength: 1 required: - occurredAt - intensity - createdAt - updatedAt additionalProperties: {} required: - sortOrder - isActive - createdAt - updatedAt - events additionalProperties: {} lifeEvents: default: [] type: array items: type: object properties: id: type: string minLength: 1 category: type: string enum: - FAMILY - HOME - WORK - LOSS - OTHER startDate: type: string pattern: ^\d{4}-\d{2}-\d{2}$ endDate: anyOf: - type: string pattern: ^\d{4}-\d{2}-\d{2}$ - type: "null" precision: type: string enum: - DAY - MONTH - YEAR title: type: string minLength: 1 titleEncrypted: type: string minLength: 1 note: type: - string - "null" noteEncrypted: anyOf: - type: string minLength: 1 - type: "null" createdAt: type: string minLength: 1 updatedAt: type: string minLength: 1 deletedAt: anyOf: - type: string minLength: 1 - type: "null" required: - category - startDate - precision - createdAt - updatedAt additionalProperties: {} allergies: default: [] type: array items: type: object properties: id: type: string minLength: 1 substance: type: string minLength: 1 category: type: string enum: - FOOD - MEDICATION - ENVIRONMENT - BIOLOGIC - OTHER type: type: string enum: - ALLERGY - INTOLERANCE severity: anyOf: - type: string enum: - MILD - MODERATE - SEVERE - type: "null" status: type: string enum: - ACTIVE - INACTIVE - RESOLVED onsetAt: anyOf: - type: string minLength: 1 - type: "null" reaction: type: - string - "null" note: type: - string - "null" createdAt: type: string minLength: 1 reactionEncrypted: anyOf: - type: string minLength: 1 - type: "null" notesEncrypted: anyOf: - type: string minLength: 1 - type: "null" deletedAt: anyOf: - type: string minLength: 1 - type: "null" updatedAt: type: string minLength: 1 required: - id - substance - category - type - status additionalProperties: {} familyHistory: default: [] type: array items: type: object properties: id: type: string minLength: 1 relationship: type: string enum: - MOTHER - FATHER - SISTER - BROTHER - DAUGHTER - SON - GRANDMOTHER_MATERNAL - GRANDFATHER_MATERNAL - GRANDMOTHER_PATERNAL - GRANDFATHER_PATERNAL - AUNT - UNCLE - COUSIN - HALF_SIBLING - OTHER condition: type: string minLength: 1 ageAtOnset: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" note: type: - string - "null" notesEncrypted: anyOf: - type: string minLength: 1 - type: "null" createdAt: type: string minLength: 1 updatedAt: type: string minLength: 1 deletedAt: anyOf: - type: string minLength: 1 - type: "null" required: - id - relationship - condition additionalProperties: {} workouts: default: [] type: array items: type: object properties: id: type: string minLength: 1 sportType: type: string minLength: 1 startedAt: type: string minLength: 1 endedAt: type: string minLength: 1 durationSec: type: integer minimum: -9007199254740991 maximum: 9007199254740991 totalEnergyKcal: type: - number - "null" totalDistanceM: type: - number - "null" avgHeartRate: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" maxHeartRate: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" minHeartRate: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" stepCount: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" elevationM: type: - number - "null" pauseDurationSec: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" source: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT externalId: type: - string - "null" externalSourceVersion: type: - string - "null" metadata: $ref: "#/components/schemas/__schema0" createdAt: type: string minLength: 1 updatedAt: type: string minLength: 1 required: - sportType - startedAt - endedAt - durationSec - source additionalProperties: {} documents: default: [] type: array items: type: object properties: id: type: string minLength: 1 kind: type: string enum: - DOCTOR_REPORT - DISCHARGE_LETTER - LAB_RESULT - IMAGING - PRESCRIPTION - REFERRAL - INSURANCE - VACCINATION - SICK_NOTE - OTHER title: type: - string - "null" filename: type: - string - "null" mimeType: type: string minLength: 1 byteSize: type: integer minimum: -9007199254740991 maximum: 9007199254740991 status: type: string enum: - STORED - EXTRACTING - EXTRACTED - FAILED - CONFIRMED - DISCARDED reportDate: type: - string - "null" documentDate: type: - string - "null" contentEncrypted: type: string minLength: 1 contentSha256: anyOf: - type: string pattern: ^[a-f0-9]{64}$ - type: "null" contentCodec: type: string minLength: 1 providerType: type: - string - "null" errorReason: type: - string - "null" summaryEncrypted: anyOf: - type: string minLength: 1 - type: "null" summaryGeneratedAt: anyOf: - type: string minLength: 1 - type: "null" summaryState: type: string enum: - NONE - PENDING - READY - WITHHELD - UNAVAILABLE lastIndexAttemptAt: anyOf: - type: string minLength: 1 - type: "null" lastIndexOutcome: type: - string - "null" sourceSystem: anyOf: - type: string enum: - PAPERLESS - PAPRA - OTHER - type: "null" sourceId: anyOf: - type: string maxLength: 128 - type: "null" sourceInstance: anyOf: - type: string maxLength: 512 - type: "null" aiReadDeferred: type: boolean extractionIncomplete: type: boolean summary: type: - string - "null" createdAt: type: string minLength: 1 updatedAt: type: string minLength: 1 required: - id - kind - mimeType - byteSize - status additionalProperties: {} documentConditionLinks: default: [] type: array items: type: object properties: documentId: type: string minLength: 1 episodeId: type: string minLength: 1 createdAt: type: string minLength: 1 required: - documentId - episodeId - createdAt additionalProperties: {} extractedFacts: default: [] type: array items: type: object properties: id: type: string minLength: 1 documentId: type: string minLength: 1 factType: type: string enum: - CONDITION - OBSERVATION - MEDICATION_STATEMENT status: type: string enum: - PENDING - APPROVED - REJECTED confidence: type: number needsReview: type: boolean committedRecordId: type: - string - "null" committedRecordType: type: - string - "null" dataEncrypted: type: string minLength: 1 provenanceEncrypted: type: string minLength: 1 data: $ref: "#/components/schemas/__schema1" provenance: $ref: "#/components/schemas/__schema2" createdAt: type: string minLength: 1 updatedAt: type: string minLength: 1 required: - id - documentId - factType - status - confidence - needsReview - createdAt - updatedAt additionalProperties: {} documentImportKeys: default: [] type: array items: type: object properties: sourceSystem: type: string enum: - PAPERLESS - PAPRA - OTHER sourceId: type: string minLength: 1 maxLength: 128 sourceInstance: anyOf: - type: string maxLength: 512 - type: "null" createdAt: type: string minLength: 1 required: - sourceSystem - sourceId additionalProperties: {} nutrientDays: default: [] type: array items: type: object properties: day: type: string minLength: 1 nutrient: type: string minLength: 1 amount: type: number unit: type: string minLength: 1 source: type: string minLength: 1 externalSourceVersion: type: - string - "null" createdAt: type: string minLength: 1 updatedAt: type: string minLength: 1 required: - day - nutrient - amount - unit - source additionalProperties: {} healthProfile: default: null anyOf: - type: object properties: id: type: string minLength: 1 aboutMe: default: null type: - string - "null" conditions: default: null type: - string - "null" allergies: default: null type: - string - "null" coachFocus: default: null type: - string - "null" aiIncludedSections: default: - ABOUT_ME - CONDITIONS - ALLERGIES - COACH_FOCUS - FAMILY_HISTORY - SMOKING_STATUS - ALCOHOL_PATTERN - SHIFT_SCHEDULE type: array items: type: string enum: - ABOUT_ME - CONDITIONS - ALLERGIES - COACH_FOCUS - FAMILY_HISTORY - SMOKING_STATUS - ALCOHOL_PATTERN - SHIFT_SCHEDULE - ACTIVITY - WORK - HABITS - GOALS lifeConfirmedJson: anyOf: - type: object propertyNames: type: string maxLength: 64 additionalProperties: type: string maxLength: 64 - type: "null" emergencyBloodType: default: null anyOf: - type: string enum: - A_POS - A_NEG - B_POS - B_NEG - AB_POS - AB_NEG - O_POS - O_NEG - UNKNOWN - type: "null" organDonorStatus: default: null anyOf: - type: string enum: - YES - NO - UNKNOWN - type: "null" advanceDirectiveStatus: default: null anyOf: - type: string enum: - EXISTS - NONE - UNKNOWN - type: "null" emergencyContacts: default: null type: - string - "null" emergencyImplants: default: null type: - string - "null" emergencyNote: default: null type: - string - "null" emergencyContactsEncrypted: anyOf: - type: string minLength: 1 - type: "null" emergencyImplantsEncrypted: anyOf: - type: string minLength: 1 - type: "null" emergencyNoteEncrypted: anyOf: - type: string minLength: 1 - type: "null" aboutMeEncrypted: anyOf: - type: string minLength: 1 - type: "null" conditionsEncrypted: anyOf: - type: string minLength: 1 - type: "null" allergiesEncrypted: anyOf: - type: string minLength: 1 - type: "null" coachFocusEncrypted: anyOf: - type: string minLength: 1 - type: "null" pendingQuestionsEncrypted: anyOf: - type: string minLength: 1 - type: "null" createdAt: type: string minLength: 1 updatedAt: type: string minLength: 1 required: - aboutMe - conditions - allergies - coachFocus - aiIncludedSections - emergencyBloodType - organDonorStatus - advanceDirectiveStatus - emergencyContacts - emergencyImplants - emergencyNote additionalProperties: {} - type: "null" healthProfileFacts: default: [] type: array items: type: object properties: id: type: string minLength: 1 kind: type: string enum: - SMOKING_STATUS - ALCOHOL_PATTERN - SHIFT_SCHEDULE - TOBACCO_DETAIL - ACTIVITY_HABIT - WORK_CONTEXT - FREE_WEEKDAYS - HABITS - HEALTH_GOALS value: default: null type: - string - "null" valueEncrypted: type: string minLength: 1 validFrom: type: string minLength: 1 validUntil: anyOf: - type: string minLength: 1 - type: "null" provenance: type: string enum: - USER_REPORTED - USER_CORRECTION supersededByRevisionId: anyOf: - type: string minLength: 1 - type: "null" createdAt: type: string minLength: 1 required: - id - kind - value - validFrom - validUntil - provenance - supersededByRevisionId - createdAt additionalProperties: {} customMetrics: default: [] type: array items: type: object properties: id: type: string minLength: 1 name: type: string minLength: 1 unit: type: string minLength: 1 targetLow: default: null type: - number - "null" targetHigh: default: null type: - number - "null" decimals: default: null anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" description: default: null type: - string - "null" correlationEnabled: default: false type: boolean createdAt: type: string minLength: 1 updatedAt: type: string minLength: 1 deletedAt: anyOf: - type: string minLength: 1 - type: "null" entries: default: [] type: array items: type: object properties: id: type: string minLength: 1 value: type: number unit: type: string minLength: 1 measuredAt: type: string minLength: 1 note: default: null type: - string - "null" noteEncrypted: anyOf: - type: string minLength: 1 - type: "null" createdAt: type: string minLength: 1 deletedAt: anyOf: - type: string minLength: 1 - type: "null" required: - value - unit - measuredAt - note additionalProperties: {} required: - name - unit - targetLow - targetHigh - decimals - description - correlationEnabled - entries additionalProperties: {} correlationPatterns: default: [] type: array items: type: object properties: id: type: string minLength: 1 canonicalKey: type: string pattern: ^p1:[a-f0-9]{64}$ family: type: string minLength: 1 factorKey: type: string minLength: 1 outcomeKey: type: string minLength: 1 lagDays: type: integer minimum: 0 maximum: 9007199254740991 sampleSize: type: integer exclusiveMinimum: 0 maximum: 9007199254740991 effectSize: type: number pValue: type: number minimum: 0 maximum: 1 qValue: default: null anyOf: - type: number minimum: 0 maximum: 1 - type: "null" evidenceHash: type: string pattern: ^[a-f0-9]{64}$ isCurrent: type: boolean lastComputedAt: type: string minLength: 1 dismissedAt: default: null anyOf: - type: string minLength: 1 - type: "null" dismissedEvidenceHash: default: null anyOf: - type: string pattern: ^[a-f0-9]{64}$ - type: "null" dismissedEffectSize: default: null type: - number - "null" dismissedSampleSize: default: null anyOf: - type: integer exclusiveMinimum: 0 maximum: 9007199254740991 - type: "null" createdAt: type: string minLength: 1 updatedAt: type: string minLength: 1 required: - canonicalKey - family - factorKey - outcomeKey - lagDays - sampleSize - effectSize - pValue - qValue - evidenceHash - isCurrent - lastComputedAt - dismissedAt - dismissedEvidenceHash - dismissedEffectSize - dismissedSampleSize additionalProperties: {} intradayProfiles: default: [] type: array items: type: object properties: id: type: string minLength: 1 type: type: string enum: - WEIGHT - BLOOD_PRESSURE_SYS - BLOOD_PRESSURE_DIA - PULSE - BODY_FAT - SLEEP_DURATION - ACTIVITY_STEPS - BLOOD_GLUCOSE - TOTAL_BODY_WATER - BONE_MASS - OXYGEN_SATURATION - HEART_RATE_VARIABILITY - RESTING_HEART_RATE - ACTIVE_ENERGY_BURNED - FLIGHTS_CLIMBED - WALKING_RUNNING_DISTANCE - VO2_MAX - BODY_TEMPERATURE - FAT_FREE_MASS - FAT_MASS - MUSCLE_MASS - SKIN_TEMPERATURE - PULSE_WAVE_VELOCITY - VASCULAR_AGE - VISCERAL_FAT - AUDIO_EXPOSURE_ENV - AUDIO_EXPOSURE_HEADPHONE - TIME_IN_DAYLIGHT - WALKING_STEADINESS - AUDIO_EXPOSURE_EVENT - RESPIRATORY_RATE - BODY_MASS_INDEX - LEAN_BODY_MASS - WALKING_HEART_RATE_AVERAGE - WALKING_ASYMMETRY - WALKING_DOUBLE_SUPPORT - WALKING_STEP_LENGTH - WALKING_SPEED - CARDIO_RECOVERY - WRIST_TEMPERATURE - FALL_COUNT - SIX_MINUTE_WALK_DISTANCE - STAIR_ASCENT_SPEED - STAIR_DESCENT_SPEED - BREATHING_DISTURBANCES - IRREGULAR_RHYTHM_NOTIFICATION - HIGH_HEART_RATE_EVENT - LOW_HEART_RATE_EVENT - WALKING_STEADINESS_EVENT - BREATHING_DISTURBANCE_EVENT - RECOVERY_SCORE - STRESS_SCORE - STRAIN_SCORE - HRV_RMSSD - DAY_STRAIN - WORKOUT_STRAIN - SLEEP_PERFORMANCE - SLEEP_EFFICIENCY - SLEEP_CONSISTENCY - SLEEP_NEED - ENERGY_EXPENDITURE_KJ - AVERAGE_HEART_RATE - MAX_HEART_RATE - SLEEP_DISTURBANCE_COUNT - ANS_CHARGE - CARDIO_LOAD - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION - RESILIENCE - PHQ9_SCORE - GAD7_SCORE - GRIP_STRENGTH - PAIN_NRS - WAIST_CIRCUMFERENCE - WAIST_TO_HEIGHT - WHO5_SCORE - SCI_SCORE - EXERCISE_MINUTES - STAND_MINUTES - STAND_HOURS - MINDFUL_MINUTES - STATE_OF_MIND_VALENCE - INDOOR_CO2 - INDOOR_TEMPERATURE - INDOOR_HUMIDITY - AFIB_BURDEN dateKey: type: string pattern: ^\d{4}-\d{2}-\d{2}$ hourlyCumulative: type: array items: type: number dayTotal: type: number sampleCount: type: integer minimum: -9007199254740991 maximum: 9007199254740991 hourlyIncrements: default: [] type: array items: type: number basis: default: PER_SAMPLE type: string enum: - PER_SAMPLE - HOUR_BUCKETS bucketCount: default: null anyOf: - type: integer minimum: 0 maximum: 9007199254740991 - type: "null" timezone: type: string minLength: 1 createdAt: type: string minLength: 1 updatedAt: type: string minLength: 1 required: - type - dateKey - hourlyCumulative - dayTotal - sampleCount - hourlyIncrements - basis - bucketCount - timezone additionalProperties: {} healthScoreRecords: default: [] type: array items: type: object properties: id: type: string minLength: 1 dayKey: type: string pattern: ^\d{4}-\d{2}-\d{2}$ timezone: type: string minLength: 1 composite: type: integer minimum: 0 maximum: 100 band: type: string enum: - green - yellow - red scoreVersion: type: integer minimum: -9007199254740991 maximum: 9007199254740991 composition: minItems: 1 type: array items: type: string minLength: 1 pillarScores: type: object propertyNames: type: string additionalProperties: type: number inputFingerprint: type: string pattern: ^[0-9a-f]{64}$ configVersion: default: null anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" configChangedAt: default: null anyOf: - type: string minLength: 1 - type: "null" computedAt: type: string minLength: 1 createdAt: type: string minLength: 1 required: - dayKey - timezone - composite - band - scoreVersion - composition - pillarScores - inputFingerprint - configVersion - configChangedAt - computedAt additionalProperties: {} onboardingRecord: default: null anyOf: - type: object properties: id: type: string minLength: 1 needs: default: {} type: object propertyNames: type: string additionalProperties: {} steps: default: [] type: array items: type: object propertyNames: type: string additionalProperties: {} firstResult: default: null anyOf: - type: object propertyNames: type: string additionalProperties: {} - type: "null" modulesDerivedAt: default: null anyOf: - type: string minLength: 1 - type: "null" completedAt: default: null anyOf: - type: string minLength: 1 - type: "null" createdAt: type: string minLength: 1 updatedAt: type: string minLength: 1 required: - needs - steps - firstResult - modulesDerivedAt - completedAt additionalProperties: {} - type: "null" practitioners: default: [] type: array items: type: object properties: id: type: string minLength: 1 name: type: string minLength: 1 specialty: type: - string - "null" practice: type: - string - "null" location: type: - string - "null" phone: type: - string - "null" locationEncrypted: anyOf: - type: string minLength: 1 - type: "null" phoneEncrypted: anyOf: - type: string minLength: 1 - type: "null" noteEncrypted: anyOf: - type: string minLength: 1 - type: "null" note: type: - string - "null" createdAt: type: string minLength: 1 updatedAt: type: string minLength: 1 deletedAt: anyOf: - type: string minLength: 1 - type: "null" required: - id - name - createdAt - updatedAt additionalProperties: {} encounters: default: [] type: array items: type: object properties: id: type: string minLength: 1 occurredAt: type: string minLength: 1 status: type: string enum: - PLANNED - DONE - CANCELLED - NO_SHOW kind: type: string enum: - ROUTINE - ACUTE - SPECIALIST - PREVENTIVE - EMERGENCY - HOSPITAL - THERAPY - OTHER - PROCEDURE practitionerId: type: - string - "null" reasonEncrypted: anyOf: - type: string minLength: 1 - type: "null" outcomeEncrypted: anyOf: - type: string minLength: 1 - type: "null" bodySiteEncrypted: anyOf: - type: string minLength: 1 - type: "null" reason: type: - string - "null" outcome: type: - string - "null" bodySite: type: - string - "null" laterality: anyOf: - type: string enum: - LEFT - RIGHT - BOTH - type: "null" reminderId: type: - string - "null" createdAt: type: string minLength: 1 updatedAt: type: string minLength: 1 deletedAt: anyOf: - type: string minLength: 1 - type: "null" required: - id - occurredAt - status - kind - createdAt - updatedAt additionalProperties: {} encounterDocumentLinks: default: [] type: array items: type: object properties: encounterId: type: string minLength: 1 targetId: type: string minLength: 1 createdAt: type: string minLength: 1 required: - encounterId - targetId - createdAt additionalProperties: {} encounterLabLinks: default: [] type: array items: type: object properties: encounterId: type: string minLength: 1 targetId: type: string minLength: 1 createdAt: type: string minLength: 1 required: - encounterId - targetId - createdAt additionalProperties: {} encounterConditionLinks: default: [] type: array items: type: object properties: encounterId: type: string minLength: 1 targetId: type: string minLength: 1 createdAt: type: string minLength: 1 required: - encounterId - targetId - createdAt additionalProperties: {} customVaccines: default: [] type: array items: type: object properties: id: type: string minLength: 1 name: type: string minLength: 1 components: type: array items: type: string typicalSeriesDoses: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" boosterIntervalMonths: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" createdAt: type: string minLength: 1 updatedAt: type: string minLength: 1 deletedAt: anyOf: - type: string minLength: 1 - type: "null" required: - id - name - createdAt - updatedAt additionalProperties: {} vaccinations: default: [] type: array items: type: object properties: id: type: string minLength: 1 occurredAt: type: string minLength: 1 antigenSlug: type: - string - "null" vaccineName: type: - string - "null" doseNumber: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" seriesDoses: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" lotNumber: type: - string - "null" site: anyOf: - type: string enum: - LEFT_ARM - RIGHT_ARM - LEFT_THIGH - RIGHT_THIGH - ORAL - NASAL - OTHER - type: "null" practitionerId: type: - string - "null" encounterId: type: - string - "null" reminderId: type: - string - "null" customVaccineId: type: - string - "null" noteEncrypted: anyOf: - type: string minLength: 1 - type: "null" note: type: - string - "null" createdAt: type: string minLength: 1 updatedAt: type: string minLength: 1 deletedAt: anyOf: - type: string minLength: 1 - type: "null" required: - id - occurredAt - createdAt - updatedAt additionalProperties: {} vaccinationDocumentLinks: default: [] type: array items: type: object properties: vaccinationId: type: string minLength: 1 targetId: type: string minLength: 1 createdAt: type: string minLength: 1 required: - vaccinationId - targetId - createdAt additionalProperties: {} labResultDocumentLinks: default: [] type: array items: type: object properties: labResultId: type: string minLength: 1 documentId: type: string minLength: 1 createdAt: type: string minLength: 1 required: - labResultId - documentId - createdAt additionalProperties: {} encounterMedicationLinks: default: [] type: array items: type: object properties: encounterId: type: string minLength: 1 medicationId: type: string minLength: 1 createdAt: type: string minLength: 1 required: - encounterId - medicationId - createdAt additionalProperties: {} encounterVaccinationLinks: default: [] type: array items: type: object properties: encounterId: type: string minLength: 1 vaccinationId: type: string minLength: 1 createdAt: type: string minLength: 1 required: - encounterId - vaccinationId - createdAt additionalProperties: {} medicationDocumentLinks: default: [] type: array items: type: object properties: medicationId: type: string minLength: 1 documentId: type: string minLength: 1 createdAt: type: string minLength: 1 required: - medicationId - documentId - createdAt additionalProperties: {} medicationConditionLinks: default: [] type: array items: type: object properties: medicationId: type: string minLength: 1 episodeId: type: string minLength: 1 createdAt: type: string minLength: 1 required: - medicationId - episodeId - createdAt additionalProperties: {} coachConversations: default: [] type: array items: type: object properties: id: type: string minLength: 1 title: type: string titleEncrypted: type: string minLength: 1 documentScoped: type: boolean summaryEncrypted: anyOf: - type: string minLength: 1 - type: "null" summary: type: - string - "null" summaryUpdatedAt: anyOf: - type: string minLength: 1 - type: "null" summaryTurnCount: type: integer minimum: 0 maximum: 9007199254740991 createdAt: type: string minLength: 1 updatedAt: type: string minLength: 1 messages: default: [] type: array items: type: object properties: id: type: string minLength: 1 role: type: string minLength: 1 contentEncrypted: type: string minLength: 1 content: type: string metricSourceJson: type: - string - "null" providerType: type: - string - "null" promptVersion: type: - string - "null" tokensUsed: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" model: type: - string - "null" resultsEncrypted: anyOf: - type: string minLength: 1 - type: "null" resultsJson: type: - string - "null" trailEncrypted: anyOf: - type: string minLength: 1 - type: "null" trailJson: type: - string - "null" createdAt: type: string minLength: 1 required: - id - role - createdAt additionalProperties: {} attachments: default: [] type: array items: type: object properties: documentId: type: string minLength: 1 addedAt: type: string minLength: 1 required: - documentId - addedAt additionalProperties: {} required: - id - documentScoped - createdAt - updatedAt - messages - attachments additionalProperties: {} mentalHealthAssessments: default: [] type: array items: type: object properties: id: type: string minLength: 1 instrument: type: string enum: - PHQ9 - GAD7 - WHO5 - SCI - AUDIT_C locale: type: string minLength: 1 wording: anyOf: - type: string enum: - validated - unvalidated_translation - original_en - type: "null" version: type: string responsesEncrypted: type: string minLength: 1 totalScore: type: integer minimum: -9007199254740991 maximum: 9007199254740991 severityBand: type: string minLength: 1 item9Flagged: type: boolean crisisShownAt: anyOf: - type: string minLength: 1 - type: "null" takenAt: type: string minLength: 1 tz: type: - string - "null" source: type: string externalId: type: - string - "null" syncVersion: type: integer minimum: -9007199254740991 maximum: 9007199254740991 deletedAt: anyOf: - type: string minLength: 1 - type: "null" createdAt: type: string minLength: 1 updatedAt: type: string minLength: 1 required: - id - instrument - locale - responsesEncrypted - totalScore - severityBand - takenAt - createdAt - updatedAt additionalProperties: {} consentReceipts: default: [] type: array items: type: object properties: id: type: string minLength: 1 kind: type: string minLength: 1 artefact: type: string minLength: 1 signedAt: type: string minLength: 1 revokedAt: anyOf: - type: string minLength: 1 - type: "null" createdAt: type: string minLength: 1 required: - id - kind - artefact - signedAt additionalProperties: {} coachFacts: default: [] type: array items: type: object properties: id: type: string minLength: 1 factEncrypted: type: string minLength: 1 fact: type: string category: type: string minLength: 1 confidence: type: integer minimum: 0 maximum: 100 sourceConversationId: type: - string - "null" source: type: string minLength: 1 maxLength: 32 lastUsedAt: anyOf: - type: string minLength: 1 - type: "null" sourceMessageId: type: - string - "null" createdAt: type: string minLength: 1 updatedAt: type: string minLength: 1 deletedAt: anyOf: - type: string minLength: 1 - type: "null" required: - id - category - createdAt - updatedAt additionalProperties: {} coachPlans: default: [] type: array items: type: object properties: id: type: string minLength: 1 metric: type: string minLength: 1 ifCueEncrypted: type: string minLength: 1 ifCue: type: string thenActionEncrypted: type: string minLength: 1 thenAction: type: string targetEncrypted: anyOf: - type: string minLength: 1 - type: "null" target: type: - string - "null" outcomeEncrypted: anyOf: - type: string minLength: 1 - type: "null" outcome: type: - string - "null" status: type: string reviewDate: anyOf: - type: string minLength: 1 - type: "null" sourceConversationId: type: - string - "null" createdAt: type: string minLength: 1 updatedAt: type: string minLength: 1 deletedAt: anyOf: - type: string minLength: 1 - type: "null" required: - id - metric - createdAt - updatedAt additionalProperties: {} coachReminders: default: [] type: array items: type: object properties: id: type: string minLength: 1 noteEncrypted: type: string minLength: 1 note: type: string metric: type: - string - "null" relatedPlanId: type: - string - "null" triggerKind: type: string dueAt: anyOf: - type: string minLength: 1 - type: "null" contextCue: type: - string - "null" status: type: string source: type: string minLength: 1 sourceConversationId: type: - string - "null" lastSurfacedAt: anyOf: - type: string minLength: 1 - type: "null" surfaceCount: type: integer minimum: 0 maximum: 9007199254740991 createdAt: type: string minLength: 1 updatedAt: type: string minLength: 1 deletedAt: anyOf: - type: string minLength: 1 - type: "null" required: - id - source - createdAt - updatedAt additionalProperties: {} measurementReminders: default: [] type: array items: type: object properties: id: type: string minLength: 1 label: type: string minLength: 1 measurementType: anyOf: - type: string enum: - WEIGHT - BLOOD_PRESSURE_SYS - BLOOD_PRESSURE_DIA - PULSE - BODY_FAT - SLEEP_DURATION - ACTIVITY_STEPS - BLOOD_GLUCOSE - TOTAL_BODY_WATER - BONE_MASS - OXYGEN_SATURATION - HEART_RATE_VARIABILITY - RESTING_HEART_RATE - ACTIVE_ENERGY_BURNED - FLIGHTS_CLIMBED - WALKING_RUNNING_DISTANCE - VO2_MAX - BODY_TEMPERATURE - FAT_FREE_MASS - FAT_MASS - MUSCLE_MASS - SKIN_TEMPERATURE - PULSE_WAVE_VELOCITY - VASCULAR_AGE - VISCERAL_FAT - AUDIO_EXPOSURE_ENV - AUDIO_EXPOSURE_HEADPHONE - TIME_IN_DAYLIGHT - WALKING_STEADINESS - AUDIO_EXPOSURE_EVENT - RESPIRATORY_RATE - BODY_MASS_INDEX - LEAN_BODY_MASS - WALKING_HEART_RATE_AVERAGE - WALKING_ASYMMETRY - WALKING_DOUBLE_SUPPORT - WALKING_STEP_LENGTH - WALKING_SPEED - CARDIO_RECOVERY - WRIST_TEMPERATURE - FALL_COUNT - SIX_MINUTE_WALK_DISTANCE - STAIR_ASCENT_SPEED - STAIR_DESCENT_SPEED - BREATHING_DISTURBANCES - IRREGULAR_RHYTHM_NOTIFICATION - HIGH_HEART_RATE_EVENT - LOW_HEART_RATE_EVENT - WALKING_STEADINESS_EVENT - BREATHING_DISTURBANCE_EVENT - RECOVERY_SCORE - STRESS_SCORE - STRAIN_SCORE - HRV_RMSSD - DAY_STRAIN - WORKOUT_STRAIN - SLEEP_PERFORMANCE - SLEEP_EFFICIENCY - SLEEP_CONSISTENCY - SLEEP_NEED - ENERGY_EXPENDITURE_KJ - AVERAGE_HEART_RATE - MAX_HEART_RATE - SLEEP_DISTURBANCE_COUNT - ANS_CHARGE - CARDIO_LOAD - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION - RESILIENCE - PHQ9_SCORE - GAD7_SCORE - GRIP_STRENGTH - PAIN_NRS - WAIST_CIRCUMFERENCE - WAIST_TO_HEIGHT - WHO5_SCORE - SCI_SCORE - EXERCISE_MINUTES - STAND_MINUTES - STAND_HOURS - MINDFUL_MINUTES - STATE_OF_MIND_VALENCE - INDOOR_CO2 - INDOOR_TEMPERATURE - INDOOR_HUMIDITY - AFIB_BURDEN - type: "null" intervalDays: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" rrule: type: - string - "null" anchorDate: anyOf: - type: string minLength: 1 - type: "null" endsOn: anyOf: - type: string minLength: 1 - type: "null" origin: type: string enum: - VORSORGE - COACH - ENCOUNTER notifyHour: type: integer minimum: 0 maximum: 23 location: type: - string - "null" nextDueAt: anyOf: - type: string minLength: 1 - type: "null" lastSatisfiedAt: anyOf: - type: string minLength: 1 - type: "null" enabled: type: boolean vaccinationAntigen: type: - string - "null" snoozedUntil: anyOf: - type: string minLength: 1 - type: "null" lastSkippedAt: anyOf: - type: string minLength: 1 - type: "null" skipCount: type: integer minimum: 0 maximum: 9007199254740991 createdAt: type: string minLength: 1 updatedAt: type: string minLength: 1 deletedAt: anyOf: - type: string minLength: 1 - type: "null" required: - id - label - createdAt - updatedAt additionalProperties: {} measurementReminderEvents: default: [] type: array items: type: object properties: id: type: string minLength: 1 reminderId: type: string minLength: 1 kind: type: string enum: - SATISFIED - SKIPPED occurredAt: type: string minLength: 1 onTime: type: boolean source: type: string enum: - manual - auto_measurement - auto_lab - telegram - vaccination - encounter - skip createdAt: type: string minLength: 1 required: - id - reminderId - kind - occurredAt - onTime - source - createdAt additionalProperties: {} personalRecords: default: [] type: array items: type: object properties: metricType: type: string enum: - WEIGHT - BLOOD_PRESSURE_SYS - BLOOD_PRESSURE_DIA - PULSE - BODY_FAT - SLEEP_DURATION - ACTIVITY_STEPS - BLOOD_GLUCOSE - TOTAL_BODY_WATER - BONE_MASS - OXYGEN_SATURATION - HEART_RATE_VARIABILITY - RESTING_HEART_RATE - ACTIVE_ENERGY_BURNED - FLIGHTS_CLIMBED - WALKING_RUNNING_DISTANCE - VO2_MAX - BODY_TEMPERATURE - FAT_FREE_MASS - FAT_MASS - MUSCLE_MASS - SKIN_TEMPERATURE - PULSE_WAVE_VELOCITY - VASCULAR_AGE - VISCERAL_FAT - AUDIO_EXPOSURE_ENV - AUDIO_EXPOSURE_HEADPHONE - TIME_IN_DAYLIGHT - WALKING_STEADINESS - AUDIO_EXPOSURE_EVENT - RESPIRATORY_RATE - BODY_MASS_INDEX - LEAN_BODY_MASS - WALKING_HEART_RATE_AVERAGE - WALKING_ASYMMETRY - WALKING_DOUBLE_SUPPORT - WALKING_STEP_LENGTH - WALKING_SPEED - CARDIO_RECOVERY - WRIST_TEMPERATURE - FALL_COUNT - SIX_MINUTE_WALK_DISTANCE - STAIR_ASCENT_SPEED - STAIR_DESCENT_SPEED - BREATHING_DISTURBANCES - IRREGULAR_RHYTHM_NOTIFICATION - HIGH_HEART_RATE_EVENT - LOW_HEART_RATE_EVENT - WALKING_STEADINESS_EVENT - BREATHING_DISTURBANCE_EVENT - RECOVERY_SCORE - STRESS_SCORE - STRAIN_SCORE - HRV_RMSSD - DAY_STRAIN - WORKOUT_STRAIN - SLEEP_PERFORMANCE - SLEEP_EFFICIENCY - SLEEP_CONSISTENCY - SLEEP_NEED - ENERGY_EXPENDITURE_KJ - AVERAGE_HEART_RATE - MAX_HEART_RATE - SLEEP_DISTURBANCE_COUNT - ANS_CHARGE - CARDIO_LOAD - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION - RESILIENCE - PHQ9_SCORE - GAD7_SCORE - GRIP_STRENGTH - PAIN_NRS - WAIST_CIRCUMFERENCE - WAIST_TO_HEIGHT - WHO5_SCORE - SCI_SCORE - EXERCISE_MINUTES - STAND_MINUTES - STAND_HOURS - MINDFUL_MINUTES - STATE_OF_MIND_VALENCE - INDOOR_CO2 - INDOOR_TEMPERATURE - INDOOR_HUMIDITY - AFIB_BURDEN metricSlot: type: - string - "null" direction: type: string enum: - MAX - MIN value: type: number unit: type: string minLength: 1 achievedAt: type: string minLength: 1 sourceMeasurementId: type: - string - "null" source: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT externalId: type: - string - "null" createdAt: type: string minLength: 1 required: - metricType - direction - value - unit - achievedAt additionalProperties: {} userAchievements: default: [] type: array items: type: object properties: achievementId: type: string minLength: 1 unlockedAt: type: string minLength: 1 createdAt: type: string minLength: 1 required: - achievementId - unlockedAt additionalProperties: {} environmentContexts: default: [] type: array items: type: object properties: date: type: string pattern: ^\d{4}-\d{2}-\d{2}$ lat: type: - number - "null" lon: type: - number - "null" locationLabel: type: - string - "null" locationEncrypted: anyOf: - type: string minLength: 1 - type: "null" source: type: string enum: - HOME - TRAVEL - DEVICE tempMin: type: - number - "null" tempMax: type: - number - "null" tempMean: type: - number - "null" apparentMean: type: - number - "null" sunshineSec: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" daylightSec: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" precipSum: type: - number - "null" pressureMean: type: - number - "null" pressureDelta: type: - number - "null" humidityMean: type: - number - "null" cloudMean: type: - number - "null" weatherCode: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" apparentMax: type: - number - "null" pm25Mean: type: - number - "null" pm25Max: type: - number - "null" pm10Mean: type: - number - "null" no2Mean: type: - number - "null" so2Mean: type: - number - "null" coMean: type: - number - "null" o3Max8h: type: - number - "null" eaqiMax: type: - number - "null" usaqiMax: type: - number - "null" uvIndexMax: type: - number - "null" dustMax: type: - number - "null" aodMax: type: - number - "null" pollenAlderMax: type: - number - "null" pollenBirchMax: type: - number - "null" pollenGrassMax: type: - number - "null" pollenMugwortMax: type: - number - "null" pollenOliveMax: type: - number - "null" pollenRagweedMax: type: - number - "null" aqDomain: type: - string - "null" aqHours: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" aqFetchedAt: anyOf: - type: string minLength: 1 - type: "null" fetchedAt: type: string minLength: 1 createdAt: type: string minLength: 1 updatedAt: type: string minLength: 1 required: - date - lat - lon - locationLabel - source additionalProperties: {} environmentTravelLocations: default: [] type: array items: type: object properties: startDate: type: string pattern: ^\d{4}-\d{2}-\d{2}$ endDate: type: string pattern: ^\d{4}-\d{2}-\d{2}$ lat: type: - number - "null" lon: type: - number - "null" label: type: - string - "null" locationEncrypted: anyOf: - type: string minLength: 1 - type: "null" createdAt: type: string minLength: 1 updatedAt: type: string minLength: 1 required: - startDate - endDate - lat - lon - label additionalProperties: {} dawarichDays: default: [] type: array items: type: object properties: date: type: string pattern: ^\d{4}-\d{2}-\d{2}$ totalDistanceM: anyOf: - type: integer minimum: 0 maximum: 9007199254740991 - type: "null" drivingDistanceM: anyOf: - type: integer minimum: 0 maximum: 9007199254740991 - type: "null" trainDistanceM: anyOf: - type: integer minimum: 0 maximum: 9007199254740991 - type: "null" placesVisited: anyOf: - type: integer minimum: 0 maximum: 9007199254740991 - type: "null" awayFromHomeMinutes: anyOf: - type: integer minimum: 0 maximum: 1500 - type: "null" countryCode: anyOf: - type: string pattern: ^[A-Z]{2}$ - type: "null" countryCodeEncrypted: anyOf: - type: string minLength: 1 - type: "null" isTravelDay: type: - boolean - "null" fetchedAt: type: string minLength: 1 createdAt: type: string minLength: 1 updatedAt: type: string minLength: 1 required: - date additionalProperties: {} ecgRecordings: default: [] type: array items: type: object properties: id: type: string minLength: 1 source: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT externalRecordingId: type: string minLength: 1 recordedAt: type: string minLength: 1 waveformEncrypted: type: string minLength: 1 waveform: type: array items: type: number samplingFrequency: type: integer minimum: -9007199254740991 maximum: 9007199254740991 sampleCount: type: integer minimum: -9007199254740991 maximum: 9007199254740991 durationSeconds: type: - number - "null" lead: type: - string - "null" averageHeartRate: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" rhythmClassification: anyOf: - type: string enum: - IRREGULAR - NOT_DETECTED - INCONCLUSIVE - LOW - VERY_LOW - FIRED - type: "null" measurementId: anyOf: - type: string minLength: 1 - type: "null" createdAt: type: string minLength: 1 updatedAt: type: string minLength: 1 required: - source - externalRecordingId - recordedAt - samplingFrequency - sampleCount - createdAt - updatedAt additionalProperties: {} manifest: default: null anyOf: - type: object properties: documents: type: object properties: included: type: string minLength: 1 note: type: string minLength: 1 required: - included - note additionalProperties: {} workouts: type: object properties: included: type: string minLength: 1 note: type: string minLength: 1 required: - included - note additionalProperties: {} required: - documents - workouts additionalProperties: {} - type: "null" decryptFailures: default: [] type: array items: type: string required: - schemaVersion - exportedAt - userId - appSettings - accountSettings - measurements - medications - customMedicationCategories - intakeEvents - moodEntries - cycleProfile - cycles - cycleDayLogs - customSymptoms - customMoodTagCategories - hiddenMoodTags - customMoodTags - labResults - biomarkers - biomarkerMerges - illnessEpisodes - symptomDefinitions - lifeEvents - allergies - familyHistory - workouts - documents - documentConditionLinks - extractedFacts - documentImportKeys - nutrientDays - healthProfile - healthProfileFacts - customMetrics - correlationPatterns - intradayProfiles - healthScoreRecords - onboardingRecord - practitioners - encounters - encounterDocumentLinks - encounterLabLinks - encounterConditionLinks - customVaccines - vaccinations - vaccinationDocumentLinks - labResultDocumentLinks - encounterMedicationLinks - encounterVaccinationLinks - medicationDocumentLinks - medicationConditionLinks - coachConversations - mentalHealthAssessments - consentReceipts - coachFacts - coachPlans - coachReminders - measurementReminders - measurementReminderEvents - personalRecords - userAchievements - environmentContexts - environmentTravelLocations - dawarichDays - ecgRecordings - manifest - decryptFailures additionalProperties: {} description: "The canonical restore-compatible backup, in its PORTABLE form. Every section a user's own record holds rides it, and the `manifest` key declares what does not. Portable specifics: soft-deleted rows are dropped rather than carried as tombstones; encrypted columns arrive DECRYPTED as plain text, so the file is readable by whoever holds it and readable by an instance that does not hold this one's key; row ids are omitted for the sections that only carry them in a disaster-recovery file. Three things are omitted outright and say so in `manifest`: original uploaded document BYTES (metadata and any generated summary ride, the files do not — download them individually), workout GPS routes and per-sample heart-rate / pace series (summary records ride), and the sensitive pair — completed WHO-5 / PHQ / GAD administrations, whose encrypted per-item answers include the PHQ-9 self-harm item, and consent receipts, which are given to ONE operator and would assert elsewhere an agreement that operator never obtained. Both of the sensitive sections arrive as empty arrays with their `manifest` entry set to `omitted`, so their absence is stated rather than inferred. Beyond the declared omissions there is a real, undeclared gap: roughly thirty of the models the backup plan marks as backed up have neither a reader here nor a restore branch yet, and `src/lib/export/backup-plan.ts` is the enumeration of which. `schemaVersion` is `2` on everything written today; the parser still accepts `1`." ProfileClaimSsoEnvelope: type: object properties: data: type: object properties: redirectTo: type: string required: - redirectTo additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false ProfileClaimPreviewEnvelope: type: object properties: data: $ref: "#/components/schemas/ProfileClaimPreview" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false ProfileClaimPreview: type: object properties: displayName: description: The profile's name, as its guardians set it. type: - string - "null" expiresAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ guardians: type: array items: type: object properties: displayName: description: "The guardian's display name; null when they set none. Never a login name or an id: the holder of the link has proved nothing yet." type: - string - "null" proposal: $ref: "#/components/schemas/ProfileClaimGuardianAccess" required: - displayName - proposal additionalProperties: false description: Each guardian and the access they keep after the claim, as proposed. The new owner confirms or changes it after signing in. methods: description: "v1.43 — how this link can be redeemed: `password` and/or `sso`. Absent on older servers." type: array items: type: string enum: - password - sso ssoProviderName: description: v1.43 — the identity provider's display name for the "Continue with …" button; null without one. type: - string - "null" required: - displayName - expiresAt - guardians additionalProperties: false ProfileClaimGuardianAccess: type: string enum: - end - read - manage ProfileClaimEnvelope: type: object properties: data: type: object properties: userId: type: string username: type: string required: - userId - username additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false LoginResponse: type: object properties: data: $ref: "#/components/schemas/AccessRefreshBundle" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false AccessRefreshBundle: type: object properties: user: type: object properties: id: type: string username: type: string required: - id - username additionalProperties: false token: description: Access token (`hlk_<64hex>`); only present for native-policy callers. A browser caller gets the session cookie instead. type: string tokenExpiresAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ refreshToken: description: Refresh token (`hlr_<64hex>`); only present for native-policy callers. type: string refreshTokenExpiresAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - user additionalProperties: false description: Native-client token bundle returned by login + refresh. Web cookie-only callers see only `user`. MfaRequiredResponse: type: object properties: data: type: "null" error: type: "null" meta: type: object properties: mfaRequired: type: boolean const: true mfaTicket: type: string description: Opaque, single-use, ~5-minute ticket to present to /api/auth/mfa/verify. methods: type: array items: type: string enum: - totp - recovery - webauthn description: Second factors the account can complete the challenge with. `webauthn` is completed via /api/auth/mfa/webauthn/verify; the rest via /api/auth/mfa/verify. required: - mfaRequired - mfaTicket - methods additionalProperties: false required: - data - error - meta additionalProperties: false description: Password accepted but a second factor is required. Not an error and not a session — no token is issued until /api/auth/mfa/verify succeeds. MfaVerifyResponse: type: object properties: data: $ref: "#/components/schemas/AccessRefreshBundle" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false OidcNativeTokenResponse: type: object properties: data: $ref: "#/components/schemas/AccessRefreshBundle" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false NativeHandoffTokenResponse: type: object properties: data: $ref: "#/components/schemas/AccessRefreshBundle" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false StepUpOptionsEnvelope: type: object properties: data: $ref: "#/components/schemas/StepUpOptionsResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false StepUpOptionsResponse: type: object properties: options: type: object propertyNames: type: string additionalProperties: {} description: SimpleWebAuthn assertion options for the caller's passkeys. challengeId: type: string required: - options - challengeId additionalProperties: false StepUpMintEnvelope: type: object properties: data: $ref: "#/components/schemas/StepUpMintResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false StepUpMintResponse: type: object properties: elevation: type: string description: Opaque single-use elevation (`hle_<64hex>`). Present it in the `X-Step-Up` header on ONE second-factor-management call. Returned exactly once; store it in memory only. expiresAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ expiresInSeconds: type: integer minimum: -9007199254740991 maximum: 9007199254740991 method: type: string enum: - password - totp - webauthn - passkey description: The factor that was re-proved. satisfiesFreshFactor: type: boolean description: Whether this elevation reaches the fresh-factor routes (MFA disable, recovery-code regeneration, security-key removal). False for a password proof — mirroring the web, where a password login never marks a session second-factor-verified. required: - elevation - expiresAt - expiresInSeconds - method - satisfiesFreshFactor additionalProperties: false ReproofEnvelope: type: object properties: data: $ref: "#/components/schemas/ReproofResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false ReproofResponse: type: object properties: method: type: string enum: - password - totp - webauthn - passkey verifiedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z))$ required: - method - verifiedAt additionalProperties: false ReproofStatusEnvelope: type: object properties: data: $ref: "#/components/schemas/ReproofStatus" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false ReproofStatus: type: object properties: recent: type: boolean const: true required: - recent additionalProperties: false TotpSetupEnvelope: type: object properties: data: $ref: "#/components/schemas/TotpSetupResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false TotpSetupResponse: type: object properties: otpauthUri: type: string description: otpauth:// URI to render as a QR code (carries the secret). totpSecret: type: string description: Base32 secret for manual entry. Pending until confirmed. required: - otpauthUri - totpSecret additionalProperties: false TotpConfirmEnvelope: type: object properties: data: $ref: "#/components/schemas/MfaRecoveryCodesResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MfaRecoveryCodesResponse: type: object properties: enabled: type: boolean recoveryCodes: type: array items: type: string description: Single-use recovery codes, shown once. Save them now. recoveryCodesRemaining: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - recoveryCodes - recoveryCodesRemaining additionalProperties: false MfaDisableEnvelope: type: object properties: data: $ref: "#/components/schemas/MfaToggleResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MfaToggleResponse: type: object properties: enabled: type: boolean required: - enabled additionalProperties: false MfaRecoveryRegenEnvelope: type: object properties: data: $ref: "#/components/schemas/MfaRecoveryCodesResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MfaStatusEnvelope: type: object properties: data: $ref: "#/components/schemas/MfaStatusResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MfaStatusResponse: type: object properties: totp: type: object properties: enabled: type: boolean required: - enabled additionalProperties: false recoveryCodesRemaining: type: integer minimum: -9007199254740991 maximum: 9007199254740991 webauthn: type: array items: $ref: "#/components/schemas/WebauthnMfaCredentialInfo" passkeyNudgeDismissed: type: boolean required: - totp - recoveryCodesRemaining - webauthn - passkeyNudgeDismissed additionalProperties: false WebauthnMfaCredentialInfo: type: object properties: id: type: string name: type: string createdAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ lastUsedAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" required: - id - name - createdAt - lastUsedAt additionalProperties: false MfaWebauthnRegisterOptionsEnvelope: type: object properties: data: $ref: "#/components/schemas/WebauthnOptionsResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false WebauthnOptionsResponse: type: object properties: options: type: object propertyNames: type: string additionalProperties: {} description: SimpleWebAuthn options to pass to the browser ceremony. challengeId: type: string description: Server-issued challenge id. required: - options - challengeId additionalProperties: false MfaWebauthnRegisterVerifyEnvelope: type: object properties: data: $ref: "#/components/schemas/WebauthnMfaCredentialInfo" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MfaWebauthnRenameEnvelope: type: object properties: data: $ref: "#/components/schemas/WebauthnMfaCredentialInfo" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MfaWebauthnRemoveEnvelope: type: object properties: data: $ref: "#/components/schemas/AuthSuccessFlagResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false AuthSuccessFlagResponse: type: object properties: success: type: boolean required: - success additionalProperties: false MfaWebauthnLoginOptionsEnvelope: type: object properties: data: $ref: "#/components/schemas/WebauthnOptionsResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MfaWebauthnVerifyResponse: type: object properties: data: $ref: "#/components/schemas/AccessRefreshBundle" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false PasskeyLoginVerifyResponse: type: object properties: data: $ref: "#/components/schemas/AccessRefreshBundle" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false RefreshResponse: type: object properties: data: $ref: "#/components/schemas/AccessRefreshBundle" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false RefreshRevokeResponse: type: object properties: data: type: object properties: revoked: type: boolean required: - revoked additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false PasskeyListEnvelope: type: object properties: data: $ref: "#/components/schemas/PasskeyListResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false PasskeyListResponse: type: array items: $ref: "#/components/schemas/PasskeyInfo" PasskeyInfo: type: object properties: id: type: string description: Row id; the handle for DELETE. name: type: string description: User-supplied label, defaulting to “Passkey”. credentialDeviceType: type: string description: "Authenticator device type as reported at registration: `singleDevice` for a credential bound to one authenticator, `multiDevice` for a syncable one." credentialBackedUp: type: boolean description: Whether the authenticator reported the credential as backed up (synced to the platform keychain) at registration time. Not re-read afterwards. createdAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ description: Registered at. lastUsedAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" description: When this passkey last completed a verified assertion; null when it has never been used to sign in. Stamped on every successful login, so a client can render a real 'last used' rather than assuming never. required: - id - name - credentialDeviceType - credentialBackedUp - createdAt - lastUsedAt additionalProperties: false SessionListEnvelope: type: object properties: data: $ref: "#/components/schemas/SessionListResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false SessionListResponse: type: object properties: sessions: type: array items: type: object properties: id: type: string device: type: string description: Coarse device label derived from the User-Agent. ipMasked: description: IP with the host portion masked — never the full address. type: - string - "null" location: description: Resolved coarse location, when available. type: - string - "null" lastActiveAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" createdAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ isCurrent: type: boolean description: True for the session making this request. required: - id - device - ipMasked - location - lastActiveAt - createdAt - isCurrent additionalProperties: false required: - sessions additionalProperties: false SignOutEverywhereEnvelope: type: object properties: data: $ref: "#/components/schemas/SignOutEverywhereResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false SignOutEverywhereResponse: type: object properties: sessionsRevoked: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Number of OTHER sessions removed (the current one is kept). accessTokensRevoked: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: "Tokens revoked: the access tokens of other device logins plus every programmatic token (connector, measurement, document), never the caller's own." connectorsRevoked: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Connected AI-assistant (MCP OAuth) connections revoked. shareLinksRevoked: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Clinician share links revoked; 0 when `keepShareLinks=1` was sent. pendingInvitesRevoked: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: "Record-sharing invitations this account offered that nobody had accepted yet, now withdrawn (`revokedBy: GRANTOR`)." grantsKept: type: array items: type: object properties: id: type: string description: The grant id, for DELETE /api/account/grants/{id}. account: type: object properties: id: type: string username: type: string displayName: type: - string - "null" required: - id - username - displayName additionalProperties: false access: type: string enum: - READ - WRITE - MANAGE required: - id - account - access additionalProperties: false description: Accepted grants this account gave that are still live. Signing out does not end them, because each is a person the owner chose and ending one silently could cut off a carer; they are listed so a client can show who can still read the record and offer to end each with DELETE /api/account/grants/{id}. required: - sessionsRevoked - accessTokensRevoked - connectorsRevoked - shareLinksRevoked - pendingInvitesRevoked - grantsKept additionalProperties: false SessionRevokeEnvelope: type: object properties: data: type: object properties: revoked: type: boolean required: - revoked additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false TrustedDeviceListEnvelope: type: object properties: data: $ref: "#/components/schemas/TrustedDeviceListResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false TrustedDeviceListResponse: type: object properties: devices: type: array items: type: object properties: id: type: string label: description: Coarse, IP-free device label (e.g. 'Firefox on macOS'). type: - string - "null" createdAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ lastUsedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ expiresAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ isCurrent: type: boolean description: True for the device making this request. required: - id - label - createdAt - lastUsedAt - expiresAt - isCurrent additionalProperties: false required: - devices additionalProperties: false TrustedDeviceRevokeAllEnvelope: type: object properties: data: type: object properties: revoked: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - revoked additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false TrustedDeviceRevokeEnvelope: type: object properties: data: type: object properties: revoked: type: boolean required: - revoked additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false SecurityActivityEnvelope: type: object properties: data: $ref: "#/components/schemas/SecurityActivityResponse" error: type: "null" meta: $ref: "#/components/schemas/ListCapMeta" required: - data - error - meta additionalProperties: false SecurityActivityResponse: type: object properties: events: type: array items: type: object properties: action: type: string description: Audit action name, e.g. auth.login.password. createdAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ location: type: - string - "null" ipMasked: description: Host-masked IP. type: - string - "null" carrier: type: - string - "null" required: - action - createdAt - location - ipMasked - carrier additionalProperties: false required: - events additionalProperties: false LogoutEnvelope: type: object properties: data: $ref: "#/components/schemas/LogoutResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false LogoutResponse: type: object properties: loggedOut: type: boolean const: true description: "Always true. The endpoint has no failure the caller acts on: it answers the same whether a cookie was cleared, a token was revoked, or neither was there." required: - loggedOut additionalProperties: false OidcStatusEnvelope: type: object properties: data: $ref: "#/components/schemas/OidcStatusResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false OidcStatusResponse: type: object properties: enabled: type: boolean description: An OIDC provider is configured on this instance. buttonLabel: description: Operator-set label for the SSO button (`OIDC_BUTTON_LABEL`, defaulting to “Single Sign-On”). Null when no provider is configured. type: - string - "null" only: type: boolean description: "`OIDC_ONLY=true` — password and passkey login are refused with 403 `oidc_only`. A client that offers those controls anyway strands the user." required: - enabled - buttonLabel - only additionalProperties: false RegistrationStatusEnvelope: type: object properties: data: $ref: "#/components/schemas/RegistrationStatusResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false RegistrationStatusResponse: type: object properties: registrationEnabled: type: boolean description: Whether open self-registration is on. An operator who turned it off can still admit a signup through an invite token. sessionCookieSecure: type: boolean description: Whether this server issues its session cookies with the `Secure` flag (`SESSION_COOKIE_SECURE`, else on in production). A browser on a plain `http://` page cannot keep such a cookie; the web login page compares this with its own scheme before any credential is sent. required: - registrationEnabled - sessionCookieSecure additionalProperties: false PasskeyLoginOptionsEnvelope: type: object properties: data: $ref: "#/components/schemas/WebauthnOptionsResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false PasskeyRenameEnvelope: type: object properties: data: $ref: "#/components/schemas/PasskeyInfo" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false PasskeyRemoveEnvelope: type: object properties: data: $ref: "#/components/schemas/AuthSuccessFlagResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false ApiTokenListEnvelope: type: object properties: data: $ref: "#/components/schemas/ApiTokenListResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false ApiTokenListResponse: type: array items: $ref: "#/components/schemas/ApiTokenInfo" ApiTokenInfo: type: object properties: id: type: string description: Row id; the handle for DELETE. name: type: string permissions: type: array items: type: string description: The token's scope list. `["*"]` is cookie-equivalent (what a native login mints); anything else is narrow and reaches only the routes that name it. Admin endpoints are cookie-only and are reachable by no token at any scope. lastUsedAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" description: Stamped fire-and-forget on use; null for an unused token. expiresAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" createdAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ revoked: type: boolean description: Revoked tokens stay in the list rather than disappearing, so a client must filter on this rather than treat presence as validity. mirrorToAppleHealth: type: boolean description: "A measurement token's Apple Health setting: whether the HealthLog app writes the readings this token sent into Apple Health. Changed with `PATCH /api/tokens/{id}`; false on every other token." required: - id - name - permissions - lastUsedAt - expiresAt - createdAt - revoked - mirrorToAppleHealth additionalProperties: false MeasurementTokenSummaryEnvelope: type: object properties: data: $ref: "#/components/schemas/MeasurementTokenSummaryResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MeasurementTokenSummaryResponse: type: object properties: unattributedReadings: type: integer minimum: 0 maximum: 9007199254740991 description: "Live EXTERNAL readings that belong to no token: sent before readings were linked to their token, by an account that held more than one. No token's Apple Health setting reaches them." required: - unattributedReadings additionalProperties: false CreateMeasurementTokenEnvelope: type: object properties: data: $ref: "#/components/schemas/CreateMeasurementTokenResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false CreateMeasurementTokenResponse: type: object properties: token: type: string description: The raw `hlk_` Bearer, RETURNED EXACTLY ONCE. It is stored only as an HMAC-SHA256 hash, so there is no way to retrieve it later; a client that does not capture it here has to mint a replacement. Treat it as a secret in logs, crash reports and analytics. name: type: string expiresAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - token - name - expiresAt additionalProperties: false description: The one response that carries a usable measurement-ingest Bearer value. CreateWorkoutTokenEnvelope: type: object properties: data: $ref: "#/components/schemas/CreateWorkoutTokenResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false CreateWorkoutTokenResponse: type: object properties: token: type: string description: The raw `hlk_` Bearer, RETURNED EXACTLY ONCE. It is stored only as an HMAC-SHA256 hash and cannot be retrieved later. Treat it as a secret. name: type: string expiresAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - token - name - expiresAt additionalProperties: false description: The one response that carries a usable workout-ingest Bearer value. CreateDocumentTokenEnvelope: type: object properties: data: $ref: "#/components/schemas/CreateDocumentTokenResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false CreateDocumentTokenResponse: type: object properties: token: type: string description: The raw `hlk_` Bearer, RETURNED EXACTLY ONCE. It is stored only as an HMAC-SHA256 hash and cannot be retrieved later. Treat it as a secret. name: type: string expiresAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - token - name - expiresAt additionalProperties: false description: The one response that carries a usable document-upload Bearer value. ApiTokenRevokeEnvelope: type: object properties: data: type: object properties: revoked: type: boolean required: - revoked additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false UpdateMeasurementTokenEnvelope: type: object properties: data: $ref: "#/components/schemas/UpdateMeasurementTokenResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false UpdateMeasurementTokenResponse: type: object properties: id: type: string mirrorToAppleHealth: type: boolean required: - id - mirrorToAppleHealth additionalProperties: false RegisterEnvelope: type: object properties: data: $ref: "#/components/schemas/RegisteredUserResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false RegisteredUserResponse: type: object properties: user: type: object properties: id: type: string username: type: string email: type: - string - "null" required: - id - username - email additionalProperties: false required: - user additionalProperties: false description: The account that was created. No credential rides this response — the session arrives as a cookie, and a native client signs in afterwards through /api/auth/login. PasswordChangeEnvelope: type: object properties: data: $ref: "#/components/schemas/PasswordChangedResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false PasswordChangedResponse: type: object properties: changed: type: boolean const: true required: - changed additionalProperties: false AuthProfileUpdateEnvelope: type: object properties: data: $ref: "#/components/schemas/AuthProfileUpdateResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false AuthProfileUpdateResponse: type: object properties: id: type: string username: type: string email: type: - string - "null" role: type: string heightCm: type: - number - "null" dateOfBirth: type: - string - "null" gender: type: - string - "null" timezone: type: - string - "null" fullName: type: - string - "null" insurerName: type: - string - "null" insurerIkNumber: type: - string - "null" hasInsuranceNumber: type: boolean description: Whether an insurance number is stored. The number itself is encrypted at rest and is never returned — only its presence. rejectedFields: description: "Present only on a PARTIAL success: the fields that were skipped while the rest of the patch was written. A 200 carrying this key means the save was incomplete — surface it, do not treat the response as a clean save. `code` is the validator code for a field that failed validation, or `rate_limited` for an email-address change that has spent the account's hourly budget; that one is not a bad value and the same address will be accepted once the window rolls over." type: array items: $ref: "#/components/schemas/ValidationIssue" required: - id - username - email - role - heightCm - dateOfBirth - gender - timezone - fullName - insurerName - insurerIkNumber - hasInsuranceNumber additionalProperties: false description: "The profile after the patch, as THIS path projects it. Deliberately named apart from `ProfileUpdateResponse`: PATCH /api/user/profile runs the same handler and answers a different field set — this one carries `id` and `role`, that one carries `displayName`, `locale`, `timeFormat`, `dateFormat` and `moodReminderEnabled`. Neither is a superset of the other. Field-by-field either way: a bad field is dropped and reported in `rejectedFields` rather than failing the whole request, unless EVERY supplied field was rejected." PasskeyReauthOptionsEnvelope: type: object properties: data: $ref: "#/components/schemas/PasskeyReauthOptionsResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false PasskeyReauthOptionsResponse: type: object properties: options: type: object propertyNames: type: string additionalProperties: {} description: SimpleWebAuthn assertion options for the re-proof ceremony. challengeId: type: string reauth: type: boolean const: true description: Marks this as the re-proof arm rather than the enrollment arm. Present only here. required: - options - challengeId - reauth additionalProperties: false PasskeyRegisterOptionsEnvelope: type: object properties: data: $ref: "#/components/schemas/WebauthnOptionsResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false PasskeyRegisterVerifyEnvelope: type: object properties: data: type: object properties: verified: type: boolean const: true required: - verified additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false CodexDeviceStartEnvelope: type: object properties: data: $ref: "#/components/schemas/CodexDeviceStartResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false CodexDeviceStartResponse: type: object properties: userCode: type: string description: Short code the person types on the verification page. verificationUrl: type: string format: uri description: Where to type it — an OpenAI-hosted page, not a HealthLog one. intervalSeconds: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: "How often to call the poll endpoint. Honour it: the poll surface is limited to 60 calls per minute per user." required: - userCode - verificationUrl - intervalSeconds additionalProperties: false description: The user-facing half of a device-code grant. The device-auth id that pairs with it is NOT here — it is sealed into an encrypted, httpOnly, 15-minute cookie, so the poll can only be completed by the browser that started the flow. CodexDevicePollEnvelope: type: object properties: data: $ref: "#/components/schemas/CodexDevicePollResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false CodexDevicePollResponse: type: object properties: status: type: string enum: - pending - connected description: "`pending` while the person has not approved yet — keep polling. `connected` once the credentials are stored; stop polling, the cookie is gone." required: - status additionalProperties: false CodexDisconnectEnvelope: type: object properties: data: type: object properties: disconnected: type: boolean required: - disconnected additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false SyncStateEnvelope: type: object properties: data: $ref: "#/components/schemas/SyncStateResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false SyncStateResponse: type: object properties: userId: type: string timezone: type: string lastSyncedAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" serverNow: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ measurements: type: object properties: lastUpdatedAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" liveCount: type: integer minimum: 0 maximum: 9007199254740991 tombstonedCount: type: integer minimum: 0 maximum: 9007199254740991 required: - lastUpdatedAt - liveCount - tombstonedCount additionalProperties: false mood: type: object properties: lastUpdatedAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" liveCount: type: integer minimum: 0 maximum: 9007199254740991 tombstonedCount: type: integer minimum: 0 maximum: 9007199254740991 required: - lastUpdatedAt - liveCount - tombstonedCount additionalProperties: false intakes: type: object properties: lastUpdatedAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" liveCount: type: integer minimum: 0 maximum: 9007199254740991 tombstonedCount: type: integer minimum: 0 maximum: 9007199254740991 required: - lastUpdatedAt - liveCount - tombstonedCount additionalProperties: false sync: type: object properties: incrementalWindowDays: type: integer exclusiveMinimum: 0 maximum: 9007199254740991 description: Days an incremental delta stays valid; tracks the native refresh-token lifetime. Beyond it a device re-pairs with a full backfill. iOS derives its window from this rather than hardcoding 60. tombstoneRetentionDays: type: integer exclusiveMinimum: 0 maximum: 9007199254740991 description: Horizon past which tombstones may be pruned. A cursor older than this gets `cursorExpired` on `/api/sync/changes`. required: - incrementalWindowDays - tombstoneRetentionDays additionalProperties: false description: Sync-window metadata the client reads instead of hardcoding. required: - userId - timezone - lastSyncedAt - serverNow - measurements - mood - intakes - sync additionalProperties: false description: iOS SyncMode handshake. Reports the server-side `lastSyncedAt` checkpoint, which the batch ingest endpoints set when a client push lands; the handshake itself writes nothing. The cheap 'should I sync?' summary; the durable delta cursor lives on `/api/sync/changes`. SyncChangesEnvelope: type: object properties: data: $ref: "#/components/schemas/SyncChangesResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false SyncChangesResponse: type: object properties: serverNow: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ cursor: description: Opaque cursor to echo into the next request. type: - string - "null" hasMore: type: boolean description: False once the client is caught up as of `serverNow`. cursorExpired: type: boolean description: True when the supplied cursor predates tombstone retention, or was issued before the account's data was last restored from a backup or deleted — drop the cursor and do a clean initial sync. changes: type: object properties: measurements: type: object properties: upserts: type: array items: $ref: "#/components/schemas/SyncMeasurementUpsert" tombstones: type: array items: $ref: "#/components/schemas/SyncMeasurementTombstone" required: - upserts - tombstones additionalProperties: false mood: type: object properties: upserts: type: array items: $ref: "#/components/schemas/SyncMoodUpsert" tombstones: type: array items: $ref: "#/components/schemas/SyncMoodTombstone" required: - upserts - tombstones additionalProperties: false intakes: type: object properties: upserts: type: array items: $ref: "#/components/schemas/SyncIntakeUpsert" tombstones: type: array items: $ref: "#/components/schemas/SyncIntakeTombstone" required: - upserts - tombstones additionalProperties: false cycleDays: type: object properties: upserts: type: array items: $ref: "#/components/schemas/CycleDayLogDTO" tombstones: type: array items: $ref: "#/components/schemas/SyncCycleDayTombstone" required: - upserts - tombstones additionalProperties: false description: Cycle day logs. Present on every page, empty arrays when the account has the cycle module off. cycles: type: object properties: upserts: type: array items: $ref: "#/components/schemas/MenstrualCycleDTO" tombstones: type: array items: $ref: "#/components/schemas/SyncCycleTombstone" required: - upserts - tombstones additionalProperties: false description: Menstrual cycles, observed and forward-predicted. Present on every page, empty arrays when the account has the cycle module off. measurementMirror: type: object properties: upserts: type: array items: $ref: "#/components/schemas/SyncMeasurementMirrorUpsert" tombstones: maxItems: 0 type: array items: type: object properties: id: type: string required: - id additionalProperties: false description: "Always empty: a deleted reading is a measurement tombstone." required: - upserts - tombstones additionalProperties: false description: Apple Health write-back changes for readings that are otherwise unchanged. Apply after `measurements`. required: - measurements - mood - intakes - cycleDays - cycles - measurementMirror additionalProperties: false required: - serverNow - cursor - hasMore - cursorExpired - changes additionalProperties: false description: "Multi-domain delta page: measurements, mood, intakes, cycle day logs, cycles, and the Apple Health write-back changes of otherwise unchanged readings. One opaque multi-domain keyset cursor; tombstones apply before upserts within each domain. Tombstone identity: measurements key on externalId, every other domain on server id. All six keys are present on every response, including the `cursorExpired` short-circuit — a domain with nothing to report sends empty arrays rather than being omitted." SyncMeasurementUpsert: type: object properties: id: type: string description: Stored row id, or a synthetic key (`day::`, `sleep-seg::`) on the collapsed list modes. type: $ref: "#/components/schemas/MeasurementType" value: type: number unit: type: string measuredAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ source: $ref: "#/components/schemas/MeasurementSource" notes: type: - string - "null" userId: type: string valueMin: description: Bucket MINIMUM — set only on an hourly heart-rate `stats:` row. type: - number - "null" valueMax: description: Bucket MAXIMUM — set only on an hourly heart-rate `stats:` row. type: - number - "null" externalId: description: Cross-device dedup key (UUID string or `stats::`). type: - string - "null" externalSourceVersion: type: - string - "null" aggregationProvenance: description: "Authority of an aggregate stored under a `stats:` identity: `HEALTHKIT_STATISTICS`, `EXPORT_XML_SOURCE_MAX` or `LEGACY_UNKNOWN`. Null on an ordinary sample row. Decode unknown values leniently." type: - string - "null" glucoseContext: description: Set only on BLOOD_GLUCOSE rows, and null there too when the writer recorded no classification (a sensor export, a device sync). Never invent one. type: - string - "null" sleepStage: description: Set only on SLEEP_DURATION rows. type: - string - "null" rhythmClassification: description: Device classification verdict; set only on EVENT rows. type: - string - "null" deviceType: description: "`watch | band | ring | phone | scale | other | unknown`." type: - string - "null" syncVersion: type: integer exclusiveMinimum: 0 maximum: 9007199254740991 description: LWW reconciliation counter; echo to keep the mirror monotonic. deletedAt: description: Soft-delete tombstone; null on a live row. anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" createdAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ updatedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ appleHealthMirror: type: boolean description: Whether the HealthLog app writes this reading into Apple Health. Server-resolved; mirror exactly the rows that say true. dayKey: type: string sampleCount: type: integer minimum: -9007199254740991 maximum: 9007199254740991 mainAsleepMinutes: description: The main night's own time asleep in minutes; null on a day of naps alone. On a sleep row `value` is the day's total, the main night plus its naps. anyOf: - type: integer minimum: 0 maximum: 9007199254740991 - type: "null" napCount: description: Naps on the same wake day. type: integer minimum: 0 maximum: 9007199254740991 napAsleepMinutes: description: Time asleep across those naps, in minutes. type: number minimum: 0 sleepStages: description: The main night's minutes per stage (`IN_BED`, `ASLEEP`, `AWAKE`, `CORE`, `DEEP`, `REM`), each the union of that stage's spans; empty on a day of naps alone. type: object propertyNames: type: string additionalProperties: type: number minimum: 0 inBedMinutes: description: The main night's time in bed in minutes, or null when no in-bed span was recorded. anyOf: - type: number minimum: 0 - type: "null" awakeMinutes: description: The main night's time awake in bed in minutes, or null when no awake span was recorded. anyOf: - type: number minimum: 0 - type: "null" awakenings: description: Awake spans between the main night's first and last time asleep. type: integer minimum: 0 maximum: 9007199254740991 required: - id - type - value - unit - measuredAt - source - externalId - syncVersion - updatedAt - appleHealthMirror additionalProperties: false MeasurementSource: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT description: Origin of the measurement. v1.4.23 added APPLE_HEALTH for the iOS HealthKit batch ingest path. v1.38.x added EXTERNAL for rows pushed in under a narrow `measurements:write` Bearer — a scale, a watch bridge, a home-automation rule — and workouts pushed in under a narrow `workouts:write` Bearer carry it too. No client may name that value on a write; the server resolves it from the credential. SyncMeasurementTombstone: type: object properties: id: type: string externalId: description: The identity key the client dedups on for measurements. type: - string - "null" syncVersion: type: integer exclusiveMinimum: 0 maximum: 9007199254740991 deletedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ updatedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - id - externalId - syncVersion - deletedAt - updatedAt additionalProperties: false description: A soft-deleted measurement. Apply tombstones BEFORE upserts within a page to avoid resurrecting a row. Since v1.42 only deletions a person makes appear here; the nightly fold that condenses raw samples into `stats:` rows deletes them outright and lists nothing. SyncMoodUpsert: type: object properties: id: type: string date: type: string description: YYYY-MM-DD anchored to the row's `tz`. mood: type: string score: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: The legacy 1-5 axis. Unchanged. a1: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" description: Pleasantness, 0-10. Server-derived from `mood` on every write, so this is populated even for rows a client posted without it. Consume the value; do not re-derive it. a2: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" description: "Stress, 0-10, INVERSE-oriented: a higher value is more stress. NULL when nobody answered it." a3: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" description: Energy, 0-10, or null. a4: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" description: Connectedness, 0-10, or null. a5: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" description: Stability, 0-10, or null. context: anyOf: - $ref: "#/components/schemas/MoodContextOutput" - type: "null" description: The day's context, or null when the entry carries none. Null and an object of nulls are different answers. tags: description: JSON array of tag keys, or null. type: - string - "null" note: type: - string - "null" moodLoggedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ source: type: string syncVersion: type: integer minimum: 0 maximum: 9007199254740991 description: LWW reconciliation counter; mood is last-writer-wins by it. updatedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - id - date - mood - score - a1 - a2 - a3 - a4 - a5 - context - tags - note - moodLoggedAt - source - syncVersion - updatedAt additionalProperties: false SyncMoodTombstone: type: object properties: id: type: string description: Server id — the identity key the client dedups on for mood. syncVersion: type: integer minimum: 0 maximum: 9007199254740991 deletedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ updatedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - id - syncVersion - deletedAt - updatedAt additionalProperties: false description: A soft-deleted mood entry, keyed on server `id`. Apply before upserts within the domain page. SyncIntakeUpsert: type: object properties: id: type: string medicationId: type: string scheduledFor: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ takenAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" skipped: type: boolean source: type: string autoRecorded: type: boolean description: v1.42.2 — the server recorded this dose because the medication has `autoMarkTaken` on. Counts as taken. syncVersion: type: integer minimum: 0 maximum: 9007199254740991 description: Reconciliation counter. An intake is immutable; a correction is a tombstone + re-insert. updatedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - id - medicationId - scheduledFor - takenAt - skipped - source - autoRecorded - syncVersion - updatedAt additionalProperties: false SyncIntakeTombstone: type: object properties: id: type: string description: Server id — the identity key the client dedups on for intakes. syncVersion: type: integer minimum: 0 maximum: 9007199254740991 deletedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ updatedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - id - syncVersion - deletedAt - updatedAt additionalProperties: false description: A soft-deleted medication intake, keyed on server `id`. Apply before upserts within the domain page. SyncCycleDayTombstone: type: object properties: id: type: string externalId: description: The client's own key for the day log, when it set one. type: - string - "null" syncVersion: type: integer minimum: 0 maximum: 9007199254740991 deletedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ updatedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - id - externalId - syncVersion - deletedAt - updatedAt additionalProperties: false description: A soft-deleted cycle day log, keyed on server `id`. Apply before upserts within the domain page. SyncCycleTombstone: type: object properties: id: type: string syncVersion: type: integer minimum: 0 maximum: 9007199254740991 deletedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ updatedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - id - syncVersion - deletedAt - updatedAt additionalProperties: false description: A soft-deleted menstrual cycle, keyed on server `id`. Apply before upserts within the domain page. SyncMeasurementMirrorUpsert: type: object properties: id: type: string description: The measurement's server id. appleHealthMirror: type: boolean description: The reading's write-back as it now stands. False means stop writing it; samples already in Apple Health stay. required: - id - appleHealthMirror additionalProperties: false description: A reading whose `appleHealthMirror` changed because the account switched the Apple Health setting of the token that sent it. The reading itself did not change and is not re-sent in `measurements`. GetMeasurementsSeriesBatchResponse: type: object properties: data: $ref: "#/components/schemas/MeasurementsSeriesBatchResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MeasurementsSeriesBatchResponse: type: object properties: series: type: object propertyNames: $ref: "#/components/schemas/MeasurementType" additionalProperties: type: array items: type: object properties: type: $ref: "#/components/schemas/MeasurementType" value: type: number description: "The day's mean (spot metrics) or total (cumulative). A row of a coarser tier (a window longer than the daily cap) carries the average day of its bucket, the same quantity: a spot metric's mean of its daily means, a cumulative metric's total over the days of the bucket that hold a reading." measuredAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ description: "Day-bucket start (ISO-8601): the user's local midnight. A window longer than the daily cap is served from a coarser tier (a UTC day, week or month); its rows are stamped at noon UTC of the bucket's first day, which falls inside the bucket's own week or month in every zone." count: description: Underlying raw-row count for the bucket. type: integer minimum: 0 maximum: 9007199254740991 minValue: description: Per-day minimum (spot metrics only; absent for cumulative). type: - number - "null" maxValue: description: Per-day maximum (spot metrics only; absent for cumulative). type: - number - "null" required: - type - value - measuredAt additionalProperties: false description: Per-type daily series, keyed by MeasurementType. Only the requested types are present. required: - series additionalProperties: false description: Batched daily series — one rollup-backed series per requested type. GetMeasurementsSeriesResponse: type: object properties: data: $ref: "#/components/schemas/MeasurementsSeriesResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MeasurementsSeriesResponse: type: object properties: kind: type: string enum: - weight - bloodPressure - pulse - bodyFat - glucose - sleep - steps - totalBodyWater - boneMass - oxygenSaturation - restingHeartRate - heartRateVariability - vo2Max unit: type: string description: Resolved unit token for `value`. `glucose` follows the user's mg/dL|mmol/L preference; `sleep` is `h` (per-night time-asleep in hours). points: type: array items: type: object properties: id: type: string description: Measurement row id; `sleep:` for a sleep night; `day:` for a day bucket (`pulse`, `glucose`, `heartRateVariability`, `oxygenSaturation` beyond 90 days); `hour:` for an hour bucket (the same kinds within 90 days when the window holds more than 10 000 readings). at: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ description: Point timestamp (ISO-8601). value: type: number description: Primary value in the top-level `unit`. secondary: description: Diastolic value for `kind=bloodPressure` (paired within ±5 min); null for every other kind. type: - number - "null" sleepStages: description: Per-stage hours for a `kind=sleep` night (CORE/DEEP/REM/…); null/absent for non-sleep kinds. anyOf: - type: object propertyNames: type: string additionalProperties: type: number - type: "null" valueMin: description: "v1.19.2 (iOS #34 extension) — per-point MINIMUM for `kind=pulse`. On an aggregated hourly heart-rate bucket `value` is the hour's average and this is the hour's low; null on a per-sample PULSE row and absent for every other kind." type: - number - "null" valueMax: description: "v1.19.2 (iOS #34 extension) — per-point MAXIMUM for `kind=pulse`. On an aggregated hourly heart-rate bucket `value` is the hour's average and this is the hour's high; null on a per-sample PULSE row and absent for every other kind." type: - number - "null" required: - id - at - value - secondary additionalProperties: false stats: type: object properties: mean: type: number min: type: number max: type: number stdDev: type: number count: type: integer minimum: 0 maximum: 9007199254740991 required: - mean - min - max - stdDev - count additionalProperties: false description: Summary over the returned points; all-zero when empty. direction: description: v1.43 — which way a change of this metric counts. anyOf: - $ref: "#/components/schemas/TrendVerdict" - type: "null" change: description: v1.43 — first and last day value; null under two days. anyOf: - $ref: "#/components/schemas/SeriesChange" - type: "null" referenceLines: description: v1.43 — lines to draw; empty when there is nothing to draw. type: array items: $ref: "#/components/schemas/SeriesReferenceLine" required: - kind - unit - points - stats additionalProperties: false description: "iOS-friendly per-kind time series: one point per reading (or per reconstructed night for `sleep`), an explicit `unit` token, and a summary `stats` block. Sample-dense kinds (`pulse`, `glucose`, `heartRateVariability`, `oxygenSaturation`) are bucketed server-side: beyond 90 days per local day, and within 90 days per local hour when the window holds more than 10 000 readings (a continuous glucose sensor, a watch or ring recording every minute; a cuff, a fingerstick meter or a few readings a night stay raw). A bucket carries the mean as `value` and otherwise has the shape of a reading of its kind; a `pulse` bucket also carries its range as `valueMin` / `valueMax`, and a `glucose` bucket is converted to the user's unit like a raw reading. `stats` is always computed over the underlying readings, never over the buckets." TrendVerdict: type: object properties: direction: type: string enum: - up-good - up-bad - neutral - hold description: "How a change counts: `up-good` a rise is progress, `up-bad` a fall is progress, `hold` staying is progress and a move either way is no setback, `neutral` no judgement. `hold` covers a value inside its band, a blood pressure, resting pulse or body fat below a profile-default band (a default's lower edge is no goal; below the person's own target a rise stays `up-good`), and steps or oxygen saturation above their band (more is never worse)." targetPosition: anyOf: - type: string enum: - below - inside - above - type: "null" description: Where the value sits against the band; null without one. basis: type: string enum: - personal_target - profile_default - metric_default description: "What the verdict was judged against: the person's own target, a profile-derived default (e.g. the age-based blood pressure target), or the metric's default direction." required: - direction - targetPosition - basis additionalProperties: false description: Which way a change of a metric counts as progress for this person. The generalisation of `tiles.weightTrend`. SeriesChange: type: object properties: from: type: number to: type: number delta: type: number sentiment: $ref: "#/components/schemas/TrendSentiment" required: - from - to - delta - sentiment additionalProperties: false description: First and last day value in the window (by the metric's day statistic) and how the change counts. Null under two days. TrendSentiment: type: string enum: - positive - negative - neutral description: The verdict applied to one delivered change, with the noise floor applied. Colour the change from this. SeriesReferenceLine: type: object properties: value: type: number description: In the unit of the response. kind: type: string enum: - lower - upper source: type: string enum: - personal - clinical appliesTo: type: string enum: - value - secondary description: "`secondary` is the diastolic value of a blood pressure." labelKey: type: string label: type: string description: In the account language. required: - value - kind - source - appliesTo - labelKey - label additionalProperties: false description: A horizontal line to draw on the chart. A personal line replaces the clinical one for the same edge. Empty list when there is nothing to draw. ListMeasurementsResponse: type: object properties: data: type: object properties: measurements: type: array items: anyOf: - $ref: "#/components/schemas/MeasurementResource" - $ref: "#/components/schemas/AggregatedMeasurementBucket" meta: $ref: "#/components/schemas/ListMeasurementsMeta" required: - measurements - meta additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MeasurementResource: type: object properties: id: type: string description: Stored row id, or a synthetic key (`day::`, `sleep-seg::`) on the collapsed list modes. type: $ref: "#/components/schemas/MeasurementType" value: type: number unit: type: string measuredAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ source: $ref: "#/components/schemas/MeasurementSource" notes: type: - string - "null" userId: type: string valueMin: description: Bucket MINIMUM — set only on an hourly heart-rate `stats:` row. type: - number - "null" valueMax: description: Bucket MAXIMUM — set only on an hourly heart-rate `stats:` row. type: - number - "null" externalId: description: External-system dedup key; null for manual entries. type: - string - "null" externalSourceVersion: type: - string - "null" aggregationProvenance: description: "Authority of an aggregate stored under a `stats:` identity: `HEALTHKIT_STATISTICS`, `EXPORT_XML_SOURCE_MAX` or `LEGACY_UNKNOWN`. Null on an ordinary sample row. Decode unknown values leniently." type: - string - "null" glucoseContext: description: Set only on BLOOD_GLUCOSE rows, and null there too when the writer recorded no classification (a sensor export, a device sync). Never invent one. type: - string - "null" sleepStage: description: Set only on SLEEP_DURATION rows. type: - string - "null" rhythmClassification: description: Device classification verdict; set only on EVENT rows. type: - string - "null" deviceType: description: "`watch | band | ring | phone | scale | other | unknown`." type: - string - "null" syncVersion: description: Bumped on every server-side mutation; the last-writer-wins input for cross-device reconciliation. type: integer minimum: -9007199254740991 maximum: 9007199254740991 deletedAt: description: Soft-delete tombstone; null on a live row. anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" createdAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ updatedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ appleHealthMirror: description: "Whether the HealthLog app writes this reading into Apple Health. Resolved by the server on every stored row: true for WITHINGS, IMPORT and MANUAL, for an EXTERNAL row whatever its account chose for the token that sent it, false otherwise. Absent on the synthesised list rows." type: boolean dayKey: type: string sampleCount: type: integer minimum: -9007199254740991 maximum: 9007199254740991 mainAsleepMinutes: description: The main night's own time asleep in minutes; null on a day of naps alone. On a sleep row `value` is the day's total, the main night plus its naps. anyOf: - type: integer minimum: 0 maximum: 9007199254740991 - type: "null" napCount: description: Naps on the same wake day. type: integer minimum: 0 maximum: 9007199254740991 napAsleepMinutes: description: Time asleep across those naps, in minutes. type: number minimum: 0 sleepStages: description: The main night's minutes per stage (`IN_BED`, `ASLEEP`, `AWAKE`, `CORE`, `DEEP`, `REM`), each the union of that stage's spans; empty on a day of naps alone. type: object propertyNames: type: string additionalProperties: type: number minimum: 0 inBedMinutes: description: The main night's time in bed in minutes, or null when no in-bed span was recorded. anyOf: - type: number minimum: 0 - type: "null" awakeMinutes: description: The main night's time awake in bed in minutes, or null when no awake span was recorded. anyOf: - type: number minimum: 0 - type: "null" awakenings: description: Awake spans between the main night's first and last time asleep. type: integer minimum: 0 maximum: 9007199254740991 required: - id - type - value - unit - measuredAt - source additionalProperties: false description: Server-shaped measurement row. GET endpoints return the whole stored row with the decrypted note on `notes` and the `notesEncrypted` ciphertext stripped; the collapsed list modes synthesise rows carrying the display fields plus `dayKey` / `sampleCount`. AggregatedMeasurementBucket: type: object properties: type: $ref: "#/components/schemas/MeasurementType" value: type: number description: The bucket's aggregated value. measuredAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ description: Bucket start. count: type: integer minimum: 0 maximum: 9007199254740991 minValue: description: The lowest reading in the bucket; absent on a summed (cumulative) type. type: - number - "null" maxValue: description: The highest reading in the bucket; absent on a summed (cumulative) type. type: - number - "null" unit: type: string required: - type - value - measuredAt additionalProperties: false description: Pre-folded bucket returned by the `aggregate=` list modes instead of a stored row. ListMeasurementsMeta: type: object properties: total: type: integer minimum: 0 maximum: 9007199254740991 limit: type: integer minimum: 0 maximum: 9007199254740991 offset: type: integer minimum: 0 maximum: 9007199254740991 dayKey: description: Echoed on the per-sample drill-down modes. type: string groupBy: description: Set on the collapsed day-sum / per-night modes. type: string enum: - day - night aggregate: description: Set on the aggregate modes — the requested grain. type: string droppedDuplicates: description: Rows discarded by the cross-source canonical picker on the day-collapse mode. type: integer minimum: 0 maximum: 9007199254740991 required: - total - limit - offset additionalProperties: false NamedSleepStageDuplicateResponse: type: object properties: data: $ref: "#/components/schemas/NamedSleepStageMeasurement" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false NamedSleepStageMeasurement: type: object properties: id: type: string description: Stored row id, or a synthetic key (`day::`, `sleep-seg::`) on the collapsed list modes. type: $ref: "#/components/schemas/MeasurementType" value: type: number unit: type: string measuredAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ source: $ref: "#/components/schemas/MeasurementSource" notes: type: - string - "null" userId: type: string valueMin: description: Bucket MINIMUM — set only on an hourly heart-rate `stats:` row. type: - number - "null" valueMax: description: Bucket MAXIMUM — set only on an hourly heart-rate `stats:` row. type: - number - "null" externalId: description: External-system dedup key; null for manual entries. type: - string - "null" externalSourceVersion: type: - string - "null" aggregationProvenance: description: "Authority of an aggregate stored under a `stats:` identity: `HEALTHKIT_STATISTICS`, `EXPORT_XML_SOURCE_MAX` or `LEGACY_UNKNOWN`. Null on an ordinary sample row. Decode unknown values leniently." type: - string - "null" glucoseContext: description: Set only on BLOOD_GLUCOSE rows, and null there too when the writer recorded no classification (a sensor export, a device sync). Never invent one. type: - string - "null" sleepStage: description: Set only on SLEEP_DURATION rows. type: - string - "null" rhythmClassification: description: Device classification verdict; set only on EVENT rows. type: - string - "null" deviceType: description: "`watch | band | ring | phone | scale | other | unknown`." type: - string - "null" syncVersion: description: Bumped on every server-side mutation; the last-writer-wins input for cross-device reconciliation. type: integer minimum: -9007199254740991 maximum: 9007199254740991 deletedAt: description: Soft-delete tombstone; null on a live row. anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" createdAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ updatedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ appleHealthMirror: description: "Whether the HealthLog app writes this reading into Apple Health. Resolved by the server on every stored row: true for WITHINGS, IMPORT and MANUAL, for an EXTERNAL row whatever its account chose for the token that sent it, false otherwise. Absent on the synthesised list rows." type: boolean dayKey: type: string sampleCount: type: integer minimum: -9007199254740991 maximum: 9007199254740991 mainAsleepMinutes: description: The main night's own time asleep in minutes; null on a day of naps alone. On a sleep row `value` is the day's total, the main night plus its naps. anyOf: - type: integer minimum: 0 maximum: 9007199254740991 - type: "null" napCount: description: Naps on the same wake day. type: integer minimum: 0 maximum: 9007199254740991 napAsleepMinutes: description: Time asleep across those naps, in minutes. type: number minimum: 0 sleepStages: description: The main night's minutes per stage (`IN_BED`, `ASLEEP`, `AWAKE`, `CORE`, `DEEP`, `REM`), each the union of that stage's spans; empty on a day of naps alone. type: object propertyNames: type: string additionalProperties: type: number minimum: 0 inBedMinutes: description: The main night's time in bed in minutes, or null when no in-bed span was recorded. anyOf: - type: number minimum: 0 - type: "null" awakeMinutes: description: The main night's time awake in bed in minutes, or null when no awake span was recorded. anyOf: - type: number minimum: 0 - type: "null" awakenings: description: Awake spans between the main night's first and last time asleep. type: integer minimum: 0 maximum: 9007199254740991 status: type: string enum: - inserted - updated - duplicate description: "`inserted` — a new row (HTTP 201). `duplicate` — the segment was already stored, by this route or the batch route, and nothing changed (HTTP 200). `updated` — a stored segment with the same stage, source and end was corrected in place, for example a later start (HTTP 200)." required: - id - type - value - unit - measuredAt - source - status additionalProperties: false description: The stored SLEEP_DURATION row for a named sleep-stage segment, with what the call did to it. CreateMeasurementResponse: type: object properties: data: anyOf: - $ref: "#/components/schemas/NamedSleepStageMeasurement" - $ref: "#/components/schemas/MeasurementResource" - type: array items: $ref: "#/components/schemas/MeasurementResource" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false GetMeasurementResponse: type: object properties: data: $ref: "#/components/schemas/MeasurementResource" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false UpdateMeasurementResponse: type: object properties: data: $ref: "#/components/schemas/MeasurementResource" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DeleteMeasurementResponse: type: object properties: data: type: object properties: deleted: type: boolean const: true required: - deleted additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false BatchMeasurementsResponse: type: object properties: data: $ref: "#/components/schemas/AppleHealthBatchResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false AppleHealthBatchResponse: type: object properties: processed: type: integer minimum: 0 maximum: 9007199254740991 inserted: type: integer minimum: 0 maximum: 9007199254740991 updated: type: integer minimum: 0 maximum: 9007199254740991 duplicates: type: integer minimum: 0 maximum: 9007199254740991 failed: type: integer minimum: 0 maximum: 9007199254740991 skipped: type: array items: type: object properties: index: type: integer minimum: 0 maximum: 9007199254740991 reason: type: string required: - index - reason additionalProperties: false entries: type: array items: $ref: "#/components/schemas/AppleHealthBatchEntryResult" required: - processed - inserted - updated - duplicates - failed - skipped - entries additionalProperties: false AppleHealthBatchEntryResult: type: object properties: index: type: integer minimum: 0 maximum: 9007199254740991 status: type: string enum: - inserted - updated - duplicate - skipped - failed description: "`inserted`/`duplicate` — the row landed (advance the cursor). `updated` — a `stats:` aggregate overwrote an existing row. `skipped` — validation no-op; see `reason`. `failed` — retryable database failure that must not advance the entry cursor; the response is marked `Cache-Control: no-store`." reason: description: "Why a row was `skipped` or a `duplicate`. `folded_window` (since v1.42): a raw sample of a dense or mean type whose local day lies before the fold boundary and whose hour or day a live `stats:` row of the same source already covers. Nothing to retry; treat it like any duplicate. `hour_bucket_misaligned`, `hour_bucket_unsupported_type`, `hour_bucket_too_old` (since v1.43): an hourly `stats::T` row the server refused, see `externalId`; permanent for the row as sent." type: string convertedValue: description: "Only on a `value_out_of_range` skip (since v1.39.1): the entry's value after conversion into the stored unit, which is the number the band was checked against. A HealthKit fraction that should have been a percent shows up here a hundred times off." type: number range: description: "Only on a `value_out_of_range` skip (since v1.39.1): the plausibility band that refused the entry, in the stored unit. Both edges are inclusive. A skip is permanent for the value as sent; resending it unchanged skips it again." type: object properties: min: type: number max: type: number unit: type: string required: - min - max - unit additionalProperties: false required: - index - status additionalProperties: false MeasurementsDeleteByExternalIdsResponse: type: object properties: data: $ref: "#/components/schemas/MeasurementsDeleteByExternalIdsResult" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MeasurementsDeleteByExternalIdsResult: type: object properties: deletedCount: type: integer minimum: 0 maximum: 9007199254740991 required: - deletedCount additionalProperties: false MeasurementsBulkDeleteResponse: type: object properties: data: $ref: "#/components/schemas/MeasurementsBulkDeleteResult" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MeasurementsBulkDeleteResult: type: object properties: deleted: type: integer minimum: 0 maximum: 9007199254740991 required: - deleted additionalProperties: false MeasurementsRestoreResponse: type: object properties: data: $ref: "#/components/schemas/MeasurementsRestoreResult" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MeasurementsRestoreResult: type: object properties: restored: type: integer minimum: 0 maximum: 9007199254740991 required: - restored additionalProperties: false SleepNightResponse: type: object properties: data: $ref: "#/components/schemas/SleepNightResource" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false SleepNightResource: type: object properties: night: type: - string - "null" main: anyOf: - type: object properties: night: type: string description: Wake-day key (YYYY-MM-DD). source: anyOf: - $ref: "#/components/schemas/MeasurementSource" - type: "null" start: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ end: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ asleepMinutes: type: integer minimum: 0 maximum: 9007199254740991 inBedMinutes: anyOf: - type: integer minimum: 0 maximum: 9007199254740991 - type: "null" awakeMinutes: anyOf: - type: integer minimum: 0 maximum: 9007199254740991 - type: "null" awakenings: type: integer minimum: 0 maximum: 9007199254740991 reconstructed: type: boolean description: True when the source has no per-stage onset timestamps and the server synthesised a contiguous timeline in a fixed physiological order (WHOOP). The client renders the hypnogram but labels it an approximate layout and never recomputes. False for real-series sources (Apple Health / Withings / Fitbit). stages: type: object propertyNames: type: string enum: - IN_BED - AWAKE - ASLEEP - REM - CORE - DEEP additionalProperties: type: integer minimum: 0 maximum: 9007199254740991 description: Minutes per stage. Only the stages the night recorded are present; an absent stage is not zero minutes, it was not measured. segments: type: array items: type: object properties: stage: anyOf: - type: string enum: - IN_BED - AWAKE - ASLEEP - REM - CORE - DEEP - type: "null" start: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ end: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ minutes: type: integer minimum: 0 maximum: 9007199254740991 required: - stage - start - end - minutes additionalProperties: false sourceDiscrepancy: anyOf: - type: object properties: deltaMinutes: type: integer minimum: 0 maximum: 9007199254740991 description: Spread (max − min) of the disagreeing per-writer asleep totals, in minutes. sources: type: array items: type: object properties: source: type: string description: MeasurementSource value of the writer bucket. deviceType: type: - string - "null" asleepMinutes: type: integer minimum: 0 maximum: 9007199254740991 required: - source - deviceType - asleepMinutes additionalProperties: false required: - deltaMinutes - sources additionalProperties: false - type: "null" description: Non-null when two writer buckets reported clearly different asleep totals for this session (> 45 min apart and > 20% of the larger total). Observational only — the served totals stay the winning writer's; clients may show a discreet 'sources disagree' hint listing each bucket's total. required: - night - source - start - end - asleepMinutes - inBedMinutes - awakeMinutes - awakenings - reconstructed - stages - segments - sourceDiscrepancy additionalProperties: false - type: "null" naps: type: array items: type: object properties: night: type: string description: Wake-day key (YYYY-MM-DD). source: anyOf: - $ref: "#/components/schemas/MeasurementSource" - type: "null" start: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ end: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ asleepMinutes: type: integer minimum: 0 maximum: 9007199254740991 inBedMinutes: anyOf: - type: integer minimum: 0 maximum: 9007199254740991 - type: "null" awakeMinutes: anyOf: - type: integer minimum: 0 maximum: 9007199254740991 - type: "null" awakenings: type: integer minimum: 0 maximum: 9007199254740991 reconstructed: type: boolean description: True when the source has no per-stage onset timestamps and the server synthesised a contiguous timeline in a fixed physiological order (WHOOP). The client renders the hypnogram but labels it an approximate layout and never recomputes. False for real-series sources (Apple Health / Withings / Fitbit). stages: type: object propertyNames: type: string enum: - IN_BED - AWAKE - ASLEEP - REM - CORE - DEEP additionalProperties: type: integer minimum: 0 maximum: 9007199254740991 description: Minutes per stage. Only the stages the night recorded are present; an absent stage is not zero minutes, it was not measured. segments: type: array items: type: object properties: stage: anyOf: - type: string enum: - IN_BED - AWAKE - ASLEEP - REM - CORE - DEEP - type: "null" start: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ end: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ minutes: type: integer minimum: 0 maximum: 9007199254740991 required: - stage - start - end - minutes additionalProperties: false sourceDiscrepancy: anyOf: - type: object properties: deltaMinutes: type: integer minimum: 0 maximum: 9007199254740991 description: Spread (max − min) of the disagreeing per-writer asleep totals, in minutes. sources: type: array items: type: object properties: source: type: string description: MeasurementSource value of the writer bucket. deviceType: type: - string - "null" asleepMinutes: type: integer minimum: 0 maximum: 9007199254740991 required: - source - deviceType - asleepMinutes additionalProperties: false required: - deltaMinutes - sources additionalProperties: false - type: "null" description: Non-null when two writer buckets reported clearly different asleep totals for this session (> 45 min apart and > 20% of the larger total). Observational only — the served totals stay the winning writer's; clients may show a discreet 'sources disagree' hint listing each bucket's total. required: - night - source - start - end - asleepMinutes - inBedMinutes - awakeMinutes - awakenings - reconstructed - stages - segments - sourceDiscrepancy additionalProperties: false required: - night - main - naps additionalProperties: false description: "One reconstructed sleep night: the main session's hypnogram segments + breakdown, plus same-wake-day naps surfaced separately. A session is a nap when it holds under three hours asleep centred between 10:00 and 20:00 local time; the main session is the longest session that is not a nap. A day whose only sleep is naps has `main: null` and its naps." SleepRhythmResponse: type: object properties: data: $ref: "#/components/schemas/SleepRhythmResource" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false SleepRhythmResource: type: object properties: sleepDebt: type: object properties: state: type: string enum: - partial - ready description: "`partial` until enough tracked nights exist (calm 'still learning'); `ready` once the rolling window can assert a balance." debtMinutes: type: integer minimum: 0 maximum: 9007199254740991 description: Outstanding sleep-debt balance over the window, in minutes, after caps. A night below need adds its shortfall; a night above need pays the balance down (floored at 0). needMinutes: type: integer minimum: 0 maximum: 9007199254740991 description: Age-based sleep need (minutes) used for the per-night deficit. nightsCounted: type: integer minimum: 0 maximum: 9007199254740991 windowNights: type: integer exclusiveMinimum: 0 maximum: 9007199254740991 description: Rolling window length in nights. nightsUntilReady: type: integer minimum: 0 maximum: 9007199254740991 description: Nights still needed before the debt is asserted (0 when ready). source: description: v1.25.0 — the active source the debt is resolved FROM, picked off the user's `sleepDebt` source ladder. `COMPUTED` is HealthLog's own rolling-balance estimate (the only producer today); a provider value would be a device-native debt. Clients explain the figure when it is `COMPUTED`. $ref: "#/components/schemas/MeasurementSource" required: - state - debtMinutes - needMinutes - nightsCounted - windowNights - nightsUntilReady - source additionalProperties: false chronotype: type: object properties: state: type: string enum: - learning - ready description: "`learning` until enough free-day nights exist (no band asserted); `ready` once the free-day sample is large enough." msfMinutes: description: Mid-sleep on free days (minutes-of-day), null while learning. type: - number - "null" msfScMinutes: description: Sleep-debt-corrected mid-sleep on free days (MSFsc, minutes-of-day), null while learning. type: - number - "null" band: anyOf: - type: string enum: - extreme_early - early - intermediate - late - extreme_late - type: "null" description: MCTQ chronotype band off MSFsc, null while learning. socialJetlagMinutes: description: Social jetlag = circular |MSF_work − MSF_free| in minutes, null when one side is missing. type: - number - "null" freeNightsCounted: type: integer minimum: 0 maximum: 9007199254740991 workNightsCounted: type: integer minimum: 0 maximum: 9007199254740991 freeNightsUntilReady: type: integer minimum: 0 maximum: 9007199254740991 description: Free-day nights still needed before a band is asserted. required: - state - msfMinutes - msfScMinutes - band - socialJetlagMinutes - freeNightsCounted - workNightsCounted - freeNightsUntilReady additionalProperties: false averagePerNight: type: object properties: state: type: string enum: - partial - ready averageMinutes: type: integer minimum: 0 maximum: 9007199254740991 description: Mean asleep minutes per scorable night (0 while partial). nightsCounted: type: integer minimum: 0 maximum: 9007199254740991 nightsUntilReady: type: integer minimum: 0 maximum: 9007199254740991 description: Scorable nights still needed before the average is asserted. required: - state - averageMinutes - nightsCounted - nightsUntilReady additionalProperties: false required: - sleepDebt - chronotype - averagePerNight additionalProperties: false description: "Server-authoritative sleep-rhythm read: the outstanding sleep-debt balance over the rolling window + MCTQ chronotype (MSF/MSFsc band, social jetlag). Free vs work nights default to weekend = free in the user's timezone (no work calendar). A view over existing per-stage SLEEP_DURATION rows — no schema, no new type." ListWorkoutsResponse: type: object properties: data: $ref: "#/components/schemas/WorkoutListResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false WorkoutListResponse: type: object properties: workouts: type: array items: $ref: "#/components/schemas/WorkoutListEntry" meta: type: object properties: total: type: integer minimum: 0 maximum: 9007199254740991 limit: type: integer exclusiveMinimum: 0 maximum: 9007199254740991 offset: type: integer minimum: 0 maximum: 9007199254740991 droppedDuplicates: type: integer minimum: 0 maximum: 9007199254740991 required: - total - limit - offset - droppedDuplicates additionalProperties: false trainingLoad: description: v1.44 — on the first page (`offset=0`) only, account-wide whatever the filter. Absent on later pages and older servers. $ref: "#/components/schemas/WorkoutTrainingLoad" required: - workouts - meta additionalProperties: false WorkoutListEntry: type: object properties: id: type: string sportType: type: - string - "null" startedAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" endedAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" durationSec: anyOf: - type: integer minimum: 0 maximum: 9007199254740991 - type: "null" distanceM: type: - number - "null" activeEnergyKcal: type: - number - "null" avgHr: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" maxHr: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" source: $ref: "#/components/schemas/MeasurementSource" externalId: type: - string - "null" hasRoute: type: boolean hasHrSeries: type: boolean required: - id - sportType - startedAt - endedAt - durationSec - distanceM - activeEnergyKcal - avgHr - maxHr - source - externalId - hasRoute - hasHrSeries additionalProperties: false WorkoutTrainingLoad: anyOf: - type: object properties: status: type: string const: ok asOf: type: string description: The last rated UTC day, at most yesterday; the figures are those of this day. The series runs on to yesterday, so a later stretch without data shows as unrated days. ctl: type: number description: "Fitness: 42-day exponentially weighted TRIMP." atl: type: number description: "Fatigue: 7-day exponentially weighted TRIMP." tsb: type: number description: "Form: fitness minus fatigue at `asOf`." daysCovered: type: integer minimum: -9007199254740991 maximum: 9007199254740991 trainingDays: type: integer minimum: -9007199254740991 maximum: 9007199254740991 recentRatedDays: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: "Rated days of the trailing `recentDays` (42 up to yesterday, or fewer when covered less): a stored impulse, or activity data without a workout." recentDays: type: integer minimum: -9007199254740991 maximum: 9007199254740991 warmingUp: type: boolean description: "True below 42 covered days: both averages start at zero." series: type: array items: type: object properties: day: type: string ctl: type: number atl: type: number tsb: type: number rated: type: boolean description: "False for a day that could not be rated: no stored impulse and no activity data (steps, heart rate, active energy), or a workout that was never scored. The averages held through it rather than counting it as rest. A day with activity data and no workout is rest (impulse 0)." required: - day - ctl - atl - tsb - rated additionalProperties: false description: The trailing 90 days, oldest first; `tsb` is form going into the day. required: - status - asOf - ctl - atl - tsb - daysCovered - trainingDays - recentRatedDays - recentDays - warmingUp - series additionalProperties: false - type: object properties: status: type: string const: insufficient reason: type: string enum: - no_impulse_history - too_little_history daysCovered: type: integer minimum: -9007199254740991 maximum: 9007199254740991 trainingDays: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - status - reason - daysCovered - trainingDays additionalProperties: false description: "v1.44 — the fitness-fatigue model over the stored daily heart-rate training impulse (Banister TRIMP; a day with activity data but no training is 0, a day with no data or an unscored workout is not rated and the averages hold through it). Needs 28 days and three training days. TRIMP units: comparable with the person's own past only." GetWorkoutResponse: type: object properties: data: $ref: "#/components/schemas/WorkoutDetailResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false WorkoutDetailResponse: type: object properties: id: type: string sportType: type: string startedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ endedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ dayKey: type: string durationSec: type: integer minimum: 0 maximum: 9007199254740991 distanceM: type: - number - "null" activeEnergyKcal: type: - number - "null" avgHr: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" maxHr: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" minHr: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" storedAvgHr: description: The row's own average heart rate (v1.42). `avgHr` may be filled in from a same-session twin by the canonical picker; this is the stored value, which an edit of a hand-entered workout sends back so the twin's value is not written onto it. anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" storedMaxHr: description: The row's own maximum heart rate (v1.42), as `storedAvgHr` is to `avgHr`. anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" stepCount: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" elevationM: type: - number - "null" pauseDurationSec: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" source: $ref: "#/components/schemas/MeasurementSource" externalId: type: - string - "null" metadata: anyOf: - {} - type: "null" route: anyOf: - $ref: "#/components/schemas/WorkoutRouteGeometry" - type: "null" samples: anyOf: - $ref: "#/components/schemas/WorkoutHrSeries" - type: "null" hrSeries: anyOf: - $ref: "#/components/schemas/WorkoutHrCurve" - type: "null" zones: anyOf: - $ref: "#/components/schemas/WorkoutZones" - type: "null" splits: anyOf: - type: array items: $ref: "#/components/schemas/WorkoutSplit" - type: "null" sportContext: anyOf: - $ref: "#/components/schemas/WorkoutSportContext" - type: "null" aiInsight: anyOf: - $ref: "#/components/schemas/WorkoutActivityInsight" - type: "null" ai: description: The `workoutInsights` AI capability. While it is unavailable `aiInsight` is null and the stored paragraph is not even read. $ref: "#/components/schemas/AiCapabilityState" canonicalId: type: string previousWorkoutId: type: - string - "null" required: - id - sportType - startedAt - endedAt - dayKey - durationSec - distanceM - activeEnergyKcal - avgHr - maxHr - minHr - stepCount - elevationM - pauseDurationSec - source - externalId - metadata - route - samples - hrSeries - zones - splits - sportContext - aiInsight - ai - canonicalId - previousWorkoutId additionalProperties: false WorkoutRouteGeometry: type: object properties: geometry: {} sampleTimestamps: anyOf: - type: array items: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: object properties: t: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ speedMs: type: number hr: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - t additionalProperties: false - type: "null" description: "One entry per geometry point, as the track was stored: a bare instant, or (from the workout batch and the Apple Health export) an object with its instant and, where recorded, speed (m/s) and heart rate (bpm)." required: - geometry - sampleTimestamps additionalProperties: false WorkoutHrSeries: type: object properties: sampleCount: type: integer minimum: 0 maximum: 9007199254740991 samples: {} required: - sampleCount - samples additionalProperties: false WorkoutHrCurve: type: object properties: source: type: string enum: - workout_series - pulse_window bucketSec: type: integer exclusiveMinimum: 0 maximum: 9007199254740991 points: type: array items: type: object properties: tSec: type: integer minimum: 0 maximum: 9007199254740991 mean: type: integer minimum: -9007199254740991 maximum: 9007199254740991 min: type: integer minimum: -9007199254740991 maximum: 9007199254740991 max: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - tSec - mean - min - max additionalProperties: false envelope: type: boolean required: - source - bucketSec - points - envelope additionalProperties: false WorkoutZones: type: object properties: model: type: string enum: - whoop - tanaka hrMax: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" zones: type: array items: type: object properties: zone: type: integer minimum: 1 maximum: 5 lowBpm: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" highBpm: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" seconds: type: integer minimum: 0 maximum: 9007199254740991 required: - zone - lowBpm - highBpm - seconds additionalProperties: false required: - model - hrMax - zones additionalProperties: false WorkoutSplit: type: object properties: km: type: integer exclusiveMinimum: 0 maximum: 9007199254740991 durationSec: type: integer minimum: 0 maximum: 9007199254740991 paceSecPerKm: type: integer minimum: 0 maximum: 9007199254740991 required: - km - durationSec - paceSecPerKm additionalProperties: false WorkoutSportContext: type: object properties: count: type: integer minimum: 0 maximum: 9007199254740991 avgDurationSec: type: integer minimum: 0 maximum: 9007199254740991 avgDistanceM: type: - number - "null" avgAvgHr: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" required: - count - avgDurationSec - avgDistanceM - avgAvgHr additionalProperties: false WorkoutActivityInsight: type: object properties: paragraph: type: string generatedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - paragraph - generatedAt additionalProperties: false AiUnavailableReason: type: string enum: - check_failed - operator_disabled - not_permitted_for_record - module_disabled - user_disabled - usage_level - no_provider - consent_required description: "Why an AI capability is unavailable, listed in precedence order; only the outermost reason that applies is reported, because it names the layer that would have to change first. `check_failed` — an input could not be read and the answer failed closed. `operator_disabled` — the operator's master switch, the capability's own switch, or the operator's instance-wide availability of its module. `not_permitted_for_record` — AI work is not admitted for this record (a delegate inside somebody else's record). `module_disabled` — an owning module is off for this record, by its own switch or by the edge of the active grant. `user_disabled` — the record's own AI opt-out: the Coach preference for the Coach, the `insights` module (AI analysis) for everything else. `usage_level` — the record's AI usage level (`ai.usage.level`, v1.43) is below what the capability needs; today only `supplementLabel` on `frugal`. `no_provider` — no configured provider can serve this capability's input. `consent_required` — the provider chain needs an AI consent receipt and none is active. The list is closed; a client that meets a value it does not know treats the capability as unavailable." AiCapabilityState: type: object properties: available: type: boolean reason: anyOf: - $ref: "#/components/schemas/AiUnavailableReason" - type: "null" description: "`null` exactly when `available` is true." onDeviceAllowed: type: boolean description: "Whether an on-device model may do this work. False for `operator_disabled`, `not_permitted_for_record`, `module_disabled`, `user_disabled`, `usage_level` and `check_failed`: the operator's and the person's decisions hold on the device too. True otherwise, since a missing server provider or a missing consent for server egress do not concern a model that runs on the device. Resolved here so the client never decides which reasons apply off the server." required: - available - reason - onDeviceAllowed additionalProperties: false description: "One AI capability, resolved for one record from every layer that can say no. Render from it; never recompute it. Data never depends on it: measurements, scores, statistics and device records load whatever it says." DeleteWorkoutResponse: type: object properties: data: type: object properties: deleted: type: boolean const: true required: - deleted additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false BatchWorkoutsResponse: type: object properties: data: $ref: "#/components/schemas/WorkoutBatchResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false WorkoutBatchResponse: type: object properties: processed: type: integer minimum: 0 maximum: 9007199254740991 description: Entries in the request. Equals `inserted + duplicates + skipped.length`. inserted: type: integer minimum: 0 maximum: 9007199254740991 description: Workout rows this request wrote. duplicates: type: integer minimum: 0 maximum: 9007199254740991 description: Entries that wrote no workout row because the workout was already stored. The top-level counters count workout rows, so an entry reported as `enriched` counts here as well even though its heart-rate series was attached. skipped: type: array items: type: object properties: index: type: integer minimum: 0 maximum: 9007199254740991 reason: type: string required: - index - reason additionalProperties: false entries: type: array items: $ref: "#/components/schemas/WorkoutBatchEntryResult" required: - processed - inserted - duplicates - skipped - entries additionalProperties: false WorkoutBatchEntryResult: type: object properties: index: type: integer minimum: 0 maximum: 9007199254740991 status: type: string enum: - inserted - duplicate - enriched - updated - skipped description: "`inserted`: a new workout row. `duplicate`: the workout was already stored and nothing was written. `enriched`: the workout was already stored and the heart-rate series this entry carried was attached to it; the workout row itself was not written, and the entry also counts towards the top-level `duplicates`. `updated` (since v1.40.0): a workout entered by hand (`source: MANUAL`, `externalId` starting `manual:`) was sent again with different values and the stored row now carries them; no row was inserted, so it also counts towards `duplicates`. `skipped`: the server refused the entry, see `reason`." reason: description: "Why a `skipped` entry was refused: `unstable_external_id` for an id that does not survive a client restart, `invalid_samples` for a heart-rate series that failed validation." type: string required: - index - status additionalProperties: false ActivityCalendarEnvelope: type: object properties: data: $ref: "#/components/schemas/ActivityCalendar" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false ActivityCalendar: type: object properties: period: type: string enum: - week - month start: type: string format: date pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ end: type: string format: date pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ gridStart: type: string format: date pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ description: First day drawn (the Monday on or before `start`). gridEnd: type: string format: date pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ description: Last day drawn (the Sunday on or after `end`). today: type: string format: date pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ timezone: type: string description: The IANA zone the days are cut in. loadType: anyOf: - type: string enum: - DAY_STRAIN - CARDIO_LOAD - STRAIN_SCORE - type: "null" description: Which training-load score `load` carries; null when the account has none. days: type: array items: type: object properties: date: type: string format: date pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ inPeriod: type: boolean future: type: boolean steps: type: - number - "null" activeMinutes: description: The larger of the day's exercise minutes and workout minutes (never their sum). type: - number - "null" workoutCount: type: integer minimum: -9007199254740991 maximum: 9007199254740991 workoutsBySport: type: object propertyNames: type: string additionalProperties: type: integer minimum: -9007199254740991 maximum: 9007199254740991 load: type: - number - "null" level: type: object properties: steps: type: string enum: - none - unrated - in_progress - low - below - usual - above - high description: "Where the day sits in the person's own spread over the 84 days before the period: `low` below their 10th percentile, `below` below the 25th, `above` above the 75th, `high` above the 90th, `usual` between. `none` = no value that day; `unrated` = fewer than 14 days of history to compare with; `in_progress` = today, which has not ended and is never rated." activeMinutes: type: string enum: - none - unrated - in_progress - low - below - usual - above - high description: "Where the day sits in the person's own spread over the 84 days before the period: `low` below their 10th percentile, `below` below the 25th, `above` above the 75th, `high` above the 90th, `usual` between. `none` = no value that day; `unrated` = fewer than 14 days of history to compare with; `in_progress` = today, which has not ended and is never rated." load: type: string enum: - none - unrated - in_progress - low - below - usual - above - high description: "Where the day sits in the person's own spread over the 84 days before the period: `low` below their 10th percentile, `below` below the 25th, `above` above the 75th, `high` above the 90th, `usual` between. `none` = no value that day; `unrated` = fewer than 14 days of history to compare with; `in_progress` = today, which has not ended and is never rated." required: - steps - activeMinutes - load additionalProperties: false required: - date - inPeriod - future - steps - activeMinutes - workoutCount - workoutsBySport - load - level additionalProperties: false weeks: type: array items: type: object properties: start: type: string format: date pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ end: type: string format: date pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ steps: type: - number - "null" activeMinutes: type: - number - "null" workouts: type: integer minimum: -9007199254740991 maximum: 9007199254740991 goals: type: array items: $ref: "#/components/schemas/ActivityGoalProgress" description: The weekly goals for a week that has begun. required: - start - end - steps - activeMinutes - workouts - goals additionalProperties: false baseline: type: object properties: steps: $ref: "#/components/schemas/ActivityBaselineStats" activeMinutes: $ref: "#/components/schemas/ActivityBaselineStats" load: $ref: "#/components/schemas/ActivityBaselineStats" required: - steps - activeMinutes - load additionalProperties: false summary: type: object properties: steps: $ref: "#/components/schemas/ActivityPeriodComparison" activeMinutes: $ref: "#/components/schemas/ActivityPeriodComparison" workouts: $ref: "#/components/schemas/ActivityPeriodComparison" load: $ref: "#/components/schemas/ActivityPeriodComparison" required: - steps - activeMinutes - workouts - load additionalProperties: false sports: type: array items: type: object properties: sportType: type: string count: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - sportType - count additionalProperties: false goals: $ref: "#/components/schemas/ActivityGoals" goalProgress: type: array items: $ref: "#/components/schemas/ActivityGoalProgress" available: type: object properties: steps: type: boolean activeMinutes: type: boolean workouts: type: boolean load: type: boolean required: - steps - activeMinutes - workouts - load additionalProperties: false previous: type: string format: date pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ description: A day in the previous period. next: anyOf: - type: string format: date pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ - type: "null" description: A day in the next period, or null when that lies after today. required: - period - start - end - gridStart - gridEnd - today - timezone - loadType - days - weeks - baseline - summary - sports - goals - goalProgress - available - previous - next additionalProperties: false ActivityGoalProgress: type: object properties: key: type: string enum: - workoutsPerWeek - activeMinutesPerWeek - stepsPerWeek - stepsPerMonth target: type: integer minimum: -9007199254740991 maximum: 9007199254740991 value: type: number met: type: boolean required: - key - target - value - met additionalProperties: false ActivityBaselineStats: anyOf: - type: object properties: median: type: number p10: type: number p25: type: number p75: type: number p90: type: number days: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Days with a value the spread was computed from. required: - median - p10 - p25 - p75 - p90 - days additionalProperties: false - type: "null" ActivityPeriodComparison: type: object properties: value: description: "The period so far: a total (steps, active minutes, workouts) or a mean (load)." type: - number - "null" daysWithData: type: integer minimum: -9007199254740991 maximum: 9007199254740991 completedDays: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Days of the period that have ended (today excluded). completedValue: description: "`value` over the completed days only. This, not `value`, is what `usualToDate` is compared with, so a running period is never judged on today's partial day." type: - number - "null" usual: description: "The person's usual full period: the median of the twelve weeks (or, per day, of the six months) before it. Null with fewer than three earlier periods of data." type: - number - "null" usualToDate: description: The usual figure for the completed days (today excluded); equals `usual` once the period is over, null before the period's first day has ended. type: - number - "null" referencePeriods: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - value - daysWithData - completedDays - completedValue - usual - usualToDate - referencePeriods additionalProperties: false ActivityGoals: type: object properties: workoutsPerWeek: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" activeMinutesPerWeek: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" stepsPerWeek: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" stepsPerMonth: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" required: - workoutsPerWeek - activeMinutesPerWeek - stepsPerWeek - stepsPerMonth additionalProperties: false description: The person's own goals; `null` = not set. There is no default goal. ActivityGoalsEnvelope: type: object properties: data: $ref: "#/components/schemas/ActivityGoals" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false ActivityGoalsPatchEnvelope: type: object properties: data: $ref: "#/components/schemas/ActivityGoals" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DayProvenanceEnvelope: type: object properties: data: $ref: "#/components/schemas/DayProvenance" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DayProvenance: type: object properties: type: type: string enum: - WEIGHT - BLOOD_PRESSURE_SYS - BLOOD_PRESSURE_DIA - PULSE - BODY_FAT - SLEEP_DURATION - ACTIVITY_STEPS - BLOOD_GLUCOSE - TOTAL_BODY_WATER - BONE_MASS - OXYGEN_SATURATION - HEART_RATE_VARIABILITY - RESTING_HEART_RATE - ACTIVE_ENERGY_BURNED - FLIGHTS_CLIMBED - WALKING_RUNNING_DISTANCE - VO2_MAX - BODY_TEMPERATURE - FAT_FREE_MASS - FAT_MASS - MUSCLE_MASS - SKIN_TEMPERATURE - PULSE_WAVE_VELOCITY - VASCULAR_AGE - VISCERAL_FAT - AUDIO_EXPOSURE_ENV - AUDIO_EXPOSURE_HEADPHONE - TIME_IN_DAYLIGHT - WALKING_STEADINESS - AUDIO_EXPOSURE_EVENT - RESPIRATORY_RATE - BODY_MASS_INDEX - LEAN_BODY_MASS - WALKING_HEART_RATE_AVERAGE - WALKING_ASYMMETRY - WALKING_DOUBLE_SUPPORT - WALKING_STEP_LENGTH - WALKING_SPEED - CARDIO_RECOVERY - WRIST_TEMPERATURE - FALL_COUNT - SIX_MINUTE_WALK_DISTANCE - STAIR_ASCENT_SPEED - STAIR_DESCENT_SPEED - BREATHING_DISTURBANCES - IRREGULAR_RHYTHM_NOTIFICATION - HIGH_HEART_RATE_EVENT - LOW_HEART_RATE_EVENT - WALKING_STEADINESS_EVENT - BREATHING_DISTURBANCE_EVENT - RECOVERY_SCORE - STRESS_SCORE - STRAIN_SCORE - HRV_RMSSD - DAY_STRAIN - WORKOUT_STRAIN - SLEEP_PERFORMANCE - SLEEP_EFFICIENCY - SLEEP_CONSISTENCY - SLEEP_NEED - ENERGY_EXPENDITURE_KJ - AVERAGE_HEART_RATE - MAX_HEART_RATE - SLEEP_DISTURBANCE_COUNT - ANS_CHARGE - CARDIO_LOAD - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION - RESILIENCE - PHQ9_SCORE - GAD7_SCORE - GRIP_STRENGTH - PAIN_NRS - WAIST_CIRCUMFERENCE - WAIST_TO_HEIGHT - WHO5_SCORE - SCI_SCORE - EXERCISE_MINUTES - STAND_MINUTES - STAND_HOURS - MINDFUL_MINUTES - STATE_OF_MIND_VALENCE - AFIB_BURDEN - INDOOR_CO2 - INDOOR_TEMPERATURE - INDOOR_HUMIDITY date: type: string format: date pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ aggregation: type: string enum: - sum - mean ranked: type: boolean metricKey: type: - string - "null" ladder: type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT picked: anyOf: - type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT - type: "null" pickReason: anyOf: - type: string enum: - only_source - highest_on_ladder - name_tiebreak - not_ranked description: "`only_source`: one source that day. `highest_on_ladder`: it stands highest on the ladder among the day's sources. `name_tiebreak`: none of them is on the ladder, the source name decided. `not_ranked`: this metric has no ladder, every source counts." - type: "null" sources: type: array items: type: object properties: source: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT rank: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Position on the ladder, 0 = highest; 90 = not on the ladder. readings: type: integer minimum: -9007199254740991 maximum: 9007199254740991 value: description: "The day's value from this source: the day total for a cumulative metric (sleep counts asleep stages only), the mean of the readings otherwise." type: - number - "null" min: type: - number - "null" max: type: - number - "null" firstAt: type: string description: ISO instant of the first reading that day. lastAt: type: string unit: type: - string - "null" devices: type: array items: type: string description: Device kinds the readings carry; `unknown` when untagged. pass: anyOf: - type: string enum: - lower_on_ladder - not_on_ladder - name_tiebreak - type: "null" description: Why this source was not the one picked; null for the picked one. required: - source - rank - readings - value - min - max - firstAt - lastAt - unit - devices - pass additionalProperties: false required: - type - date - aggregation - ranked - metricKey - ladder - picked - pickReason - sources additionalProperties: false SourceComparisonEnvelope: type: object properties: data: $ref: "#/components/schemas/SourceComparison" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false SourceComparison: type: object properties: type: type: string enum: - WEIGHT - BLOOD_PRESSURE_SYS - BLOOD_PRESSURE_DIA - PULSE - BODY_FAT - SLEEP_DURATION - ACTIVITY_STEPS - BLOOD_GLUCOSE - TOTAL_BODY_WATER - BONE_MASS - OXYGEN_SATURATION - HEART_RATE_VARIABILITY - RESTING_HEART_RATE - ACTIVE_ENERGY_BURNED - FLIGHTS_CLIMBED - WALKING_RUNNING_DISTANCE - VO2_MAX - BODY_TEMPERATURE - FAT_FREE_MASS - FAT_MASS - MUSCLE_MASS - SKIN_TEMPERATURE - PULSE_WAVE_VELOCITY - VASCULAR_AGE - VISCERAL_FAT - AUDIO_EXPOSURE_ENV - AUDIO_EXPOSURE_HEADPHONE - TIME_IN_DAYLIGHT - WALKING_STEADINESS - AUDIO_EXPOSURE_EVENT - RESPIRATORY_RATE - BODY_MASS_INDEX - LEAN_BODY_MASS - WALKING_HEART_RATE_AVERAGE - WALKING_ASYMMETRY - WALKING_DOUBLE_SUPPORT - WALKING_STEP_LENGTH - WALKING_SPEED - CARDIO_RECOVERY - WRIST_TEMPERATURE - FALL_COUNT - SIX_MINUTE_WALK_DISTANCE - STAIR_ASCENT_SPEED - STAIR_DESCENT_SPEED - BREATHING_DISTURBANCES - IRREGULAR_RHYTHM_NOTIFICATION - HIGH_HEART_RATE_EVENT - LOW_HEART_RATE_EVENT - WALKING_STEADINESS_EVENT - BREATHING_DISTURBANCE_EVENT - RECOVERY_SCORE - STRESS_SCORE - STRAIN_SCORE - HRV_RMSSD - DAY_STRAIN - WORKOUT_STRAIN - SLEEP_PERFORMANCE - SLEEP_EFFICIENCY - SLEEP_CONSISTENCY - SLEEP_NEED - ENERGY_EXPENDITURE_KJ - AVERAGE_HEART_RATE - MAX_HEART_RATE - SLEEP_DISTURBANCE_COUNT - ANS_CHARGE - CARDIO_LOAD - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION - RESILIENCE - PHQ9_SCORE - GAD7_SCORE - GRIP_STRENGTH - PAIN_NRS - WAIST_CIRCUMFERENCE - WAIST_TO_HEIGHT - WHO5_SCORE - SCI_SCORE - EXERCISE_MINUTES - STAND_MINUTES - STAND_HOURS - MINDFUL_MINUTES - STATE_OF_MIND_VALENCE - AFIB_BURDEN - INDOOR_CO2 - INDOOR_TEMPERATURE - INDOOR_HUMIDITY from: type: string format: date pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ to: type: string format: date pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ aggregation: type: string enum: - sum - mean ranked: type: boolean ladder: type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT unit: type: - string - "null" sources: type: array items: type: object properties: source: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT days: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - source - days additionalProperties: false a: anyOf: - type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT - type: "null" b: anyOf: - type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT - type: "null" days: type: array items: type: object properties: date: type: string format: date pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ a: type: - number - "null" b: type: - number - "null" picked: anyOf: - type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT - type: "null" required: - date - a - b - picked additionalProperties: false summary: type: object properties: daysA: type: integer minimum: -9007199254740991 maximum: 9007199254740991 daysB: type: integer minimum: -9007199254740991 maximum: 9007199254740991 daysBoth: type: integer minimum: -9007199254740991 maximum: 9007199254740991 meanA: type: - number - "null" meanB: type: - number - "null" meanDifference: type: - number - "null" meanAbsoluteDifference: type: - number - "null" pickedA: type: integer minimum: -9007199254740991 maximum: 9007199254740991 pickedB: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - daysA - daysB - daysBoth - meanA - meanB - meanDifference - meanAbsoluteDifference - pickedA - pickedB additionalProperties: false required: - type - from - to - aggregation - ranked - ladder - unit - sources - a - b - days - summary additionalProperties: false DeviceRegisterResponse: type: object properties: data: type: object properties: id: type: string required: - id additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DeviceRevokeEnvelope: type: object properties: data: $ref: "#/components/schemas/DeviceRevokeResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DeviceRevokeResponse: type: object properties: id: type: string revoked: type: boolean const: true refreshTokensRevoked: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Refresh tokens the cascade revoked. accessTokensRevoked: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Access tokens the cascade revoked. required: - id - revoked - refreshTokensRevoked - accessTokensRevoked additionalProperties: false description: What the revocation cascade actually removed. The counts let a client say how many credentials were killed rather than only that something happened. MedicationIntakeAggregateEnvelope: type: object properties: data: anyOf: - type: array items: $ref: "#/components/schemas/TodayIntakeEntry" - type: array items: $ref: "#/components/schemas/MedicationComplianceDayBucket" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false TodayIntakeEntry: type: object properties: id: type: string description: Intake-event id. medicationId: type: string scheduledAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ description: The slot the dose belongs to — the event's `scheduledFor`, renamed on this wire only. takenAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" status: type: string enum: - skipped - taken - missed - pending - snoozed description: "Resolved in that precedence order. `missed` is TERMINAL — the nightly cron closed a never-acted slot — and is not a `pending` that can still be answered. `snoozed` is not a property of this event at all: it reflects the MEDICATION's deferral stamp still lying in the future, so every pending dose of that medication reads snoozed at once." snoozedUntil: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" description: The medication's deferral stamp, repeated on each of its events. Null when the medication is not deferred. required: - id - medicationId - scheduledAt - takenAt - status - snoozedUntil additionalProperties: false description: One of today's dose slots, flattened for the dose sheet and the dashboard tile. A deliberately narrow projection — not the full intake-event row. MedicationComplianceDayBucket: type: object properties: date: type: string description: "`YYYY-MM-DD` in the account's timezone." scheduled: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: "Doses the SCHEDULE expected that day, from the recurrence engine — not the count of logged rows. That distinction is the point of the field: counting rows made every window read ~100 % because each logged dose was both numerator and denominator." taken: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Doses actually taken that day. required: - date - scheduled - taken additionalProperties: false description: One day of schedule-anchored compliance. Paused days drop out of the denominator, and archived schedule eras are honoured, so `scheduled` reflects what was actually expected at the time. MedicationIntakeStatusEnvelope: type: object properties: data: $ref: "#/components/schemas/MedicationIntakeEvent" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MedicationIntakeEvent: type: object properties: id: type: string userId: type: string medicationId: type: string scheduledFor: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ takenAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" skipped: type: boolean autoMissed: type: boolean description: True when the nightly cron closed the slot as missed (no user action). Auto-missed rows count against adherence but never consume inventory. autoRecorded: type: boolean description: v1.42.2 — true when the server recorded this dose because the medication has `autoMarkTaken` on, not the person. It counts as taken everywhere (adherence, history, Coach) and consumed inventory like a manual take. Any take or skip the person records on the row clears it. Clients label such a dose "recorded automatically". attributionSource: type: string enum: - AUTO - USER_PIN description: "How the row bound to its schedule slot: AUTO = the write path's nearest-slot resolution; USER_PIN = the user explicitly pinned the slot (the dedup converge keeps the pinned row)." source: type: string enum: - WEB - API - REMINDER - IMPORT - APPLE_HEALTH idempotencyKey: type: - string - "null" createdAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ updatedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ injectionSite: anyOf: - type: string enum: - ABDOMEN_LEFT - ABDOMEN_RIGHT - ABDOMEN_UPPER_LEFT - ABDOMEN_UPPER_RIGHT - THIGH_LEFT - THIGH_RIGHT - UPPER_ARM_LEFT - UPPER_ARM_RIGHT - type: "null" description: Recorded injection site for a site-tracked medication (GLP-1 rotation surface). NULL when the medication does not track sites or none was recorded. doseTaken: description: Free-text dose actually taken when it differed from the scheduled dose (titration weeks, split doses). NULL = the scheduled dose. type: - string - "null" inventoryConsumption: anyOf: - {} - type: "null" description: JSON ledger of the container decrements this intake caused (`[{itemId, units}]`), written by the consumption hook. NULL when nothing was consumed (skipped / no tracked inventory). externalId: description: v1.28 — client-supplied stable id for externally-mirrored intakes (Apple Health sync); the dedup key for re-synced rows. NULL for native rows. type: - string - "null" syncVersion: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Monotonic per-row version for incremental sync readers; bumps on every mutation. deletedAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" description: Soft-delete tombstone. Non-null rows are excluded from every list/aggregate read; sync readers use it to propagate deletions. required: - id - userId - medicationId - scheduledFor - takenAt - skipped - autoMissed - autoRecorded - attributionSource - source - idempotencyKey - createdAt - updatedAt - injectionSite - doseTaken - inventoryConsumption - externalId - syncVersion - deletedAt additionalProperties: false description: Single dose log row — the FULL row shape both the intake POST (201/200) and the intake list GET return. `takenAt` is non-null for confirmed intakes; `skipped:true` represents a deliberately-missed dose (no inventory consumption). Glp1DetailResponseEnvelope: type: object properties: data: $ref: "#/components/schemas/Glp1DetailResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false Glp1DetailResponse: type: object properties: doseChanges: type: array items: $ref: "#/components/schemas/Glp1DoseChange" description: Full titration history, `effectiveFrom` ascending. recentIntakes: type: array items: type: object properties: takenAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" injectionSite: description: Recorded site, or null when none was captured. type: - string - "null" required: - takenAt - injectionSite additionalProperties: false description: The last twelve taken doses, newest first, for the site-rotation view. inventory: anyOf: - $ref: "#/components/schemas/Glp1Inventory" - type: "null" description: Null when the medication has neither containers nor a usable legacy ledger — an unknown supply, which is not zero supply. required: - doseChanges - recentIntakes - inventory additionalProperties: false description: "The GLP-1 card's extras: titration history, recent injections with their sites, and the running supply math." Glp1DoseChange: type: object properties: id: type: string effectiveFrom: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ doseValue: type: number doseUnit: type: string note: description: Titration note, decrypted on read. Null when none was recorded. type: - string - "null" required: - id - effectiveFrom - doseValue - doseUnit - note additionalProperties: false description: One titration step, oldest first. The note is encrypted at rest and the plaintext column is left null on every write this endpoint makes. Glp1Inventory: type: object properties: pensRemaining: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" description: Usable containers (ACTIVE or IN_USE with units left). dosesRemaining: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" description: Pooled units divided by the medication's `unitsPerDose`, floored — consumption spills across containers. weeksOfSupply: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" description: Equal to `dosesRemaining`. It is the weekly-cadence approximation the canonical GLP-1 case makes, and it is NOT re-derived for a medication on any other cadence. lowStock: type: boolean description: The same reorder-lead-aware runway evaluation the daily low-stock notification runs, so the card and the notification cannot disagree. False whenever the low-stock alert is switched off, and false when no schedule consumes the medication (no consumption means no runway, which is not the same as plenty). required: - pensRemaining - dosesRemaining - weeksOfSupply - lowStock additionalProperties: false description: Running supply math. Computed over the per-container entities when any exist; otherwise, and only then, over the legacy running-sum ledger. Containers always win — the ledger never overrides them. Glp1PostResponseEnvelope: type: object properties: data: anyOf: - type: object properties: doseChange: $ref: "#/components/schemas/Glp1DoseChange" required: - doseChange additionalProperties: false - type: object properties: inventory: $ref: "#/components/schemas/Glp1InventoryEvent" required: - inventory additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false Glp1InventoryEvent: type: object properties: id: type: string medicationId: type: string delta: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Positive added, negative consumed. reason: type: string occurredAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - id - medicationId - delta - reason - occurredAt additionalProperties: false description: A row of the DEPRECATED running-sum inventory ledger. Register containers through the inventory endpoints instead; reads fall back to this ledger only while a medication has no containers at all. MedicationApiEndpointStateEnvelope: type: object properties: data: $ref: "#/components/schemas/MedicationApiEndpointState" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MedicationApiEndpointState: type: object properties: enabled: type: boolean description: True when at least one live token carries this scope. activeTokenCount: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Live, unrevoked, unexpired tokens scoped to this medication. Normally 0 or 1. required: - enabled - activeTokenCount additionalProperties: false description: Presence only. No token, hash or prefix is on this wire — the stored value is an HMAC and there is no path back to the plaintext. MedicationApiEndpointPutEnvelope: type: object properties: data: anyOf: - $ref: "#/components/schemas/MedicationApiEndpointDisabled" - $ref: "#/components/schemas/MedicationApiEndpointEnabled" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MedicationApiEndpointDisabled: type: object properties: enabled: type: boolean const: false revokedTokenCount: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: How many live tokens this call revoked. required: - enabled - revokedTokenCount additionalProperties: false description: "The answer to disabling. Note the asymmetry: this shape carries `revokedTokenCount` and NOT `activeTokenCount`, so a client decoding one shape for both PUT outcomes will find the key missing." MedicationApiEndpointEnabled: type: object properties: enabled: type: boolean const: true activeTokenCount: type: integer minimum: -9007199254740991 maximum: 9007199254740991 token: description: The raw `hlk_<64 hex>` token, and the ONLY time it is ever returned. Null when the endpoint was already enabled and nothing was minted — check `created` rather than inferring from this. Store it on receipt; it is hashed at rest and cannot be shown again. type: - string - "null" created: type: boolean description: True when this call minted a token, false when one existed. required: - enabled - activeTokenCount - token - created additionalProperties: false description: "The answer to enabling the endpoint. A fresh mint is 201 with the token; an already-enabled endpoint is 200 with `token: null` and `created: false`." MedicationApiEndpointCreatedEnvelope: type: object properties: data: $ref: "#/components/schemas/MedicationApiEndpointEnabled" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false ReminderPhaseConfigEnvelope: type: object properties: data: $ref: "#/components/schemas/ReminderPhaseConfig" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false ReminderPhaseConfig: type: object properties: id: description: Row id. ABSENT when the read fell through to defaults, because no row exists — which is how a client tells a stored configuration from the fallback. type: string medicationId: description: Absent on the defaults. type: string greenValue: type: integer minimum: -9007199254740991 maximum: 9007199254740991 greenMode: type: string enum: - MINUTES - PERCENT yellowValue: type: integer minimum: -9007199254740991 maximum: 9007199254740991 yellowMode: type: string enum: - MINUTES - PERCENT orangeValue: type: integer minimum: -9007199254740991 maximum: 9007199254740991 orangeMode: type: string enum: - MINUTES - PERCENT redValue: type: integer minimum: -9007199254740991 maximum: 9007199254740991 redMode: type: string enum: - MINUTES - PERCENT required: - greenValue - greenMode - yellowValue - yellowMode - orangeValue - orangeMode - redValue - redMode additionalProperties: false description: How long each reminder phase runs for one medication. `MINUTES` is an absolute offset; `PERCENT` is a share of the dose window. Values are 0..1440. ReminderPhaseConfigSavedEnvelope: type: object properties: data: $ref: "#/components/schemas/ReminderPhaseConfig" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false ReminderPhaseConfigResetEnvelope: type: object properties: data: type: object properties: reset: type: boolean const: true required: - reset additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false ListMedicationSideEffectsEnvelope: type: object properties: data: type: object properties: items: type: array items: $ref: "#/components/schemas/MedicationSideEffect" meta: type: object properties: total: type: integer minimum: 0 maximum: 9007199254740991 required: - total additionalProperties: false required: - items - meta additionalProperties: false error: type: "null" meta: $ref: "#/components/schemas/ListCapMeta" required: - data - error - meta additionalProperties: false MedicationSideEffect: type: object properties: id: type: string userId: type: string description: The RECORD owner, which is not the actor on a delegated write. medicationId: type: string occurredAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ category: type: string description: Derived SERVER-side from `entry` through the authoritative taxonomy. Never accepted from a client — a category sent on the wire is dropped, so a row cannot be stamped with one that contradicts its entry. entry: type: string description: The catalogue entry. Closed Prisma enum. severity: type: integer minimum: 1 maximum: 5 notes: description: The free-text note, decrypted on read. Stored encrypted; the ciphertext column is stripped before the row leaves the server. type: - string - "null" createdAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - id - userId - medicationId - occurredAt - category - entry - severity - notes - createdAt additionalProperties: false description: One logged side effect. Not a clinical record — the account owns it, and it stays deletable at any time rather than locking after a retraction window. CreateMedicationSideEffectEnvelope: type: object properties: data: $ref: "#/components/schemas/MedicationSideEffect" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DeleteMedicationSideEffectEnvelope: type: object properties: data: type: object properties: id: type: string deleted: type: boolean const: true required: - id - deleted additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false UpdateMedicationIntakeEventEnvelope: type: object properties: data: $ref: "#/components/schemas/MedicationIntakeEvent" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DeleteMedicationIntakeEventEnvelope: type: object properties: data: type: object properties: deleted: type: boolean const: true required: - deleted additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false BulkDeleteMedicationIntakeEnvelope: type: object properties: data: type: object properties: deleted: type: integer minimum: 0 maximum: 9007199254740991 required: - deleted additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false PurgeMedicationIntakeEnvelope: type: object properties: data: type: object properties: purged: type: boolean const: true count: type: integer minimum: 0 maximum: 9007199254740991 required: - purged - count additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MedicationIntakeImportAcceptedEnvelope: type: object properties: data: type: object properties: jobId: type: string status: type: string const: queued statusUrl: type: string required: - jobId - status - statusUrl additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false BulkMedicationIntakeResponseEnvelope: type: object properties: data: $ref: "#/components/schemas/BulkMedicationIntakeResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false BulkMedicationIntakeResponse: type: object properties: processed: type: integer minimum: 0 maximum: 9007199254740991 inserted: type: integer minimum: 0 maximum: 9007199254740991 updated: type: integer minimum: 0 maximum: 9007199254740991 duplicates: type: integer minimum: 0 maximum: 9007199254740991 skipped: type: array items: type: object properties: index: type: integer minimum: 0 maximum: 9007199254740991 reason: type: string required: - index - reason additionalProperties: false entries: type: array items: $ref: "#/components/schemas/BulkMedicationIntakeEntryResult" required: - processed - inserted - updated - duplicates - skipped - entries additionalProperties: false BulkMedicationIntakeEntryResult: type: object properties: index: type: integer minimum: 0 maximum: 9007199254740991 status: type: string enum: - inserted - updated - duplicate - skipped description: "`inserted`/`updated`/`duplicate` — the row landed (advance the cursor). `skipped` — not stored; see `reason`." reason: description: "Why a `skipped` entry was not stored. Among others: `medication_not_found`, `unstable_external_id`, `force_slot_invalid`, `force_slot_occupied`, `injection_site_not_allowed`, and (v1.42.0) `intake_not_tracked` for an entry against a medication kept as a record only (`trackIntake: false`), the per-entry form of `medication.intake.notTracked`. A skip is final: resending the entry gives the same answer." type: string id: description: The landed row id (absent on skips). type: string required: - index - status additionalProperties: false ListMedicationsResponse: type: object properties: data: type: array items: $ref: "#/components/schemas/MedicationListEntry" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MedicationListEntry: type: object properties: id: type: string userId: description: The record the row belongs to. type: string name: type: string dose: type: string treatmentClass: $ref: "#/components/schemas/MedicationTreatmentClass" deliveryForm: description: How the medication is taken. A one-time injection is `oneShot` with `INJECTION`. type: string enum: - ORAL - INJECTION - OTHER trackInjectionSites: description: Whether intakes record an injection site. Only offered for `INJECTION`. type: boolean allowedInjectionSites: description: The sites the rotation offers; empty means every site. type: array items: type: string enum: - ABDOMEN_LEFT - ABDOMEN_RIGHT - ABDOMEN_UPPER_LEFT - ABDOMEN_UPPER_RIGHT - THIGH_LEFT - THIGH_RIGHT - UPPER_ARM_LEFT - UPPER_ARM_RIGHT externalSource: anyOf: - type: string const: APPLE_HEALTH - type: "null" description: v1.32.25 — provenance of the medication. `APPLE_HEALTH` marks the row a read-only mirror of the iOS 26+ HealthKit Medications sync; NULL is a native HealthLog medication. Read-only echo of the stored provenance — the create route sets it, the list read surfaces it so mirrored-vs-native is visible without inspecting the iOS client's local registry. externalId: description: The source's own identifier for a mirrored medication, paired with `externalSource`; null for a native one. type: - string - "null" lowStockNotifiedAt: description: When the low-stock alert last fired for this medication; null when it has not since the stock was last topped up. Server-written. anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" lowStockNotifiedThresholdDays: description: The runway threshold, in days, that alert fired at. Server-written. anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" dosesPerUnit: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" description: Doses per pen / vial for inventory tracking. NULL = inventory tracking off. unitsPerDose: type: number description: v1.16.10 — inventory units one dose consumes (e.g. 2 tablets of 2 mg for a 4 mg dose). v1.16.12 — may be a split-pill fraction (¼ / ⅓ / ½ / ⅔ / ¾); thirds carry as ≈0.3333 / 0.6667. v1.39.1 (#1034) — any value above 0 and at most 100 with at most 4 decimal places, so also a whole number plus a fraction (1.5, 2.25, 1.3333) or a measured amount (0.8). Default 1. The intake consumption hook decrements this many units per taken dose; dose-derived readouts divide unit counts by it. reorderLeadDays: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" description: v1.17.0 — optional per-medication reorder lead time in days (0–60). The low-stock alert widens its trigger by this lead plus one dose-interval so a refill arrives before the last dose. null = inherit the user-level notificationPrefs.medication.reorderLeadDays default (10). active: type: boolean notificationsEnabled: type: boolean liveActivityEnabled: type: boolean description: v1.7.0 iOS Live Activity opt-in for this medication's reminders. Default false. The iOS client owns the ActivityKit lifecycle; the server only stores + echoes the flag. criticalAlarmEnabled: type: boolean description: v1.7.0 iOS 26 AlarmKit critical-reminder opt-in. Default false. Critical alarms bypass the device mute switch / Focus; the server stores the preference only. atcCode: description: v1.9.0 optional WHO ATC classification code (active-substance class, e.g. `A10BX10`). User/clinician-asserted; never machine-guessed. Emitted on the FHIR `medicationCodeableConcept` under `http://www.whocc.no/atc`. NULL = no code captured. type: - string - "null" rxNormCode: description: v1.9.0 optional RxNorm RxCUI (numeric, US identifier, e.g. `2601723`). Secondary FHIR coding under `http://www.nlm.nih.gov/research/umls/rxnorm`, alongside any ATC code. NULL = no code captured. type: - string - "null" pausedAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" snoozedUntil: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" remindersActive: type: boolean description: "v1.42.2 — whether the server sends dose reminders for this medication: true only when `notificationsEnabled` is on, the medication is `active`, its course has not ended (`endsOn` is today or later on the person's clock, or unset, and no `endedAt` has passed), its intake is tracked (`trackIntake`), it is not `asNeeded`, and `autoMarkTaken` is off. The same decision gates every server-side reminder send. A client that arms local reminders arms them from this, not from `notificationsEnabled`. Read-only — computed, not stored." nextDueAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" nextDueOverdue: type: boolean startsOn: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" description: v1.5 course start (ISO date). Anchors RRULE BYDAY / BYMONTHDAY patterns and the rolling-interval countdown's first window. NULL means active from creation. endsOn: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" description: v1.5 course end (ISO date). NULL means chronic. Equals `startsOn` when `oneShot` is true. endedAt: description: "#1280 — the instant the latest course was ended with `POST /api/medications/{id}/courses/end` (the course's own `endedAt`). From it on no dose is due or reminded and `courseStatus` is ENDED; NULL when the course runs to the end of `endsOn`. Read-only — written by the course writer only." anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" oneShot: type: boolean description: v1.5 single-administration flag. When true the medication has at most one schedule (no `rrule` / `rollingIntervalDays`), and `active` auto-flips to false once the dose is logged. asNeeded: type: boolean description: "v1.16.11 as-needed (PRN) flag. When true the medication carries ZERO schedules (the write routes 422 on any schedule entry alongside the flag): it is never due (`nextDueAt` stays null), never reminded, and excluded from every compliance rate/streak — but intakes still log as ad-hoc rows, inventory still consumes per `unitsPerDose`, and the history renders. Stays active indefinitely. Mutually exclusive with `oneShot`." trackIntake: type: boolean description: "v1.39.1 — intake tracking. True (the default) for every medication that existed before the field. When false the medication is kept as a record: nothing is ever due (`nextDueAt` stays null), nothing reminds on any channel, and it is excluded from every adherence figure. Its stored schedule then travels in `recordedSchedules` while `schedules` is served empty, so a client that arms reminders from `schedules` stays silent without an update." autoMarkTaken: type: boolean description: "v1.42.2 — doses are recorded automatically. False (the default) for every medication that existed before the field. When true the server records each scheduled dose as taken (an intake with `autoRecorded: true`, `takenAt` = the slot time) shortly after its time passes, from `autoMarkTakenSince` on. A slot the person took, skipped or removed is never touched, and they can still change or skip any dose. No medication reminder is sent for it on any channel while it is on; a client that arms local reminders should not arm them for it either. `notificationsEnabled` keeps the person's own setting and applies again once this is switched off." autoMarkTakenSince: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" description: v1.42.2 — when `autoMarkTaken` was last switched on. No slot anchored before it is ever recorded automatically. NULL while the option is off. Read-only — set by the server on the switch. createdAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ updatedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ schedules: type: array items: $ref: "#/components/schemas/MedicationScheduleOutput" description: "The schedules in force. Empty for an as-needed medication and (v1.39.1) for a medication with `trackIntake: false`, whose stored schedule is in `recordedSchedules`." recordedSchedules: description: "v1.39.1 — present only when `trackIntake` is false: the stored schedule, kept as information. Nothing derives a due dose or a reminder from it. Absent on a tracked medication, whose schedule is `schedules`." type: array items: $ref: "#/components/schemas/MedicationScheduleOutput" category: $ref: "#/components/schemas/MedicationCategory" categoryLabel: description: v1.40 — the decrypted label of a custom category (`category` = `custom:`); null for a built-in category, which a client localises from `category`. Read-only. type: - string - "null" courses: type: array items: $ref: "#/components/schemas/MedicationCourse" description: v1.40 — every course, ascending by start. Empty for a medication taken continuously since creation. Server-resolved; render it, do not recompute it. courseCount: type: integer minimum: 0 maximum: 9007199254740991 previousCourseEndedOn: anyOf: - type: string format: date pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ - type: "null" description: v1.40 — the last day of the most recent course that has ended (answers 'when did I last take it'); null when none has. canStartCourse: type: boolean description: "v1.40 — whether `POST /api/medications/{id}/courses` may start a new course now: the medication is active, not one-time, has a course and every course's last day is before today (a course ended today with `POST /api/medications/{id}/courses/end` is over, but a course starting today would share its last day, so the start is offered from the next day), and the caller is the record's owner (starting a course is not delegable, so a delegate always reads false)." nextDueScheduleId: description: The schedule `nextDueAt` belongs to; null when nothing is due. type: - string - "null" courseStatus: type: string enum: - UPCOMING - CURRENT - ENDED description: "v1.39.4 — where today (on the user's clock) sits in the course: UPCOMING before `startsOn`, ENDED after `endsOn`, CURRENT otherwise. Calendar days, both ends inclusive, so a course that ends today is CURRENT all day, unless it was ended with `POST /api/medications/{id}/courses/end`: then it is ENDED from that instant (`endedAt`). Read-only — computed, not stored." intakeActionable: type: boolean description: "v1.39.4 — whether the medication offers taken / skip today: true only when it is `active`, its intake is tracked (`trackIntake`) and `courseStatus` is CURRENT. Clients render the dose actions from this and do not re-derive it. Logging a past dose from the history is not gated by it. Read-only — computed, not stored." canEndCourse: type: boolean description: '#1280 — whether `POST /api/medications/{id}/courses/end` may end the course now: true exactly while a course is running (`courseStatus` CURRENT; a medication with no dates is running) and the caller may end it: the owner of the record, or a delegate whose grant manages its medications. False to every other caller. Clients offer "End course" from this and do not re-derive it; the action refuses with 409 `medications.course.notRunning` when it is false. Read-only — computed, not stored.' lastTakenAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" description: Latest non-skipped `MedicationIntakeEvent.takenAt` for the medication. Drives the rolling-cadence countdown surface. todayEventCount: type: integer minimum: 0 maximum: 9007199254740991 description: "Number of ACTIONED intake events for today (user-local day window): rows with a recorded `takenAt` or an explicit skip. Pending projector-minted rows do not count — the card overdue-pill suppression compares this against the passed-dose count, and a pending mint must not read as covered." stockUnitsRemaining: description: v1.16.10 — usable inventory units left across the medication's containers (sum of `unitsRemaining` over ACTIVE / IN_USE items with units left). NULL = inventory tracking off (no items ever registered); 0 = tracking on, supply ran out. Read-only — aggregated, not stored. type: - number - "null" stockDosesRemaining: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" description: "v1.16.10 — dose-derived stock as a whole-dose count. v1.37.19 — slot-aware: the divisor is the schedule-weighted average units per dose (each slot's `resolvedUnitsPerDose` weighted by its cadence share), falling back to the medication-level `unitsPerDose` when no schedule derives a consumption rate. `unitsPerDose` may be a fraction (½ tablet ⇒ twice the doses). NULL when inventory tracking is off. Drives the table view's Bestand column. Read-only — aggregated, not stored." runwayDays: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" description: v1.37.19 — projected whole days the usable stock covers under the slot-aware burn rate (the same math the low-stock notification engine runs, so the wire and the push can never disagree). NULL = inventory tracking off or no consuming cadence derivable; 0 = tracking on, supply ran out. Read-only — computed, not stored. hasIngredients: description: v1.43 — whether a composition per unit is stored (`GET /api/medications/{id}` carries it as `ingredients`). Absent on older servers. type: boolean required: - id - name - dose - treatmentClass - externalSource - dosesPerUnit - unitsPerDose - reorderLeadDays - active - notificationsEnabled - liveActivityEnabled - criticalAlarmEnabled - atcCode - rxNormCode - pausedAt - snoozedUntil - remindersActive - nextDueAt - nextDueOverdue - startsOn - endsOn - oneShot - asNeeded - trackIntake - autoMarkTaken - autoMarkTakenSince - createdAt - updatedAt - schedules - category - categoryLabel - courses - courseCount - previousCourseEndedOn - canStartCourse - nextDueScheduleId - courseStatus - intakeActionable - canEndCourse - lastTakenAt - todayEventCount - stockUnitsRemaining - stockDosesRemaining - runwayDays additionalProperties: false description: List-row variant of the medication resource enriched with the joined `category`, the v1.32.25 `externalSource` provenance echo, `lastTakenAt`, `todayEventCount`, and the v1.16.10 aggregated stock fields (`stockUnitsRemaining`, `stockDosesRemaining`) the dashboard + iOS client consume. The base medication fields (`id`, `name`, `dose`, `treatmentClass`, `dosesPerUnit`, `active`, `notificationsEnabled`, `pausedAt`, `snoozedUntil`, `startsOn`, `endsOn`, `oneShot`, `createdAt`, `updatedAt`, `schedules`) are inlined; see the `Medication` component for their semantics. MedicationCategory: type: string description: Clinical category. One of the built-in values (BLOOD_PRESSURE, VITAMIN, SUPPLEMENT, PAIN_RELIEF, ALLERGY, DIGESTIVE, THYROID, HORMONE, SKIN, SLEEP_AID, DIABETES, ANTIBIOTIC, MENTAL_HEALTH, OTHER) or, since v1.40, `custom:`, the key of one of the caller's own categories (see `GET /api/medications/categories`), whose label rides beside it as `categoryLabel`. A client must decode an unknown value tolerantly and must not write `category` back unless the person changed it. Orthogonal to `MedicationTreatmentClass`. MedicationCourse: type: object properties: id: type: string startsOn: type: string format: date pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ description: First day of the course, inclusive (`YYYY-MM-DD`). endsOn: anyOf: - type: string format: date pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ - type: "null" description: Last day, inclusive. NULL = open (only the latest course). endedAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" description: "#1280 — the instant `POST /api/medications/{id}/courses/end` ended this course, on its `endsOn` day. No dose slot at or after it is due, reminded or expected, and the course is ENDED from it on. NULL when the whole `endsOn` day counts (an end date set in the editor). Any edit that moves `endsOn` clears it." status: type: string enum: - UPCOMING - CURRENT - ENDED description: Where today, on the person's clock, sits in this course. A course with `endedAt` is ENDED on its `endsOn` day already. takenDoses: type: integer minimum: 0 maximum: 9007199254740991 description: Doses logged as taken whose slot falls on a day of this course. note: description: The person's note on this course. Encrypted at rest. type: - string - "null" required: - id - startsOn - endsOn - endedAt - status - takenDoses - note additionalProperties: false description: v1.40 (#1024) — one course of a medication. Courses never overlap and every course except the latest has ended, so the medication's `startsOn`/`endsOn`/`courseStatus`/`intakeActionable` are always those of the latest course. CreateMedicationResponse: type: object properties: data: $ref: "#/components/schemas/MedicationDetail" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MedicationDetail: type: object properties: id: type: string userId: description: The record the row belongs to. type: string name: type: string dose: type: string treatmentClass: $ref: "#/components/schemas/MedicationTreatmentClass" deliveryForm: description: How the medication is taken. A one-time injection is `oneShot` with `INJECTION`. type: string enum: - ORAL - INJECTION - OTHER trackInjectionSites: description: Whether intakes record an injection site. Only offered for `INJECTION`. type: boolean allowedInjectionSites: description: The sites the rotation offers; empty means every site. type: array items: type: string enum: - ABDOMEN_LEFT - ABDOMEN_RIGHT - ABDOMEN_UPPER_LEFT - ABDOMEN_UPPER_RIGHT - THIGH_LEFT - THIGH_RIGHT - UPPER_ARM_LEFT - UPPER_ARM_RIGHT externalSource: description: v1.32.25 — provenance of the medication. `APPLE_HEALTH` marks the row a read-only mirror of the iOS 26+ HealthKit Medications sync; NULL is a native HealthLog medication. anyOf: - type: string const: APPLE_HEALTH - type: "null" externalId: description: The source's own identifier for a mirrored medication, paired with `externalSource`; null for a native one. type: - string - "null" lowStockNotifiedAt: description: When the low-stock alert last fired for this medication; null when it has not since the stock was last topped up. Server-written. anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" lowStockNotifiedThresholdDays: description: The runway threshold, in days, that alert fired at. Server-written. anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" dosesPerUnit: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" description: Doses per pen / vial for inventory tracking. NULL = inventory tracking off. unitsPerDose: type: number description: v1.16.10 — inventory units one dose consumes (e.g. 2 tablets of 2 mg for a 4 mg dose). v1.16.12 — may be a split-pill fraction (¼ / ⅓ / ½ / ⅔ / ¾); thirds carry as ≈0.3333 / 0.6667. v1.39.1 (#1034) — any value above 0 and at most 100 with at most 4 decimal places, so also a whole number plus a fraction (1.5, 2.25, 1.3333) or a measured amount (0.8). Default 1. The intake consumption hook decrements this many units per taken dose; dose-derived readouts divide unit counts by it. reorderLeadDays: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" description: v1.17.0 — optional per-medication reorder lead time in days (0–60). The low-stock alert widens its trigger by this lead plus one dose-interval so a refill arrives before the last dose. null = inherit the user-level notificationPrefs.medication.reorderLeadDays default (10). active: type: boolean notificationsEnabled: type: boolean liveActivityEnabled: type: boolean description: v1.7.0 iOS Live Activity opt-in for this medication's reminders. Default false. The iOS client owns the ActivityKit lifecycle; the server only stores + echoes the flag. criticalAlarmEnabled: type: boolean description: v1.7.0 iOS 26 AlarmKit critical-reminder opt-in. Default false. Critical alarms bypass the device mute switch / Focus; the server stores the preference only. atcCode: description: v1.9.0 optional WHO ATC classification code (active-substance class, e.g. `A10BX10`). User/clinician-asserted; never machine-guessed. Emitted on the FHIR `medicationCodeableConcept` under `http://www.whocc.no/atc`. NULL = no code captured. type: - string - "null" rxNormCode: description: v1.9.0 optional RxNorm RxCUI (numeric, US identifier, e.g. `2601723`). Secondary FHIR coding under `http://www.nlm.nih.gov/research/umls/rxnorm`, alongside any ATC code. NULL = no code captured. type: - string - "null" pausedAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" snoozedUntil: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" remindersActive: type: boolean description: v1.42.2 — whether the server sends dose reminders for this medication. See the `Medication` component. nextDueAt: description: "Present on the READ paths only — the list and single GET compute it; the create / update responses return the stored row and omit it. v1.7.0 server-computed next due instant across all the medication's schedules (earliest `nextOccurrenceAfter`). Read-only — computed, not stored. NULL when no schedule has an upcoming slot (paused, one-shot in the past, `endsOn` crossed, every schedule PRN). v1.16.4 — when an unresolved slot's anchor has passed but the catch-up band is still open (`anchor < now <= overdueEnd`, current schedule era), this carries THAT slot (a past instant) with `nextDueOverdue: true` instead of jumping to the next future slot. The list GET is cached 60 s, so a 60 s staleness is accepted." anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" nextDueOverdue: description: "Present on the READ paths only, alongside `nextDueAt`. v1.16.4 — true when `nextDueAt` is an OPEN overdue slot: its anchor has passed, `now` is still inside the slot's catch-up band, and no taken / skipped / auto-missed row resolves it. False for a regular future next-due (and when `nextDueAt` is null). Read-only — computed, not stored." type: boolean startsOn: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" description: v1.5 course start (ISO date). Anchors RRULE BYDAY / BYMONTHDAY patterns and the rolling-interval countdown's first window. NULL means active from creation. endsOn: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" description: v1.5 course end (ISO date). NULL means chronic. Equals `startsOn` when `oneShot` is true. endedAt: description: "#1280 — the instant the latest course was ended with `POST /api/medications/{id}/courses/end` (the course's own `endedAt`). From it on no dose is due or reminded and `courseStatus` is ENDED; NULL when the course runs to the end of `endsOn`. Read-only — written by the course writer only." anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" oneShot: type: boolean description: v1.5 single-administration flag. When true the medication has at most one schedule (no `rrule` / `rollingIntervalDays`), and `active` auto-flips to false once the dose is logged. asNeeded: type: boolean description: "v1.16.11 as-needed (PRN) flag. When true the medication carries ZERO schedules (the write routes 422 on any schedule entry alongside the flag): it is never due (`nextDueAt` stays null), never reminded, and excluded from every compliance rate/streak — but intakes still log as ad-hoc rows, inventory still consumes per `unitsPerDose`, and the history renders. Stays active indefinitely. Mutually exclusive with `oneShot`." trackIntake: type: boolean description: "v1.39.1 — intake tracking. True (the default) for every medication that existed before the field. When false the medication is kept as a record: nothing is ever due (`nextDueAt` stays null), nothing reminds on any channel, and it is excluded from every adherence figure. Its stored schedule then travels in `recordedSchedules` while `schedules` is served empty, so a client that arms reminders from `schedules` stays silent without an update." autoMarkTaken: type: boolean description: "v1.42.2 — doses are recorded automatically. False (the default) for every medication that existed before the field. When true the server records each scheduled dose as taken (an intake with `autoRecorded: true`, `takenAt` = the slot time) shortly after its time passes, from `autoMarkTakenSince` on. A slot the person took, skipped or removed is never touched, and they can still change or skip any dose. No medication reminder is sent for it on any channel while it is on; a client that arms local reminders should not arm them for it either. `notificationsEnabled` keeps the person's own setting and applies again once this is switched off." autoMarkTakenSince: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" description: v1.42.2 — when `autoMarkTaken` was last switched on. No slot anchored before it is ever recorded automatically. NULL while the option is off. Read-only — set by the server on the switch. createdAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ updatedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ schedules: type: array items: $ref: "#/components/schemas/MedicationScheduleOutput" description: "The schedules in force. Empty for an as-needed medication and (v1.39.1) for a medication with `trackIntake: false`, whose stored schedule is in `recordedSchedules`." recordedSchedules: description: "v1.39.1 — present only when `trackIntake` is false: the stored schedule, kept as information. Nothing derives a due dose or a reminder from it. Absent on a tracked medication, whose schedule is `schedules`." type: array items: $ref: "#/components/schemas/MedicationScheduleOutput" category: $ref: "#/components/schemas/MedicationCategory" categoryLabel: description: v1.40 — the decrypted label of a custom category (`category` = `custom:`); null for a built-in category, which a client localises from `category`. Read-only. type: - string - "null" courses: type: array items: $ref: "#/components/schemas/MedicationCourse" description: v1.40 — every course, ascending by start. Empty for a medication taken continuously since creation. Server-resolved; render it, do not recompute it. courseCount: type: integer minimum: 0 maximum: 9007199254740991 previousCourseEndedOn: anyOf: - type: string format: date pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ - type: "null" description: v1.40 — the last day of the most recent course that has ended (answers 'when did I last take it'); null when none has. canStartCourse: type: boolean description: "v1.40 — whether `POST /api/medications/{id}/courses` may start a new course now: the medication is active, not one-time, has a course and every course's last day is before today (a course ended today with `POST /api/medications/{id}/courses/end` is over, but a course starting today would share its last day, so the start is offered from the next day), and the caller is the record's owner (starting a course is not delegable, so a delegate always reads false)." courseStatus: type: string enum: - UPCOMING - CURRENT - ENDED description: "v1.39.4 — where today (on the user's clock) sits in the course: UPCOMING before `startsOn`, ENDED after `endsOn`, CURRENT otherwise. Calendar days, both ends inclusive, so a course that ends today is CURRENT all day, unless it was ended with `POST /api/medications/{id}/courses/end`: then it is ENDED from that instant (`endedAt`). Read-only — computed, not stored." intakeActionable: type: boolean description: "v1.39.4 — whether the medication offers taken / skip today: true only when it is `active`, its intake is tracked (`trackIntake`) and `courseStatus` is CURRENT. Clients render the dose actions from this and do not re-derive it. Logging a past dose from the history is not gated by it. Read-only — computed, not stored." canEndCourse: type: boolean description: '#1280 — whether `POST /api/medications/{id}/courses/end` may end the course now: true exactly while a course is running (`courseStatus` CURRENT; a medication with no dates is running) and the caller may end it: the owner of the record, or a delegate whose grant manages its medications. False to every other caller. Clients offer "End course" from this and do not re-derive it; the action refuses with 409 `medications.course.notRunning` when it is false. Read-only — computed, not stored.' stockUnitsRemaining: description: "v1.37.19 — usable inventory units left (same semantics as the list entry's field): NULL = inventory tracking off; 0 = tracking on, supply ran out." type: - number - "null" stockDosesRemaining: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" description: v1.37.19 — slot-aware dose-derived stock, mirroring the list entry's field. runwayDays: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" description: v1.37.19 — projected whole days the usable stock covers under the slot-aware burn rate; NULL = tracking off or no consuming cadence. ingredients: description: v1.43 — the composition per unit, empty when none is stored. Absent on older servers. type: array items: $ref: "#/components/schemas/MedicationIngredient" related: description: v1.43 — records related to this one, resolved on the server. Absent on older servers. type: array items: $ref: "#/components/schemas/RelatedLink" required: - id - name - dose - treatmentClass - dosesPerUnit - unitsPerDose - reorderLeadDays - active - notificationsEnabled - liveActivityEnabled - criticalAlarmEnabled - atcCode - rxNormCode - pausedAt - snoozedUntil - remindersActive - startsOn - endsOn - oneShot - asNeeded - trackIntake - autoMarkTaken - autoMarkTakenSince - createdAt - updatedAt - schedules - category - categoryLabel - courses - courseCount - previousCourseEndedOn - canStartCourse - courseStatus - intakeActionable - canEndCourse - stockUnitsRemaining - stockDosesRemaining - runwayDays additionalProperties: false description: Detail variant of the medication resource enriched with the joined `category`. The base medication fields are inlined; see the `Medication` component for their semantics. MedicationIngredient: type: object properties: nutrient: type: string description: Nutrient catalogue code. amountPerUnit: type: number description: Amount in one unit (tablet, capsule), canonical unit. unit: type: string description: The catalogue's canonical unit for the code. enteredAmount: description: The amount as entered when it was entered in another unit. type: - number - "null" enteredUnit: description: The input unit it was entered in (e.g. `IU`). type: - string - "null" labelKey: type: string label: type: string description: The nutrient's name in the account language. required: - nutrient - amountPerUnit - unit - enteredAmount - enteredUnit - labelKey - label additionalProperties: false description: What one unit of a medication (usually a supplement) contains. Every taken dose adds `amountPerUnit × unitsPerDose` to the day's micronutrients under the source `SUPPLEMENT`. RelatedLink: type: object properties: kind: type: string enum: - labResult - encounter - document - conditionEpisode - medication - vaccination id: type: string label: type: string date: description: "`YYYY-MM-DD` or an ISO instant, as the record carries it." type: - string - "null" href: type: string description: The web path of the related record, the same form as a push `url`. via: type: string enum: - direct - encounter - efficacyTarget description: "`direct` for a stored link; `encounter` when the relation runs through a visit both are filed against; `efficacyTarget` for a medication whose efficacy target is this lab marker." required: - kind - id - label - date - href - via additionalProperties: false description: One record related to the one shown. Only kinds whose module is on and that the caller's sharing grant covers are listed. Skip an unknown `kind`. ListMedicationCategoriesResponse: type: object properties: data: type: object properties: categories: type: array items: $ref: "#/components/schemas/MedicationCategoryLabel" required: - categories additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MedicationCategoryLabel: type: object properties: key: type: string description: "`custom:`, the value a medication's `category` carries when it is filed under this category." label: type: string description: The person's own label, 1 to 40 characters. sortOrder: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Position in the picker and the filter row, ascending. isActive: type: boolean description: false = hidden from the picker. Medications filed under a hidden category keep it. medicationCount: type: integer minimum: 0 maximum: 9007199254740991 description: How many of the record's medications are filed under it. required: - key - label - sortOrder - isActive - medicationCount additionalProperties: false description: v1.40 (#1041) — a medication category the person named themselves, shown next to the built-in ones. At most 20 per account (hidden ones count). CreateMedicationCategoryResponse: type: object properties: data: $ref: "#/components/schemas/MedicationCategoryLabel" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false UpdateMedicationCategoryResponse: type: object properties: data: $ref: "#/components/schemas/MedicationCategoryLabel" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DeleteMedicationCategoryResponse: type: object properties: data: type: object properties: key: type: string movedCount: type: integer minimum: 0 maximum: 9007199254740991 description: Medications moved to OTHER. required: - key - movedCount additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MedicationCoursesResponse: type: object properties: data: type: object properties: courses: type: array items: $ref: "#/components/schemas/MedicationCourse" courseCount: type: integer minimum: -9007199254740991 maximum: 9007199254740991 previousCourseEndedOn: anyOf: - type: string format: date pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ - type: "null" canStartCourse: type: boolean required: - courses - courseCount - previousCourseEndedOn - canStartCourse additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false CreateMedicationCourseResponse: type: object properties: data: type: object properties: id: type: string startsOn: type: string format: date pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ endsOn: anyOf: - type: string format: date pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ - type: "null" required: - id - startsOn - endsOn additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false EndMedicationCourseResponse: type: object properties: data: type: object properties: startsOn: anyOf: - type: string format: date pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ - type: "null" endsOn: anyOf: - type: string format: date pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ - type: "null" endedAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" required: - startsOn - endsOn - endedAt additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false UpdateMedicationCourseResponse: type: object properties: data: type: object properties: id: type: string startsOn: type: string format: date pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ endsOn: anyOf: - type: string format: date pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ - type: "null" required: - id - startsOn - endsOn additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DeleteMedicationCourseResponse: type: object properties: data: type: object properties: id: type: string deleted: type: boolean const: true required: - id - deleted additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MedicationListLayoutResponse: type: object properties: data: $ref: "#/components/schemas/MedicationListLayoutResult" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MedicationListLayoutResult: type: object properties: version: type: number const: 1 view: description: Which presentation /medications renders in. Default "cards". Optional on PUT — when omitted the stored value is preserved (preserve-when-absent, like `chartOverlayPrefs` on the dashboard layout). Always present on responses. type: string enum: - cards - table order: description: User-defined manual medication order (medication ids, first = top), shared by both views. Display-only — unknown / deleted ids are ignored at render time, never 422. Optional on PUT (preserve-when-absent); always present on responses. maxItems: 200 type: array items: type: string minLength: 1 maxLength: 64 updatedAt: description: "Optimistic-concurrency token: the stored row's `updatedAt` at read/write time. Echo it back as `baseUpdatedAt` on the next write. Opaque." type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - version additionalProperties: false description: Resolved medications list presentation plus the optimistic-concurrency `updatedAt` token. MedicationListLayoutSaved: type: object properties: data: $ref: "#/components/schemas/MedicationListLayoutResult" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MedicationListLayoutReset: type: object properties: data: $ref: "#/components/schemas/MedicationListLayoutResult" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false GetMedicationResponse: type: object properties: data: $ref: "#/components/schemas/MedicationDetail" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false UpdateMedicationResponse: type: object properties: data: $ref: "#/components/schemas/MedicationDetail" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DeleteMedicationResponse: type: object properties: data: type: object properties: deleted: type: boolean required: - deleted additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false ReplayMedicationIntakeResponse: type: object properties: data: $ref: "#/components/schemas/MedicationIntakeEvent" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false CreateMedicationIntakeResponse: type: object properties: data: $ref: "#/components/schemas/MedicationIntakeEvent" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false ListMedicationIntakeEventsResponse: type: object properties: data: type: object properties: events: type: array items: $ref: "#/components/schemas/MedicationIntakeEvent" meta: type: object properties: total: type: integer minimum: 0 maximum: 9007199254740991 limit: type: integer exclusiveMinimum: 0 maximum: 9007199254740991 offset: type: integer minimum: 0 maximum: 9007199254740991 required: - total - limit - offset additionalProperties: false required: - events - meta additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false ListMedicationInventoryResponse: type: object properties: data: type: object properties: items: type: array items: $ref: "#/components/schemas/MedicationInventoryItem" summary: $ref: "#/components/schemas/MedicationSupplySummary" meta: type: object properties: total: type: integer minimum: 0 maximum: 9007199254740991 required: - total additionalProperties: false required: - items - summary - meta additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MedicationInventoryItem: type: object properties: id: type: string userId: type: string medicationId: type: string state: type: string enum: - ACTIVE - IN_USE - EXPIRED - USED_UP description: Container lifecycle state. ACTIVE = unopened; IN_USE = opened (on a PEN / AMPOULE the 30-day in-use clock is running); EXPIRED = printed expiry lapsed, or the in-use window lapsed on a clock-bearing container, with units left; USED_UP = drained (terminal). containerType: type: string enum: - PEN - AMPOULE - BLISTER - INHALER - BOTTLE - OTHER description: Kind of physical container (PEN / AMPOULE / BLISTER / INHALER / BOTTLE / OTHER); defaults to OTHER. PEN and AMPOULE are the sealed reservoirs the first dose breaches, so opening one starts a 30-day post-opening window; BLISTER / INHALER / BOTTLE / OTHER hold individually sealed units and expire only on their printed date. unitsTotal: description: Units the container shipped with (tablets / ampoules / puffs; 1–1000). v1.16.12 — fractional, so a split-pill remainder reads cleanly. Dose-derived readouts divide by the medication's `unitsPerDose`. v1.18.3 (iOS#31) — NULL when the unit count is unknown (a corrupt or legacy row); the client renders "unknown", never a fabricated 0. type: - number - "null" unitsRemaining: description: Units left in the container. v1.16.12 — fractional (a ½-tablet dose leaves 29.5 of 30). Decremented by the intake consumption hook (FEFO with spillover across containers); refunded when a taken dose is skipped, edited away, or deleted. v1.18.3 (iOS#31) — NULL when the count is unknown (a corrupt or legacy row); the client renders "unknown", never a fabricated 0 it could decrement into negatives. type: - number - "null" firstUseAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" description: Instant the container was first used. NULL until opened. On a PEN / AMPOULE it starts the 30-day in-use clock; on the other kinds it only records that the pack is open. expiresAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" description: Persisted MIN(firstUseAt + 30 days, printedExpiry) for a clock-bearing container (PEN / AMPOULE), else printedExpiry alone. NULL when no deadline applies — an opened blister with no printed date has none. printedExpiry: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" purchasedAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" manufacturer: description: Marketing-authorisation holder / maker as printed on the carton. NULL for a container registered before the field existed, and for plain supply rows that never carried one. type: - string - "null" doseStrength: description: Strength as printed on the container, e.g. "5 mg/0.5 ml". Free text, not split into a number + unit — pens state strength per dose, per ml, or per cartridge. NULL when unknown. type: - string - "null" notes: type: - string - "null" createdAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ updatedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - id - userId - medicationId - state - containerType - unitsTotal - unitsRemaining - firstUseAt - expiresAt - printedExpiry - purchasedAt - manufacturer - doseStrength - notes - createdAt - updatedAt additionalProperties: false description: One supply container (pen / blister pack / bottle) of a medication. Counts UNITS — the medication's `unitsPerDose` maps units to doses. The intake write paths consume from the open container first, then first-expiry-first-out over unopened stock. MedicationSupplySummary: type: object properties: unitsRemaining: type: number description: Pooled units across available (ACTIVE / IN_USE, units left) containers. Floored at 0 — a corrupt / legacy negative row can never surface a negative Bestand. unitsTotal: type: number description: Pooled capacity across the same available containers. dosesRemaining: type: number description: "Dose-derived headline: `floor(unitsRemaining / unitsPerDose)` (whole doses; a partial dose is not a dose)." dosesTotal: type: number description: "Dose-derived capacity: `floor(unitsTotal / unitsPerDose)`." expiredUnits: type: number description: Units still sitting in EXPIRED containers — visible to the user as a muted suffix, never folded into the available headline or the runway estimate. required: - unitsRemaining - unitsTotal - dosesRemaining - dosesTotal - expiredUnits additionalProperties: false description: Server-authoritative supply summary for a medication's containers. Computed from the same availability predicate the medications-list payload and the GLP-1 endpoint use, so every surface agrees on what 'remaining' means. CreateMedicationInventoryItemResponse: type: object properties: data: $ref: "#/components/schemas/MedicationInventoryItem" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false UpdateMedicationInventoryItemResponse: type: object properties: data: $ref: "#/components/schemas/MedicationInventoryItem" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DeleteMedicationInventoryItemResponse: type: object properties: data: type: object properties: id: type: string deleted: type: boolean required: - id - deleted additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MedicationExtractResponse: type: object properties: data: $ref: "#/components/schemas/MedicationExtractionResult" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MedicationExtractionResult: type: object properties: name: type: string minLength: 1 maxLength: 100 dose: type: string minLength: 1 maxLength: 50 doseUnit: type: string enum: - mg - ml - iu - tablets - drops - puffs - sprays cadenceKind: type: string enum: - daily - weekdays - everyNWeeks - monthly - everyNMonths - yearly - rolling - oneShot weekdays: maxItems: 7 type: array items: type: string enum: - MO - TU - WE - TH - FR - SA - SU intervalWeeks: type: integer minimum: 1 maximum: 52 intervalMonths: type: integer minimum: 1 maximum: 12 dayOfMonth: type: integer minimum: 1 maximum: 31 rollingIntervalDays: type: integer minimum: 1 maximum: 365 timesOfDay: maxItems: 12 type: array items: type: string pattern: ^([01]\d|2[0-3]):[0-5]\d$ startsOn: type: string pattern: ^\d{4}-\d{2}-\d{2}$ endsOn: type: string pattern: ^\d{4}-\d{2}-\d{2}$ oneShot: type: boolean confidence: type: object propertyNames: type: string additionalProperties: type: number minimum: 0 maximum: 1 additionalProperties: false description: Citation-guarded partial extraction of medication scheduling fields. Every field is optional; the wizard merges what is present onto the form state and leaves the rest blank. `name` and `dose` are post-validated against the original free-text and dropped when not substring-matched, so the wizard cannot silently land a hallucinated brand or dose. `cadenceKind` / `doseUnit` / `weekdays` are closed enums; numeric fields are clamped to the wizard's wire bounds. GetMedicationCadenceResponse: type: object properties: data: $ref: "#/components/schemas/MedicationCadenceResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MedicationCadenceResponse: type: object properties: windowDays: type: integer exclusiveMinimum: 0 maximum: 9007199254740991 anchorIso: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ next: anyOf: - type: object properties: windowStart: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ windowEnd: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ scheduleIndex: type: integer minimum: 0 maximum: 9007199254740991 required: - windowStart - windowEnd - scheduleIndex additionalProperties: false - type: "null" chips: $ref: "#/components/schemas/MedicationCadenceChips" timeline: type: array items: $ref: "#/components/schemas/MedicationCadenceTimelinePoint" required: - windowDays - anchorIso - next - chips - timeline additionalProperties: false description: Cadence + compliance read for a single medication. `next` is the upcoming-dose envelope (null when the course has ended or the rolling clock has no pinning intake yet); `timeline` walks the requested `windowDays` worth of slots in ascending time order. MedicationCadenceChips: type: object properties: adherenceRate: description: 0-100, taken / (taken + missed). Skipped doses are excluded from the denominator — a deliberate decision, not a compliance failure. NULL when no dose was expected in the window (brand-new medication, paused). type: - number - "null" currentStreak: type: integer minimum: 0 maximum: 9007199254740991 description: Consecutive days ending at `asOf` where every expected dose was taken or skipped. Days with no expected dose advance the streak; missed days break it. longestStreak: type: integer minimum: 0 maximum: 9007199254740991 description: Longest all-taken-or-skipped run anywhere in the window. missedLast30: type: integer minimum: 0 maximum: 9007199254740991 description: Count of missed doses inside the window. windowDays: type: integer minimum: 0 maximum: 9007199254740991 description: Window size used — mirrors the input for the chart legend. required: - adherenceRate - currentStreak - longestStreak - missedLast30 - windowDays additionalProperties: false description: Compliance summary values for the medication detail page chip row. MedicationCadenceTimelinePoint: type: object properties: day: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ windowStart: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ windowEnd: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ scheduleIndex: type: integer minimum: 0 maximum: 9007199254740991 status: type: string required: - day - windowStart - windowEnd - scheduleIndex - status additionalProperties: false description: One expected-vs-actual dose slot for the cadence timeline chart. `status` is one of `taken | skipped | missed | pending | future` and drives the chip colour. ListMedicationComplianceResponse: type: object properties: data: type: array items: $ref: "#/components/schemas/MedicationComplianceSummaryEntry" error: type: "null" meta: type: object properties: aggregate: anyOf: - $ref: "#/components/schemas/MedicationComplianceAggregate" - type: "null" days: description: The `days` the request asked for. anyOf: - type: number const: 30 - type: number const: 60 - type: number const: 90 - type: number const: 180 - type: number const: 365 requestId: type: string required: - aggregate additionalProperties: false description: Absent on servers older than v1.42.0; a client that reads it treats absence as no aggregate. required: - data - error - meta additionalProperties: false MedicationComplianceSummaryEntry: type: object properties: medicationId: type: string applicable: type: boolean description: Whether this medication has a local expected-dose grid and therefore a displayable adherence percentage. notApplicableReason: anyOf: - type: string enum: - NO_LOCAL_SCHEDULE - INTAKE_NOT_TRACKED - type: "null" compliance7: description: Seven-day adherence summary, or an all-zero compatibility placeholder when applicable is false. $ref: "#/components/schemas/ComplianceResult" compliance30: description: Thirty-day adherence summary, or an all-zero compatibility placeholder when applicable is false. $ref: "#/components/schemas/ComplianceResult" compliance60: description: "Present only on `?days=60`: the trailing 60 days." $ref: "#/components/schemas/ComplianceResult" compliance90: description: "Present only on `?days=90`: the trailing 90 days." $ref: "#/components/schemas/ComplianceResult" compliance180: description: "Present only on `?days=180`: the trailing 180 days." $ref: "#/components/schemas/ComplianceResult" compliance365: description: "Present only on `?days=365`: the trailing 365 days." $ref: "#/components/schemas/ComplianceResult" complianceDisplay: anyOf: - $ref: "#/components/schemas/ComplianceDisplay" - type: "null" required: - medicationId - applicable - notApplicableReason - compliance7 - compliance30 - complianceDisplay additionalProperties: false description: Compact per-medication adherence row for the batched card read. Zero-local-schedule scheduled medications remain present with applicable=false so clients render a settled not-applicable state instead of a loading skeleton. ComplianceResult: type: object properties: totalExpected: type: integer minimum: 0 maximum: 9007199254740991 description: "Full denominator over the window: `taken + skipped + missed`. Cadence-aware. A slot from before the medication's `createdAt` counts only when a recorded row (a take, a skip, or a miss carried in with restored history) claims it, so the days before the medication existed are never missed." taken: type: integer minimum: 0 maximum: 9007199254740991 skipped: type: integer minimum: 0 maximum: 9007199254740991 description: Doses the user explicitly skipped — excluded from the `rate` denominator. missed: type: integer minimum: 0 maximum: 9007199254740991 rate: type: integer minimum: 0 maximum: 100 description: Adherence percentage `round(taken / (taken + missed) * 100)` — `skipped` is excluded from the denominator. streak: type: integer minimum: 0 maximum: 9007199254740991 description: Consecutive days with every due dose taken. required: - totalExpected - taken - skipped - missed - rate - streak additionalProperties: false description: Rolling-window adherence summary. `compliance30` is the authoritative 'last 30 days, taken vs expected' read — clients should display `rate` and use `totalExpected` as the denominator rather than re-deriving it from the daily map. ComplianceDisplay: type: object properties: shortDays: type: integer exclusiveMinimum: 0 maximum: 9007199254740991 longDays: type: integer exclusiveMinimum: 0 maximum: 9007199254740991 expectedShort: type: integer minimum: 0 maximum: 9007199254740991 expectedLong: type: integer minimum: 0 maximum: 9007199254740991 minStableDoses: type: integer minimum: 0 maximum: 9007199254740991 short: type: object properties: rate: type: integer minimum: 0 maximum: 100 taken: type: integer minimum: 0 maximum: 9007199254740991 expected: type: integer minimum: 0 maximum: 9007199254740991 description: v1.15.9 rate denominator over the short window (`taken + missed`); user skips are excluded. Render `taken / expected · rate%`. missed: type: integer minimum: 0 maximum: 9007199254740991 description: v1.15.9 doses counted against the rate over the short window — includes forgotten doses the auto-miss cron flipped. streak: type: integer minimum: 0 maximum: 9007199254740991 required: - rate - taken - expected - missed - streak additionalProperties: false long: type: object properties: rate: type: integer minimum: 0 maximum: 100 taken: type: integer minimum: 0 maximum: 9007199254740991 expected: type: integer minimum: 0 maximum: 9007199254740991 description: v1.15.9 rate denominator over the long window (`taken + missed`). missed: type: integer minimum: 0 maximum: 9007199254740991 description: v1.15.9 doses counted against the rate over the long window. required: - rate - taken - expected - missed additionalProperties: false currentCycle: type: object properties: state: type: string enum: - on_track - due - missed - none description: "Open-cycle state, decoupled from the percentage rows: `on_track` = next dose not yet due; `due` = due now / in grace; `missed` = past grace with no logged intake (the only red state); `none` = no projected next dose (PRN / paused / ended)." nextDueAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" description: The open cycle's due instant. Null when `state` is `none`. graceUntil: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" description: End of the due slot's grace window. Null when `state` is `none`. hasClosedCycles: type: boolean description: False for a brand-new sparse med with zero closed dose cycles — the percentage rows are vacuous and the card should show a neutral 'not enough data yet' state. required: - state - nextDueAt - graceUntil - hasClosedCycles additionalProperties: false description: v1.13.x — the current (open) dose cycle, surfaced so a between-doses sparse med renders a neutral 'next dose in N days' line instead of a scary red 0%. The percentage rows above already exclude the open forward cycle from their denominator. currentDose: type: object properties: status: type: string enum: - upcoming - on_time_window - overdue - missed - taken_on_time - taken_late - skipped description: Per-dose state of the open cycle, server-derived from the window model so the card renders the take-window (green) / overdue / heavily-overdue escalation from one authority. `upcoming` when no dose is open (PRN / paused / ended). targetAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" description: Target instant of the open dose. Null when no dose is open. required: - status - targetAt additionalProperties: false description: v1.15.9 — the open dose's per-dose status + target, so the card highlights the actionable take-window green and escalates an overdue dose without re-deriving the window math client-side. required: - shortDays - longDays - expectedShort - expectedLong - minStableDoses - short - long - currentCycle - currentDose additionalProperties: false description: The two-row card block whose windows scale with dosing cadence (dense meds keep 7 / 30 days, sparse meds step both windows up). NOT the 30-day denominator — read `compliance30.totalExpected` for that. MedicationComplianceAggregate: type: object properties: compliance7: $ref: "#/components/schemas/ComplianceResult" compliance30: $ref: "#/components/schemas/ComplianceResult" compliance60: anyOf: - $ref: "#/components/schemas/ComplianceResult" - type: "null" compliance90: anyOf: - $ref: "#/components/schemas/ComplianceResult" - type: "null" compliance180: anyOf: - $ref: "#/components/schemas/ComplianceResult" - type: "null" compliance365: anyOf: - $ref: "#/components/schemas/ComplianceResult" - type: "null" medicationCount: type: integer exclusiveMinimum: 0 maximum: 9007199254740991 description: How many applicable medications the figure is built from. required: - compliance7 - compliance30 - medicationCount additionalProperties: false description: "One adherence figure for the whole account in the `ComplianceResult` shape, weighted by expected doses: `taken`, `skipped` and `missed` are summed across every medication with `applicable: true`, and `rate` is recomputed from the sums by the per-medication rule (round(100 · taken / (taken + missed)), capped at 100, 100 when nothing was expected). Averaging the per-medication rates would let a weekly injection weigh as much as a three-times-daily tablet. `streak` is the shortest per-medication streak. The `complianceN` block for a requested `days` is present when `days` was sent and is not 30." GetMedicationComplianceResponse: type: object properties: data: $ref: "#/components/schemas/MedicationComplianceResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MedicationComplianceResponse: type: object properties: applicable: type: boolean description: Whether a local adherence percentage applies to this medication. notApplicableReason: anyOf: - type: string enum: - NO_LOCAL_SCHEDULE - INTAKE_NOT_TRACKED - type: "null" description: "Reason adherence is not applicable. NO_LOCAL_SCHEDULE means the medication has no HealthLog-owned expected-dose grid. INTAKE_NOT_TRACKED (v1.39.1) means the medication's intake tracking is switched off (`trackIntake: false`): its schedule is kept as information and nothing is expected from it." compliance7: description: Seven-day adherence summary. When applicable is false, this remains a non-null all-zero compatibility placeholder for released clients and must not be rendered as a percentage. $ref: "#/components/schemas/ComplianceResult" compliance30: description: Thirty-day adherence summary. When applicable is false, this remains a non-null all-zero compatibility placeholder for released clients and must not be rendered as a percentage. $ref: "#/components/schemas/ComplianceResult" dailyCompliance: type: object propertyNames: type: string additionalProperties: $ref: "#/components/schemas/DailyComplianceEntry" description: Flat per-day map keyed YYYY-MM-DD in the user timezone. Empty when adherence is not applicable. complianceDisplay: anyOf: - $ref: "#/components/schemas/ComplianceDisplay" - type: "null" description: Cadence-scaled display block, or null when adherence is not applicable. required: - applicable - notApplicableReason - compliance7 - compliance30 - dailyCompliance - complianceDisplay additionalProperties: false description: Adherence read for a single medication. A scheduled medication with no local schedule returns applicable=false and NO_LOCAL_SCHEDULE rather than a vacuous 100 percent. Scheduled medications that have a local schedule keep the existing cadence-aware arithmetic, including windows with no doses due. PRN behaviour is unchanged. DailyComplianceEntry: type: object properties: expected: type: integer minimum: 0 maximum: 9007199254740991 description: Engine-computed due-slot count for the day. Equals `expectedCount`; kept for existing consumers. expectedCount: type: integer minimum: 0 maximum: 9007199254740991 description: True due-slot count for the day (additive field clients key off so they don't infer due-ness from `expected`). due: type: boolean description: "`expectedCount > 0`. Paint a per-day glyph as expected/missed ONLY when `due === true`; off-cadence / pre-creation / PRN days are not misses." taken: type: integer minimum: 0 maximum: 9007199254740991 skipped: type: integer minimum: 0 maximum: 9007199254740991 onTime: type: integer minimum: 0 maximum: 9007199254740991 description: Doses taken in the on-time band, including the `early` bucket (early counts as compliant). late: type: integer minimum: 0 maximum: 9007199254740991 veryLate: type: integer minimum: 0 maximum: 9007199254740991 early: description: Doses taken before the on-time band's grace start; already folded into `onTime`, surfaced separately for consumers that differentiate. type: integer minimum: 0 maximum: 9007199254740991 required: - expected - expectedCount - due - taken - skipped - onTime - late - veryLate additionalProperties: false description: Per-day compliance cell with the timing breakdown that drives the history glyph track. GetMedicationEfficacyResponse: type: object properties: data: $ref: "#/components/schemas/MedicationEfficacyResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MedicationEfficacyResponse: type: object properties: medicationId: type: string medicationName: type: string eligible: type: boolean reason: type: string enum: - one_shot - no_target startsOn: type: - string - "null" resolution: type: object properties: tier: type: string enum: - override - atc - name - none cls: type: - string - "null" required: - tier - cls additionalProperties: false windowDays: type: integer minimum: -9007199254740991 maximum: 9007199254740991 minWeeksPerSide: type: integer minimum: -9007199254740991 maximum: 9007199254740991 markers: type: object properties: start: type: - string - "null" startSource: anyOf: - type: string enum: - startsOn - firstReading - type: "null" doseChanges: type: array items: type: object properties: at: type: string label: type: string required: - at - label additionalProperties: false pauses: type: array items: type: object properties: from: type: string to: type: - string - "null" required: - from - to additionalProperties: false required: - start - startSource - doseChanges - pauses additionalProperties: false targets: type: array items: type: object properties: kind: type: string enum: - metric - lab key: type: string label: type: string unit: type: - string - "null" primary: type: boolean referenceBand: anyOf: - type: object properties: low: type: number high: type: number required: - low - high additionalProperties: false - type: "null" series: type: array items: type: object properties: t: type: string value: type: number status: type: string enum: - in-range - below - above - unknown required: - t - value additionalProperties: false beforeAfter: type: object properties: present: type: boolean reason: type: string enum: - insufficient_before - insufficient_after - no_start - no_data before: type: object properties: mean: type: number count: type: integer minimum: -9007199254740991 maximum: 9007199254740991 from: type: string to: type: string required: - mean - count - from - to additionalProperties: false after: type: object properties: mean: type: number count: type: integer minimum: -9007199254740991 maximum: 9007199254740991 from: type: string to: type: string required: - mean - count - from - to additionalProperties: false delta: anyOf: - type: object properties: mean: type: number pct: type: - number - "null" required: - mean - pct additionalProperties: false - type: "null" required: - present additionalProperties: false levelShift: anyOf: - type: object properties: present: type: boolean at: type: string nearStart: type: boolean required: - present additionalProperties: false - type: "null" segmented: anyOf: - type: object properties: present: type: boolean reason: type: string enum: - no_start - insufficient_before - insufficient_after - fit_failed level: type: number window: anyOf: - type: object properties: from: type: string to: type: string endReason: type: string enum: - today - window - dose_change required: - from - to - endReason additionalProperties: false - type: "null" days: type: object properties: before: type: integer minimum: -9007199254740991 maximum: 9007199254740991 after: type: integer minimum: -9007199254740991 maximum: 9007199254740991 excludedLowAdherence: type: integer minimum: -9007199254740991 maximum: 9007199254740991 excludedNotTaking: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - before - after - excludedLowAdherence - excludedNotTaking additionalProperties: false adherence: type: object properties: status: type: string enum: - tracked - not_tracked floorPct: type: integer minimum: -9007199254740991 maximum: 9007199254740991 lookbackDays: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - status - floorPct - lookbackDays additionalProperties: false levelChange: anyOf: - type: object properties: estimate: type: number ciLow: type: number ciHigh: type: number standardError: type: number includesZero: type: boolean required: - estimate - ciLow - ciHigh - standardError - includesZero additionalProperties: false - type: "null" slopeChangePerWeek: anyOf: - type: object properties: estimate: type: number ciLow: type: number ciHigh: type: number standardError: type: number includesZero: type: boolean required: - estimate - ciLow - ciHigh - standardError - includesZero additionalProperties: false - type: "null" preSlopePerWeek: anyOf: - type: object properties: estimate: type: number ciLow: type: number ciHigh: type: number standardError: type: number includesZero: type: boolean required: - estimate - ciLow - ciHigh - standardError - includesZero additionalProperties: false - type: "null" fit: anyOf: - type: object properties: n: type: integer minimum: -9007199254740991 maximum: 9007199254740991 df: type: integer minimum: -9007199254740991 maximum: 9007199254740991 rSquared: type: number residualAutocorrelation: type: number hacLag: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: "Newey–West lag applied: the automatic rule, at least 7 (a week of daily carry-over)." autocorrelationCaveat: type: boolean description: "True when the lag-1 residual autocorrelation exceeds 0.5: the carry-over is strong enough that the intervals may still be too narrow, and the read-out says so." required: - n - df - rSquared - residualAutocorrelation - hacLag - autocorrelationCaveat additionalProperties: false - type: "null" confounders: type: array items: type: object properties: medicationName: type: string event: type: string enum: - started - stopped - dose_change - paused - resumed date: type: string required: - medicationName - event - date additionalProperties: false required: - present - level - window - days - adherence - levelChange - slopeChangePerWeek - preSlopePerWeek - fit - confounders additionalProperties: false - type: "null" required: - kind - key - label - unit - primary - referenceBand - series - beforeAfter - levelShift - segmented additionalProperties: false adherence: type: array items: type: object properties: date: type: string rate: type: number taken: type: integer minimum: -9007199254740991 maximum: 9007199254740991 missed: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - date - rate - taken - missed additionalProperties: false overrideOptions: type: object properties: metrics: type: array items: type: object properties: key: type: string label: type: string required: - key - label additionalProperties: false biomarkers: type: array items: type: object properties: id: type: string label: type: string unit: type: string required: - id - label - unit additionalProperties: false required: - metrics - biomarkers additionalProperties: false required: - medicationId - medicationName - eligible - startsOn - resolution - windowDays - minWeeksPerSide - markers - targets - adherence - overrideOptions additionalProperties: false description: Server-authoritative, strictly-descriptive efficacy view relating a medication to the outcome metric(s)/lab(s) its class is prescribed to move, around its start. Carries the resolved target(s) with their series, the start/dose-change/pause markers, a before/after-start comparison (honest `{present:false}` below the per-side data floor), an adherence lane (cadence-aware per-day rate, never recomputed), an optional conservative level-shift note, and the retarget options. There is NO verdict / score / assessment field by construction — the client renders numbers and neutral connective phrasing only, never a causal or dose-advice claim. SetMedicationEfficacyTargetResponse: type: object properties: data: type: object properties: ok: type: boolean cleared: type: boolean additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false GetDoseHistoryResponse: type: object properties: data: $ref: "#/components/schemas/DoseHistoryResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DoseHistoryResponse: type: object properties: from: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ description: "Start of the window recorded doses were read over: the requested `from` after the span clamp." to: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ description: "End of the window recorded doses were read over: the requested `to`, or the slot time of a dose recorded ahead of its slot when that lies later (at most one day after `to`)." family: type: string enum: - daily - weekly - one_shot - none description: Cadence family the window's slots were minted under. hasExpectedSlots: type: boolean rows: type: array items: $ref: "#/components/schemas/DoseHistoryRow" required: - from - to - family - hasExpectedSlots - rows additionalProperties: false description: "Per-slot dose ledger over [from, to]: every expected slot with a status plus every off-schedule intake tagged ad-hoc. Built from the same band minter + `reconstructDoseHistory` the compliance % consumes, so the history view and the rate never contradict each other." DoseHistoryRow: type: object properties: kind: type: string enum: - slot - ad_hoc description: "`slot` — a scheduled dose window; `ad_hoc` — a standalone off-schedule intake." at: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ description: The slot anchor instant, or the ad-hoc take's own time. timeOfDay: description: The slot's `HH:mm` label, or null for an ad-hoc row. type: - string - "null" status: type: string enum: - taken_on_time - taken_late - skipped - missed - upcoming - ad_hoc pinned: description: Present and true when the row is served by a deliberate user pin ('zugeordnet'). type: boolean nearestSlot: description: "Due-context for an ad-hoc take: the nearest slot it could belong to (preferring an unserved one). `filled` false means the slot can still be offered for pinning." type: object properties: at: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ timeOfDay: type: string filled: type: boolean required: - at - timeOfDay - filled additionalProperties: false intake: anyOf: - type: object properties: id: type: - string - "null" scheduledFor: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ takenAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" skipped: type: boolean autoMissed: type: boolean autoRecorded: type: boolean description: v1.42.2 — the dose was recorded automatically (`autoMarkTaken`), not by the person. doseTaken: description: Per-intake dose override; null = configured dose. type: - string - "null" source: anyOf: - type: string enum: - WEB - API - REMINDER - IMPORT - APPLE_HEALTH - type: "null" description: "v1.32.8 (iOS #64) — how the dose was recorded: `WEB` (browser), `API` (Bearer / native app), `REMINDER` (the medication reminder worker), `IMPORT` (CSV importer), `APPLE_HEALTH` (the HealthKit dose-event mirror). Null on legacy rows written before the column carried a value. Derived server-side from the write transport, never client-asserted." required: - id - scheduledFor - takenAt - skipped - autoMissed - autoRecorded - doseTaken - source additionalProperties: false - type: "null" description: The intake attributed to this row, if any. required: - kind - at - timeOfDay - status - intake additionalProperties: false ListScheduleRevisionsResponse: type: object properties: data: type: object properties: currentSince: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z))$ description: "Instant the LIVE plan took over: the newest revision's `validUntil`, or the medication's `createdAt` when no era has been archived." revisions: type: array items: $ref: "#/components/schemas/MedicationScheduleRevision" description: Archived eras, newest first. required: - currentSince - revisions additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MedicationScheduleRevision: type: object properties: id: type: string validFrom: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z))$ description: Inclusive start instant of the era. validUntil: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z))$ description: Exclusive end instant of the era — the moment the next plan took over. source: type: string enum: - ARCHIVED - MANUAL description: Provenance. ARCHIVED = minted by the schedule-replace write path (immutable). MANUAL = user-entered pre-tracking era (deletable). entries: type: array items: $ref: "#/components/schemas/ScheduleRevisionEntry" required: - id - validFrom - validUntil - source - entries additionalProperties: false description: One archived schedule era covering `[validFrom, validUntil)`. The dose-history ledger, compliance tallies, and cadence chips mint past days against the era that was live then. ScheduleRevisionEntry: type: object properties: timesOfDay: type: array items: type: string description: Daily dose times (HH:mm, user local) the era ran at. label: type: - string - "null" dose: type: - string - "null" scheduleType: type: string description: Schedule-type discriminator of the archived row (SCHEDULED / PRN / CYCLIC). required: - timesOfDay - label - dose - scheduleType additionalProperties: false description: Display summary of one archived schedule row inside an era. The full snapshot (windows, rrule, doseWindows, …) stays server-side; this projection carries what the timeline renders. CreateScheduleRevisionResponse: type: object properties: data: $ref: "#/components/schemas/MedicationScheduleRevision" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false UpdateScheduleRevisionResponse: type: object properties: data: $ref: "#/components/schemas/MedicationScheduleRevision" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DeleteScheduleRevisionResponse: type: object properties: data: type: object properties: deleted: type: boolean required: - deleted additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MedicationDoseHistoryImportDryRunEnvelope: type: object properties: data: $ref: "#/components/schemas/MedicationDoseHistoryImportResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MedicationDoseHistoryImportResponse: type: object properties: dryRun: type: boolean jobId: description: Null on a dry run (nothing was queued). type: - string - "null" status: description: Present only on a real (non-dry-run) submission. type: string const: queued statusUrl: description: Relative poll path (`/api/medications/intake/dose-history-import/{jobId}/status`). Null on a dry run. type: - string - "null" file: $ref: "#/components/schemas/MedicationDoseHistoryImportFile" required: - dryRun - jobId - statusUrl - file additionalProperties: false description: The file-level verdict, plus the queued job pointer on a real submission. A dry run (`?dryRun=1`) returns the same `file` verdict with `jobId`/`statusUrl` null and no `status` field — parses, matches, and reports without writing anything. MedicationDoseHistoryImportFile: type: object properties: rowsRead: type: integer minimum: 0 maximum: 9007199254740991 description: Data rows read from the file, refusals included. queued: type: integer minimum: 0 maximum: 9007199254740991 description: Rows that parsed and matched a medication. refused: type: integer minimum: 0 maximum: 9007199254740991 description: Total rows that did not queue, across every reason. refusedByReason: type: array items: $ref: "#/components/schemas/MedicationImportSkipGroup" description: One entry per reason that actually occurred, count descending then reason. unmatchedMedications: maxItems: 50 type: array items: type: string description: Medication names the file used that match nothing on the record, deduplicated, first-seen order, capped at 50. ambiguousMedications: maxItems: 50 type: array items: type: string description: Names that match more than one medication, so no single one can be meant. Capped at 50. mirroredMedications: maxItems: 50 type: array items: type: string description: Names that match only a medication mirrored from an external list. Capped at 50. unknownColumns: maxItems: 50 type: array items: type: string description: Header cells the importer has no verdict for. Capped at 50. codingsNotRead: type: integer minimum: 0 maximum: 9007199254740991 description: Rows whose `Codings` cell held something left unread. fromArchivedMedications: type: integer minimum: 0 maximum: 9007199254740991 description: Rows the file marked as belonging to a medication archived in the source. required: - rowsRead - queued - refused - refusedByReason - unmatchedMedications - ambiguousMedications - mirroredMedications - unknownColumns - codingsNotRead - fromArchivedMedications additionalProperties: false MedicationImportSkipGroup: type: object properties: reason: $ref: "#/components/schemas/MedicationImportSkipReason" count: type: integer minimum: 1 maximum: 9007199254740991 required: - reason - count additionalProperties: false MedicationImportSkipReason: type: string enum: - duplicate_in_file - already_recorded - medication_not_found - medication_ambiguous - medication_is_mirrored - status_no_dose_information - status_reminder_event - status_notification_not_sent - status_unknown - missing_timestamp - missing_timezone_offset - unreadable_timestamp - implausible_timestamp - missing_medication - unreadable_dosage - unreadable_row description: "Why one source row did not become an intake row. Closed 16-value set: duplicate_in_file, already_recorded, medication_not_found, medication_ambiguous, medication_is_mirrored, status_no_dose_information, status_reminder_event, status_notification_not_sent, status_unknown, missing_timestamp, missing_timezone_offset, unreadable_timestamp, implausible_timestamp, missing_medication, unreadable_dosage, unreadable_row." MedicationDoseHistoryImportQueuedEnvelope: type: object properties: data: $ref: "#/components/schemas/MedicationDoseHistoryImportResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MedicationIntakeImportJobStatusEnvelope: type: object properties: data: $ref: "#/components/schemas/MedicationIntakeImportJobStatusResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MedicationIntakeImportJobStatusResponse: type: object properties: jobId: type: string status: type: string enum: - queued - running - done - failed description: queued — admitted, not yet picked up. running — the worker is walking entries. done — finished (see `result` for the outcome, even a fully-refused run). failed — the worker could not complete the run (see `failureReason`). progress: $ref: "#/components/schemas/MedicationImportProgress" result: anyOf: - $ref: "#/components/schemas/MedicationImportResult" - type: "null" description: Set only once `status` is `done`; null before that. failureReason: description: Set only when `status` is `failed`; null otherwise. type: - string - "null" createdAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ startedAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" description: When the worker picked the job up; null while still queued. completedAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" description: When the job reached `done` or `failed`; null while active. required: - jobId - status - progress - result - failureReason - createdAt - startedAt - completedAt additionalProperties: false description: Poll target for a queued intake-import job — shared by the per-medication import and the account-wide dose-history import, so a client polls both through one shape. MedicationImportProgress: type: object properties: processed: type: integer minimum: 0 maximum: 9007199254740991 description: Entries the worker has walked so far, in file order. total: type: integer minimum: 0 maximum: 9007199254740991 description: Entries queued for this job. imported: type: integer minimum: 0 maximum: 9007199254740991 description: Entries that have reached the database so far. skippedByReason: type: object propertyNames: $ref: "#/components/schemas/MedicationImportSkipReason" additionalProperties: type: integer minimum: 1 maximum: 9007199254740991 description: Running skip tally by reason. A reason with no skips yet is absent from the object, never present as 0. skipDetails: description: Same bounded detail list as the finished `result`. type: array items: $ref: "#/components/schemas/MedicationImportSkipDetail" skippedDetailsOmitted: type: integer minimum: 0 maximum: 9007199254740991 touchedDays: type: array items: type: string description: "Deprecated and inert: always `[]` on a job admitted from v1.43.8, when the per-day compliance rollup it drove was removed. A job admitted earlier keeps the days it recorded. Kept for clients that decode it; do not read it." rollupProcessed: type: integer minimum: 0 maximum: 9007199254740991 description: "Deprecated and inert: always `0` on a job admitted from v1.43.8. Kept for clients that decode it; do not read it." required: - processed - total - imported - skippedByReason - touchedDays - rollupProcessed additionalProperties: false description: "In-flight job progress, written whole at admission and advanced in place by the worker. Present from the moment the job is created (queued), so `processed: 0, total: N` is the shape a client sees immediately after kickoff, not an empty object." MedicationImportSkipDetail: type: object properties: line: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: 1-based source-file row ordinal. reason: $ref: "#/components/schemas/MedicationImportSkipReason" required: - line - reason additionalProperties: false description: One refused source row, reduced to the ordinal + reason — never the row's own content (no medication name, timestamp, or dosage text). MedicationImportResult: type: object properties: imported: type: integer minimum: 0 maximum: 9007199254740991 skipped: type: integer minimum: 0 maximum: 9007199254740991 description: Total entries that did not become a row. skipReasons: type: array items: $ref: "#/components/schemas/MedicationImportSkipGroup" description: One entry per reason that actually occurred, count descending then reason — never the same sentence repeated per row. Empty when nothing was skipped. skipDetails: description: First bounded source-row details (capped at 200). Absent on a legacy job run before this field existed. type: array items: $ref: "#/components/schemas/MedicationImportSkipDetail" skippedDetailsOmitted: description: Count of additional skipped rows beyond the `skipDetails` cap, counted without retaining their contents. Absent when nothing was omitted. type: integer minimum: 0 maximum: 9007199254740991 required: - imported - skipped - skipReasons additionalProperties: false description: The finished job's outcome. Null while the job is still queued or running — `status` + `progress` are the fields to read meanwhile. ProfileSummaryEnvelope: type: object properties: data: $ref: "#/components/schemas/ProfileSummary" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false ProfileSummary: type: object properties: allergies: type: array items: type: object properties: substance: type: string category: type: string required: - substance - category additionalProperties: false description: Up to 25 live allergy records, newest first. familyHistory: type: array items: type: object properties: condition: type: string relationship: type: string required: - condition - relationship additionalProperties: false description: Up to 25 live family-history entries, newest first. facts: type: array items: type: object properties: label: type: string enum: - SMOKING_STATUS - ALCOHOL_PATTERN - SHIFT_SCHEDULE description: The fact KIND, not a display label — the client localises it. value: type: string description: The current value of that fact, e.g. `NEVER` / `FORMER` / `CURRENT` for smoking status. required: - label - value additionalProperties: false description: Current lifestyle facts, at most one per kind (SMOKING_STATUS / ALCOHOL_PATTERN / SHIFT_SCHEDULE). A kind with no current revision is OMITTED rather than reported as null, and so is one whose stored value could not be decrypted — the list is what is known, and absence is not distinguishable from unreadable here. required: - allergies - familyHistory - facts additionalProperties: false description: "The bounded profile view a record viewer gets: allergies, family history and current lifestyle facts. Structurally incapable of becoming a Settings or AI surface." AiTestEnvelope: type: object properties: data: $ref: "#/components/schemas/AiTestResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false AiTestResponse: anyOf: - type: object properties: ok: type: boolean const: true providerType: type: string model: type: - string - "null" tokensUsed: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" sample: type: string description: First 200 characters of the provider's reply to the probe prompt. Model output, not a credential. required: - ok - providerType - model - tokensUsed - sample additionalProperties: false - type: object properties: ok: type: boolean const: false providerType: type: string reasonCode: type: string enum: - credentials - rate_limited - server_error - bad_request - unreachable reason: type: string description: English fallback prose for the code. Secret-free by construction — the provider's own error body is logged server-side and never returned. httpStatus: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" description: The upstream status, when there was one. Null for a transport failure. required: - ok - providerType - reasonCode - reason - httpStatus additionalProperties: false description: "The probe outcome. Read `ok`, not the status code: a provider that answered and rejected the call is a 200 with `ok: false`." GetAiProviderResponse: type: object properties: data: $ref: "#/components/schemas/AiProviderResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false AiProviderResponse: type: object properties: provider: anyOf: - type: string enum: - OPENAI - ANTHROPIC - LOCAL - OPENAI_COMPATIBLE - CHATGPT_OAUTH - type: "null" description: The user's selected provider, or null when none is set. model: type: - string - "null" baseUrl: description: Custom base URL (LOCAL provider only); null otherwise. type: - string - "null" aiAvailable: type: boolean description: True when ANY provider can serve this user — including the operator's server-managed key when the user set none — AND the operator's master AI switch is on. With every AI capability switched off there is no AI available, whatever is configured. iOS keys Coach visibility off this. The per-capability answer is `ai` on `/api/auth/me`. managedBy: anyOf: - type: string enum: - user - local - server - type: "null" description: "Resolved provider origin: `user` (BYOK), `local` (self-hosted), `server` (operator key), or null when no provider is available." hasAnthropicKey: type: boolean anthropicKeyPreview: description: "`...` + last 4 chars of the stored Anthropic key, or null." type: - string - "null" hasLocalKey: type: boolean hasOpenaiKey: type: boolean openaiKeyPreview: description: "`...` + last 4 chars of the stored OpenAI key, or null." type: - string - "null" compatBaseUrl: description: "v1.33.1 (#470) — base URL of the OpenAI-compatible gateway (LiteLLM / OpenRouter / vLLM). Its own column: the OpenAI provider stays pinned to api.openai.com and never reads it." type: - string - "null" compatModel: description: Model the gateway should route. Null falls back to `model`; with neither set the gateway entry does not resolve. type: - string - "null" hasCompatKey: type: boolean description: Whether a bearer is stored for the gateway. The bearer is optional — a gateway without auth needs none. responseTimeoutSeconds: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" description: v1.22 (#89) — per-user response timeout for AI generation, in seconds (10–600). Null = the built-in comprehensive-briefing default (~120 s). Mainly for slow local/self-hosted backends. localReasoningEffort: anyOf: - type: string enum: - none - low - medium - high - type: "null" description: "#1126 — reasoning setting of the Local provider entry, sent as `reasoning_effort` (`none` = Off). Null = Default: the key is not sent." compatReasoningEffort: anyOf: - type: string enum: - none - low - medium - high - type: "null" description: "#1126 — the same for the OpenAI-compatible gateway entry." serverProviderHealth: type: string enum: - healthy - unhealthy - unknown description: "Instance-wide health of the operator's shared providers (`admin-openai` / `admin-codex`), folded across all accounts and reduced to a tri-state: `healthy` when one of them demonstrably served somebody within the last 24 h and no credential sits in its auth-failure cooldown, `unhealthy` when the last outcome somebody observed was a failure, `unknown` when nothing has been tried lately on this instance (including a brand-new instance whose ledger is still empty) or the read failed. Deliberately carries no count, no timestamp and no account identity — 'the provider of this instance last worked' is a statement about the instance, not about other people. Fails closed for the offer: neither `unhealthy` nor `unknown` may be offered. They are not interchangeable for copy, though — `unhealthy` is a verdict a client may state, `unknown` is the absence of one and must not be reported as an outage. Reading this never probes a provider." serverProviderOffer: type: boolean description: "Whether the shared provider may honestly be offered to this caller in one tap. True only when all of: the operator's provider is the one that would serve them (`managedBy: \"server\"`), `serverProviderHealth` is `healthy`, the operator keeps at least one capability the consent would unlock switched on (the Coach, the daily briefing, or reading documents; the master switch off turns all three off), the acting record holds its own credentials (a managed profile never does), the caller holds no receipt yet, and the instance is not running in demo mode (where the grant the tap makes is refused at the edge). Anything unknown makes it false." serverProviderConsent: type: boolean description: Whether this caller already holds an active `ai_full` / `ai_coach` receipt — the grant `POST /api/consent/ai/web` mints. When true there is nothing left to offer, and `serverProviderOffer` is false for that reason rather than for a bad one. required: - provider - model - baseUrl - aiAvailable - managedBy - hasAnthropicKey - anthropicKeyPreview - hasLocalKey - hasOpenaiKey - openaiKeyPreview - compatBaseUrl - compatModel - hasCompatKey - responseTimeoutSeconds - localReasoningEffort - compatReasoningEffort - serverProviderHealth - serverProviderOffer - serverProviderConsent additionalProperties: false description: The calling user's AI-provider configuration plus the effective availability (`aiAvailable` + `managedBy`). Reports key presence + a masked preview only — never a plaintext key. PatchAiProviderResponse: type: object properties: data: type: object properties: updated: type: boolean const: true required: - updated additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false GetCoachPrefsResponse: type: object properties: data: $ref: "#/components/schemas/CoachPrefsResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false CoachPrefsResponse: type: object properties: tone: default: warm type: string enum: - warm - neutral - concise verbosity: default: default type: string enum: - brief - default - detailed excludeMetrics: default: [] maxItems: 11 type: array items: type: string enum: - bp - weight - pulse - mood - compliance - hrv - sleep - resting_hr - steps - medications - anthropometrics showEvidenceByDefault: default: false type: boolean defaultWindow: default: allTime type: string enum: - last7days - last30days - last90days - lastYear - allTime description: "How far back the Coach may read, in every answer: snapshot, retrieval tools and summaries alike. `allTime` (the default) is no limit. Formerly only the starting window; `lastYear` came with the limit." dataClusters: maxItems: 10 type: array items: type: string enum: - cardio - body - activity - workouts - sleep - mood - glucose - medication - mobility - environment reminderSuggestions: type: object properties: enabled: default: true type: boolean stopped: default: false type: boolean dismissedCadences: default: [] maxItems: 32 type: array items: type: string maxLength: 64 lastSuggestedAt: default: null anyOf: - type: string maxLength: 40 - type: "null" required: - enabled - stopped - dismissedCadences - lastSuggestedAt additionalProperties: false followUpChips: description: v1.39.4 — offer up to three follow-up chips under the Coach's latest answer. Absent means on; only `false` switches them off. type: boolean reasoning: description: "v1.41 — how hard the Coach thinks before it answers: `off`, `low`, `medium` or `high`. Absent means `medium`. The operator may switch reasoning off or cap the level; the resolved value is published on `GET /api/auth/me` as `coachReasoning`." type: string enum: - off - low - medium - high updatedAt: description: "Optimistic-concurrency token: the stored row's `updatedAt` at read/write time. Echo it back as `baseUpdatedAt` on the next write. Opaque — only ever echo a server-returned value, never parse or synthesise it." type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - tone - verbosity - excludeMetrics - showEvidenceByDefault - defaultWindow additionalProperties: false PutCoachPrefsResponse: type: object properties: data: $ref: "#/components/schemas/CoachPrefsResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false GetDisableCoachResponse: type: object properties: data: $ref: "#/components/schemas/DisableCoachFlagOutput" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false PatchDisableCoachResponse: type: object properties: data: $ref: "#/components/schemas/DisableCoachFlagOutput" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false GetDiabetesResponse: type: object properties: data: $ref: "#/components/schemas/DiabetesFlagOutput" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false PatchDiabetesResponse: type: object properties: data: $ref: "#/components/schemas/DiabetesFlagOutput" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false HealthScoreConfigEnvelope: type: object properties: data: $ref: "#/components/schemas/HealthScoreConfigResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false HealthScoreConfigResponse: type: object properties: pillars: type: array items: $ref: "#/components/schemas/ScorePillarId" description: Registry-ordered pillars that count toward the score. Data availability narrows this further at score time, and that narrowing is not a choice — so this is the composition, not a promise that every pillar has a number today. excludedPillars: type: array items: $ref: "#/components/schemas/ScorePillarId" description: Registry-ordered pillars the person took out. hasSelection: type: boolean description: "True once a selection has been written. It says the person chose, not that their choice differs from the default: an account that opened the surface and kept every pillar has a selection. False means nobody ever chose, and such an account still inherits a later change of defaults." version: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Per-user recipe version, monotonic and incremented on every accepted write. 0 while no selection has been written. A score record names the version that produced it, so a comparison can see the recipe move. changedAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" description: When the recipe last changed; null while it never has. updatedAt: description: "Optimistic-concurrency token: the stored row's `updatedAt` at read/write time. Echo it back as `baseUpdatedAt` on the next write. Opaque — only ever echo a server-returned value, never parse or synthesise it." type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - pillars - excludedPillars - hasSelection - version - changedAt additionalProperties: false description: The resolved Health Score composition. `updatedAt` is ABSENT while the account has never written a selection — there is nothing to guard a write against, so the first save is the unconditional write. PatchHealthScoreConfigEnvelope: type: object properties: data: $ref: "#/components/schemas/HealthScoreConfigResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false GetModulesResponse: type: object properties: data: $ref: "#/components/schemas/ModulesResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false ModulesResponse: type: object properties: modules: $ref: "#/components/schemas/ModuleMap" updatedAt: description: "Optimistic-concurrency token: the stored row's `updatedAt` at read/write time. Echo it back as `baseUpdatedAt` on the next write. Opaque — only ever echo a server-returned value, never parse or synthesise it." type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - modules additionalProperties: false ModuleMap: type: object properties: cycle: type: boolean description: Whether the "cycle" module is enabled for this account. mood: type: boolean description: Whether the "mood" module is enabled for this account. sleep: type: boolean description: Whether the "sleep" module is enabled for this account. glucose: type: boolean description: Whether the "glucose" module is enabled for this account. workouts: type: boolean description: Whether the "workouts" module is enabled for this account. recovery: type: boolean description: Whether the "recovery" module is enabled for this account. labs: type: boolean description: Whether the "labs" module is enabled for this account. illness: type: boolean description: Whether the "illness" module is enabled for this account. achievements: type: boolean description: Whether the "achievements" module is enabled for this account. coach: type: boolean description: Whether the "coach" module is enabled for this account. insights: type: boolean description: Whether the "insights" module is enabled for this account. medications: type: boolean description: Whether the "medications" module is enabled for this account. doctorReport: type: boolean description: Whether the "doctorReport" module is enabled for this account. environment: type: boolean description: Whether the "environment" module is enabled for this account. mcp: type: boolean description: Whether the "mcp" module is enabled for this account. inboundDocuments: type: boolean description: Whether the "inboundDocuments" module is enabled for this account. mentalHealth: type: boolean description: Whether the "mentalHealth" module is enabled for this account. nutrients: type: boolean description: Whether the "nutrients" module is enabled for this account. vaccinations: type: boolean description: Whether the "vaccinations" module is enabled for this account. timeline: type: boolean description: Whether the "timeline" module is enabled for this account. required: - cycle - mood - sleep - glucose - workouts - recovery - labs - illness - achievements - coach - insights - medications - doctorReport - environment - mcp - inboundDocuments - mentalHealth - nutrients - vaccinations - timeline additionalProperties: false description: Fully-resolved module enable/disable map. Every toggleable module key is present. `false` means the surface should disappear end-to-end (nav, dashboard, insights, …). `cycle` mirrors `cycleTrackingEnabled`; `coach` mirrors the resolved per-user opt-out + operator master flag. On `GET /api/auth/me` it answers for the RECORD the session is inside rather than for the caller, because every surface it gates shows the record's data, and it is masked to the sections the active grant opens — a module outside them reads `false` rather than the record's true state. With no switch — which is every native request, since the Bearer transport carries none — the two are the same account, nothing is masked and the payload is unchanged. PatchModulesResponse: type: object properties: data: $ref: "#/components/schemas/ModulesResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false GetSourcePriorityResponse: type: object properties: data: $ref: "#/components/schemas/ResolvedSourcePriority" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false ResolvedSourcePriority: type: object properties: steps: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT activeEnergy: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT walkingRunningDistance: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT flightsClimbed: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT sleep: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT weight: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT bloodPressure: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT pulse: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT bodyFat: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT bodyTemperature: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT spo2: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT hrv: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT restingHeartRate: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT vo2Max: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT skinTemperature: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT respiratoryRate: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT recovery: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT stress: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT sleepDebt: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT hrvRmssd: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT metricPriority: type: object properties: steps: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT activeEnergy: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT walkingRunningDistance: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT flightsClimbed: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT sleep: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT weight: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT bloodPressure: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT pulse: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT bodyFat: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT bodyTemperature: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT spo2: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT hrv: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT restingHeartRate: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT vo2Max: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT skinTemperature: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT respiratoryRate: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT recovery: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT stress: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT sleepDebt: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT hrvRmssd: maxItems: 16 type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT additionalProperties: false deviceTypePriority: type: object properties: default: maxItems: 8 type: array items: type: string enum: - watch - band - ring - phone - scale - other - unknown additionalProperties: maxItems: 8 type: array items: type: string enum: - watch - band - ring - phone - scale - other - unknown inUse: type: array items: type: string enum: - MANUAL - WITHINGS - IMPORT - APPLE_HEALTH - COMPUTED - WHOOP - FITBIT - NIGHTSCOUT - POLAR - OURA - TELEGRAM - MCP - GOOGLE_HEALTH - STRAVA - EXTERNAL - HEALTH_CONNECT description: Ranked sources this account has connected or holds at least one measurement from, in ladder-catalogue order. Read-only; an editor may list only these while the ladders keep every source. required: - inUse additionalProperties: false PutSourcePriorityResponse: type: object properties: data: $ref: "#/components/schemas/ResolvedSourcePriority" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false UploadAvatarResponse: type: object properties: data: type: object properties: avatarUrl: type: string contentType: type: string enum: - image/jpeg - image/png - image/webp updatedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - avatarUrl - contentType - updatedAt additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false GetProfileResponse: type: object properties: data: $ref: "#/components/schemas/ProfileResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false ProfileResponse: type: object properties: username: type: string displayName: type: - string - "null" email: type: - string - "null" dateOfBirth: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" gender: anyOf: - type: string enum: - MALE - FEMALE - OTHER - type: "null" heightCm: type: - number - "null" locale: type: - string - "null" timezone: type: string description: "The IANA zone the server cuts this account's days in, the same value `/api/auth/me` reports: the stored zone, or the instance default when the stored value is not a usable IANA name (since v1.39.1; before, the raw stored value). Always a valid IANA name, so sending it back in a PATCH is accepted." timeFormat: type: string enum: - AUTO - H12 - H24 description: Hour-cycle display preference. AUTO follows the locale convention, H12 forces AM/PM, H24 forces 24-hour. dateFormat: type: string enum: - AUTO - DMY - MDY - YMD description: Date-order display preference. AUTO follows the locale convention, DMY pins day-month-year (dd.MM.yyyy), MDY pins month-day-year (MM/dd/yyyy), YMD pins ISO yyyy-MM-dd. moodReminderEnabled: type: boolean fullName: type: - string - "null" insurerName: type: - string - "null" insurerIkNumber: description: German insurer institution number (IKNR), 9 digits. type: - string - "null" insuranceNumber: description: German KVNR, decrypted for the form prefill. type: - string - "null" modules: $ref: "#/components/schemas/ModuleMap" required: - username - email - dateOfBirth - gender - heightCm - locale - timezone - timeFormat - dateFormat - moodReminderEnabled - fullName - insurerName - insurerIkNumber - insuranceNumber - modules additionalProperties: false description: Flattened profile fields for the native client (GET). The KVNR is decrypted server-side; the IKNR (v1.8.6) is plaintext at rest. The `modules` map mirrors the /api/auth/me projection so the alias carries the same module flags. PatchProfileResponse: type: object properties: data: $ref: "#/components/schemas/ProfileUpdateResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false ProfileUpdateResponse: type: object properties: username: type: string displayName: type: - string - "null" email: type: - string - "null" dateOfBirth: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" gender: anyOf: - type: string enum: - MALE - FEMALE - OTHER - type: "null" heightCm: type: - number - "null" locale: type: - string - "null" timezone: type: string description: "The IANA zone the server cuts this account's days in, the same value `/api/auth/me` reports: the stored zone, or the instance default when the stored value is not a usable IANA name (since v1.39.1; before, the raw stored value). Always a valid IANA name, so sending it back in a PATCH is accepted." timeFormat: type: string enum: - AUTO - H12 - H24 description: Hour-cycle display preference. AUTO follows the locale convention, H12 forces AM/PM, H24 forces 24-hour. dateFormat: type: string enum: - AUTO - DMY - MDY - YMD description: Date-order display preference. AUTO follows the locale convention, DMY pins day-month-year (dd.MM.yyyy), MDY pins month-day-year (MM/dd/yyyy), YMD pins ISO yyyy-MM-dd. moodReminderEnabled: type: boolean fullName: type: - string - "null" insurerName: type: - string - "null" insurerIkNumber: description: German insurer institution number (IKNR), 9 digits. type: - string - "null" hasInsuranceNumber: type: boolean description: Whether a KVNR is on file (the value itself is not echoed). rejectedFields: description: "Present only on a PARTIAL success: the fields that were skipped while the rest of the patch was written. A 200 carrying this key means the save was incomplete — surface it, do not treat the response as a clean save. `code` is the validator code for a field that failed validation, or `rate_limited` for an email-address change that has spent the account's hourly budget; that one is not a bad value and the same address will be accepted once the window rolls over. The handler has always been able to answer with this key on this path; it was published only on PUT /api/auth/profile." type: array items: $ref: "#/components/schemas/ValidationIssue" required: - username - email - dateOfBirth - gender - heightCm - locale - timezone - timeFormat - dateFormat - moodReminderEnabled - fullName - insurerName - insurerIkNumber - hasInsuranceNumber additionalProperties: false description: Profile echo returned by PATCH /api/user/profile. Reports KVNR presence as a boolean rather than re-emitting the decrypted value. NOT the shape `PUT /api/auth/profile` returns — the two share a handler but project it differently (see `AuthProfileUpdateResponse`), which is why this is no longer described as covering both. GetNotificationPrefsResponse: type: object properties: data: $ref: "#/components/schemas/NotificationPrefsResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false NotificationPrefsResponse: type: object properties: medication: type: object properties: clientManaged: type: boolean description: True when the iOS app delivers medication reminders locally. The server then keeps the reminder that announces a dose off APNs (the phone shows its own at dose time) and still pushes the follow-ups for a dose that stays open, which the phone does not send (v1.42.0; before, every MEDICATION_REMINDER push was suppressed). Other channels are never affected. deliveryDefault: type: string enum: - server - client description: 'Roaming user-level delivery default. "client" implies clientManaged: true.' lowStockRunwayDays: anyOf: - type: integer minimum: 1 maximum: 60 - type: "null" description: Low-stock alert threshold as remaining runway days (1–60). null = alert off. Default 7. reorderLeadDays: type: integer minimum: 0 maximum: 60 description: Reorder lead time in days (0–60) the low-stock alert assumes. The alert fires this lead plus one dose-interval before the supply runs out so a refill arrives before the last dose. Default 10; a per-medication reorderLeadDays overrides it. required: - clientManaged - deliveryDefault - lowStockRunwayDays - reorderLeadDays additionalProperties: false mood: type: object properties: reminderHour: type: integer minimum: 0 maximum: 23 description: Local-time hour for the daily mood reminder. Default 22. required: - reminderHour additionalProperties: false cycle: type: object properties: clientManaged: type: boolean description: True when the iOS app owns local cycle reminders; the server-side cycle APNs cron is suppressed. required: - clientManaged additionalProperties: false coach: type: object properties: nudgesEnabled: type: boolean description: Master switch for the proactive Coach nudge cron. nudgeMedication: type: boolean description: Medication-group nudge triggers (compliance). nudgeVitals: type: boolean description: Vitals-group nudge triggers (bp / score / weight / sleep). nudgeRoutine: type: boolean description: Routine-group nudge triggers (measurement gap / self-context). nudgeFrequency: type: string enum: - weekly - biweekly description: "Nudge frequency cap: one per 7 or per 14 days." nudgeAiComposed: type: boolean description: Opt-in for model-written nudge copy. Off by default; the deterministic template is always the fallback. required: - nudgesEnabled - nudgeMedication - nudgeVitals - nudgeRoutine - nudgeFrequency - nudgeAiComposed additionalProperties: false measurementReminder: type: object properties: clientManaged: type: boolean description: True when the iOS app owns local check-up reminders; the server-side measurement-reminder APNs send is then suppressed. Default false. required: - clientManaged additionalProperties: false content: type: object properties: detail: type: string enum: - generic - detailed description: What a notification may show. "generic" (default) names what it is about with no health figure; "detailed" adds figures such as the morning score. Tap targets and quick answers are the same in both. required: - detail additionalProperties: false updatedAt: description: "Optimistic-concurrency token: the stored row's `updatedAt` at read/write time. Echo it back as `baseUpdatedAt` on the next write. Opaque — only ever echo a server-returned value, never parse or synthesise it." type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - medication - mood - cycle - coach - measurementReminder - content additionalProperties: false PatchNotificationPrefsResponse: type: object properties: data: $ref: "#/components/schemas/NotificationPrefsResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false GetReportSelectionResponseEnvelope: type: object properties: data: $ref: "#/components/schemas/GetReportSelectionResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false GetReportSelectionResponse: type: object properties: profile: anyOf: - $ref: "#/components/schemas/SavedReportProfile" - type: "null" description: The saved profile, or null when the account has never saved one, or when the stored blob no longer parses against the current leaf catalogue. Null is never resolved to a default scope server-side — the client applies its own named template and shows that as a visible choice. required: - profile additionalProperties: false PutReportSelectionResponseEnvelope: type: object properties: data: $ref: "#/components/schemas/PutReportSelectionResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false PutReportSelectionResponse: type: object properties: profile: $ref: "#/components/schemas/SavedReportProfile" required: - profile additionalProperties: false GetUnitPreferenceEnvelope: type: object properties: data: $ref: "#/components/schemas/UnitPreferenceResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false UnitPreferenceResponse: type: object properties: unitPreference: type: string enum: - metric - imperial required: - unitPreference additionalProperties: false PatchUnitPreferenceEnvelope: type: object properties: data: $ref: "#/components/schemas/UnitPreferenceResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false GetGlucoseUnitEnvelope: type: object properties: data: $ref: "#/components/schemas/GlucoseUnitResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false GlucoseUnitResponse: type: object properties: glucoseUnit: type: string enum: - mg/dL - mmol/L required: - glucoseUnit additionalProperties: false PatchGlucoseUnitEnvelope: type: object properties: data: $ref: "#/components/schemas/GlucoseUnitResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false GetDocumentsAutoAiReadEnvelope: type: object properties: data: $ref: "#/components/schemas/DocumentsAutoAiReadResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DocumentsAutoAiReadResponse: type: object properties: documentsAutoAiRead: type: boolean required: - documentsAutoAiRead additionalProperties: false PatchDocumentsAutoAiReadEnvelope: type: object properties: data: $ref: "#/components/schemas/DocumentsAutoAiReadResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false GetInjectionSitePrefsEnvelope: type: object properties: data: $ref: "#/components/schemas/InjectionSitePrefsResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false InjectionSitePrefsResponse: type: object properties: globalExcludedInjectionSites: type: array items: type: string enum: - ABDOMEN_LEFT - ABDOMEN_RIGHT - ABDOMEN_UPPER_LEFT - ABDOMEN_UPPER_RIGHT - THIGH_LEFT - THIGH_RIGHT - UPPER_ARM_LEFT - UPPER_ARM_RIGHT description: The stored deny-list, deduplicated and in canonical enum order. required: - globalExcludedInjectionSites additionalProperties: false PatchInjectionSitePrefsEnvelope: type: object properties: data: $ref: "#/components/schemas/InjectionSitePrefsResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false GetThresholdsEnvelope: type: object properties: data: $ref: "#/components/schemas/ThresholdsResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false ThresholdsResponse: type: object properties: effective: type: object propertyNames: type: string additionalProperties: $ref: "#/components/schemas/EffectiveRange" description: "Every `ThresholdMetric`, resolved. Exhaustive: a metric with no override and no derivable default is present with a null `range`, not missing." overrides: $ref: "#/components/schemas/ThresholdOverrides" required: - effective - overrides additionalProperties: false EffectiveRange: type: object properties: range: anyOf: - $ref: "#/components/schemas/TrafficRange" - type: "null" description: The band in force — the override when one is set, otherwise the computed default. Null when the default needs profile data the account has not supplied. isOverride: type: boolean description: Whether `range` came from a stored override. default: anyOf: - $ref: "#/components/schemas/TrafficRange" - type: "null" description: The unmodified computed default, so a client can render current-versus-default without recomputing it. bounds: type: object properties: min: type: number max: type: number unit: type: string description: Canonical unit, e.g. `mmHg`, `kg`, `%`. required: - min - max - unit additionalProperties: false description: Outer physiological guardrails for this metric — the range an override must sit inside. Not a default. required: - range - isOverride - default - bounds additionalProperties: false TrafficRange: type: object properties: greenMin: type: number greenMax: type: number orangeMin: type: number orangeMax: type: number required: - greenMin - greenMax - orangeMin - orangeMax additionalProperties: false description: "A traffic-light band: green is the target window, the orange wings stretch either side of it and are clamped to the metric's physiological bounds." ThresholdOverrides: type: object propertyNames: type: string additionalProperties: type: object properties: min: type: number max: type: number required: - min - max additionalProperties: false description: Stored overrides, keyed by `ThresholdMetric`. Partial by construction — a metric absent from the map is on its computed default. `{}` means no override anywhere. PutThresholdsEnvelope: type: object properties: data: $ref: "#/components/schemas/ThresholdsOverridesResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false ThresholdsOverridesResponse: type: object properties: overrides: $ref: "#/components/schemas/ThresholdOverrides" required: - overrides additionalProperties: false DeleteThresholdsEnvelope: type: object properties: data: $ref: "#/components/schemas/ThresholdsOverridesResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DeviceListEnvelope: type: object properties: data: $ref: "#/components/schemas/DeviceListResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DeviceListResponse: type: object properties: devices: type: array items: $ref: "#/components/schemas/DeviceInfo" required: - devices additionalProperties: false DeviceInfo: type: object properties: id: type: string label: type: string description: "Best available name: the reported model, falling back to the bundle id, falling back to the bare platform string. Never blank." platform: type: string appVersion: type: - string - "null" locale: type: - string - "null" channels: type: array items: type: string enum: - web_push - apns description: Which push transports this device row can currently be reached on. Derived from which token columns are populated, not from a stored list. lastSeenAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ createdAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ isCurrent: type: boolean description: True only when the caller sent `X-Device-Id` AND that device still holds an unrevoked refresh token for this account. A browser session has no such anchor, so a cookie caller sees false on every row — that is correct, not a bug to work around. required: - id - label - platform - appVersion - locale - channels - lastSeenAt - createdAt - isCurrent additionalProperties: false description: A registered device, as the security surface renders it. Identifying metadata only — no push tokens, no APNs identifiers, no IP addresses. DevicePatchEnvelope: type: object properties: data: type: object properties: id: type: string medicationDelivery: anyOf: - type: string enum: - server - client - type: "null" required: - id - medicationDelivery additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false AccountDeviceRevokeEnvelope: type: object properties: data: $ref: "#/components/schemas/DeviceRevokeResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false GetLabsLocalOcrEnvelope: type: object properties: data: $ref: "#/components/schemas/LabsLocalOcrResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false LabsLocalOcrResponse: type: object properties: labsLocalOcrEnabled: type: boolean required: - labsLocalOcrEnabled additionalProperties: false PatchLabsLocalOcrEnvelope: type: object properties: data: $ref: "#/components/schemas/LabsLocalOcrResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false GetWorkoutRoutesEnvelope: type: object properties: data: $ref: "#/components/schemas/WorkoutRoutesResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false WorkoutRoutesResponse: type: object properties: storeWorkoutRoutes: type: boolean required: - storeWorkoutRoutes additionalProperties: false PatchWorkoutRoutesEnvelope: type: object properties: data: $ref: "#/components/schemas/WorkoutRoutesResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false GetUseCentralCodexEnvelope: type: object properties: data: $ref: "#/components/schemas/UseCentralCodexResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false UseCentralCodexResponse: type: object properties: useCentralCodex: type: boolean required: - useCentralCodex additionalProperties: false PatchUseCentralCodexEnvelope: type: object properties: data: $ref: "#/components/schemas/UseCentralCodexResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false LocalePutEnvelope: type: object properties: data: type: object properties: locale: type: string enum: - de - en - fr - es - it - pl - ko - nl - pt-BR required: - locale additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false TimezonePutEnvelope: type: object properties: data: type: object properties: timezone: type: string required: - timezone additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false PasskeyNudgeDismissEnvelope: type: object properties: data: type: object properties: dismissed: type: boolean const: true required: - dismissed additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false CoachConversationsPageResponse: type: object properties: data: $ref: "#/components/schemas/CoachConversationsPage" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false CoachConversationsPage: type: object properties: conversations: type: array items: $ref: "#/components/schemas/CoachConversation" nextCursor: description: Id of the last conversation on this page; pass it back as `cursor` for the next page. Null at the end of the list. type: - string - "null" required: - conversations - nextCursor additionalProperties: false description: Cursor-paginated page of the caller's Coach conversations, most-recent activity first. CoachConversation: type: object properties: id: type: string title: type: string description: Title summarised from the first user message. createdAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ updatedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ description: Bumped on every appended message; the rail orders by this. messageCount: type: integer minimum: -9007199254740991 maximum: 9007199254740991 fenced: type: boolean description: "v1.29.x (S7) — the sticky fence flag. true = a FENCED thread: its turns route through the hardened fenced endpoint (no tools, no health snapshot), never the tool route. Permanent once true. false = a normal health thread." attachments: description: v1.29.x (S7) — the LIVE set of documents attached to this thread. Empty on a health thread (or a fenced thread whose attachments were all detached). type: array items: $ref: "#/components/schemas/CoachConversationAttachment" documentTitle: description: v1.29.x (S7) — the FIRST attachment's resolved title, kept for the rail's single-line badge. Null on a health thread or a fenced thread with no live attachment. type: - string - "null" required: - id - title - createdAt - updatedAt - messageCount - fenced additionalProperties: false description: Lightweight conversation metadata for the history rail. No message bodies — the rail does not decrypt. CoachConversationAttachment: type: object properties: documentId: type: string title: description: The attached document's resolved title (title, falling back to filename). Null when it has neither. Plaintext; no health values. type: - string - "null" required: - documentId - title additionalProperties: false CoachStreamEvent: oneOf: - type: object properties: type: type: string const: token token: type: string required: - type - token additionalProperties: false - type: object properties: type: type: string const: provenance metricSource: $ref: "#/components/schemas/CoachProvenance" required: - type - metricSource additionalProperties: false - type: object properties: type: type: string const: suggestion suggestion: $ref: "#/components/schemas/CoachSuggestion" required: - type - suggestion additionalProperties: false - type: object properties: type: type: string const: suggestedAction suggestedAction: $ref: "#/components/schemas/CoachSuggestedActionCard" required: - type - suggestedAction additionalProperties: false - type: object properties: type: type: string const: reasoning text: type: string required: - type - text additionalProperties: false - type: object properties: type: type: string const: done conversationId: type: string messageId: type: string usage: $ref: "#/components/schemas/CoachUsage" stop: $ref: "#/components/schemas/CoachStop" withheldResults: description: v1.41 — the turn was blocked after interim tables went out; remove them. type: boolean const: true required: - type - conversationId - messageId additionalProperties: false - type: object properties: type: type: string const: error code: type: string message: type: string reason: type: string enum: - check_failed - operator_disabled - not_permitted_for_record - module_disabled - user_disabled - usage_level - no_provider - consent_required required: - type - code - message additionalProperties: false - type: object properties: type: type: string const: step step: $ref: "#/components/schemas/CoachStep" required: - type - step additionalProperties: false - type: object properties: type: type: string const: result result: $ref: "#/components/schemas/CoachResultTable" interim: description: v1.41 — sent while the turn still runs. type: boolean const: true required: - type - result additionalProperties: false - type: object properties: type: type: string const: followUps followUps: type: array items: $ref: "#/components/schemas/CoachFollowUp" required: - type - followUps additionalProperties: false - type: object properties: type: type: string const: clarification clarification: $ref: "#/components/schemas/CoachClarification" required: - type - clarification additionalProperties: false - type: object properties: type: type: string const: activity activity: $ref: "#/components/schemas/CoachActivity" required: - type - activity additionalProperties: false - type: object properties: type: type: string const: memoryNote note: $ref: "#/components/schemas/CoachMemoryNote" required: - type - note additionalProperties: false - type: object properties: type: type: string const: planProposal proposal: $ref: "#/components/schemas/CoachPlanProposal" required: - type - proposal additionalProperties: false description: "One Server-Sent Events frame of a Coach turn, sent as `data: \\n\\n` and dispatched on `type`. Order: `(activity | step)*` → `token*` → `provenance` → `result*` → `suggestion?` → `suggestedAction?` → `memoryNote?` → `planProposal?` → `clarification?` → `followUps?` → `done`, or a single `error`. `result` frames with `interim: true` may also arrive among the `activity` frames. Clients ignore a `type` they do not know." type: object CoachProvenance: type: object properties: windows: readOnly: true description: Analysis windows the assistant drew on this turn. type: array items: type: string enum: - last7days - last30days - last90days - lastYear - allTime metrics: readOnly: true description: Stable metric-topic keys referenced (e.g. bp, weight, sleep, glucose). `general` is the empty-snapshot sentinel. The client translates these labels; the server never localises them. type: array items: type: string enum: - bp - weight - pulse - mood - compliance - hrv - sleep - resting_hr - steps - active_energy - flights - distance - vo2_max - body_temp - walking_hr - respiratory_rate - spo2 - pulse_wave_velocity - vascular_age - body_fat - fat_mass - fat_free_mass - muscle_mass - lean_body_mass - bone_mass - total_body_water - bmi - visceral_fat - glucose - walking_steadiness - walking_asymmetry - walking_double_support - walking_step_length - walking_speed - audio_env - audio_headphone - audio_event - daylight - skin_temp - workouts - general counts: description: Per-metric sample-count summary; absent on an empty snapshot. type: object propertyNames: type: string enum: - bp - weight - pulse - mood - compliance - hrv - sleep - resting_hr - steps - active_energy - flights - distance - vo2_max - body_temp - walking_hr - respiratory_rate - spo2 - pulse_wave_velocity - vascular_age - body_fat - fat_mass - fat_free_mass - muscle_mass - lean_body_mass - bone_mass - total_body_water - bmi - visceral_fat - glucose - walking_steadiness - walking_asymmetry - walking_double_support - walking_step_length - walking_speed - audio_env - audio_headphone - audio_event - daylight - skin_temp - workouts - general additionalProperties: type: integer minimum: -9007199254740991 maximum: 9007199254740991 keyValues: description: Load-bearing numbers the assistant surfaced, rendered in the collapsible evidence block. Hard-capped at 8 entries. readOnly: true type: array items: type: object properties: label: type: string minLength: 1 maxLength: 80 value: type: string minLength: 1 maxLength: 40 unit: type: string maxLength: 16 window: type: string maxLength: 40 required: - label - value additionalProperties: false suggestion: description: The cadence-suggestion card this turn carried, if any. $ref: "#/components/schemas/CoachSuggestion" suggestedAction: description: The confirm-to-apply action card this turn carried, if any. $ref: "#/components/schemas/CoachSuggestedActionCard" toolCalls: description: "v1.20.0 — the retrieval-tool trace for this turn: which tools the Coach called and whether each found data. Metadata only (no values). Absent on the legacy snapshot path and on turns that called no tools." readOnly: true type: array items: type: object properties: name: type: string present: type: boolean required: - name - present additionalProperties: false groundedFigures: description: Bare magnitudes the turn's tools returned, recalled by a later turn's number check. Server-computed, label-less. readOnly: true type: array items: type: number unverifiedFigures: description: v1.32.14 — count of numeric tokens the grounding guard withheld from this reply (each rewritten to the editorial elision mark). Drives the quiet per-message notice. Count only, never the withheld values. Absent when the turn withheld nothing. type: integer exclusiveMinimum: 0 maximum: 9007199254740991 steps: description: v1.39.4 — what the Coach read on this turn. type: array items: $ref: "#/components/schemas/CoachStep" method: description: v1.39.4 — how the answer was reached. $ref: "#/components/schemas/CoachMethod" results: description: v1.39.4 — the tables this turn produced, metadata only; the values are behind the results endpoint. type: array items: $ref: "#/components/schemas/CoachResultMeta" followUps: description: v1.39.4 — the chips offered under this reply. type: array items: $ref: "#/components/schemas/CoachFollowUp" clarification: description: v1.39.4 — the choices, when this reply is a question. $ref: "#/components/schemas/CoachClarification" forcedFinal: description: v1.39.4 — the answer was forced at the round cap while the Coach still wanted to read. Absent otherwise. type: boolean const: true continuationOf: description: "v1.39.4 — this reply continues an answer that was forced at the round cap (the `continue` chip): the id of that earlier assistant message. A continuation offers no further `continue` chip. Absent otherwise." type: string maxLength: 64 activity: description: v1.41 — the turn's trail, metadata only. type: array items: $ref: "#/components/schemas/CoachActivityMeta" stop: description: v1.41 — why the answer was forced, when it was. $ref: "#/components/schemas/CoachStop" assumptions: description: v1.41 — what the answer assumed instead of asking, at most two. type: array items: $ref: "#/components/schemas/CoachAssumption" memoryNote: description: v1.41 — the fact this reply saved or proposes, without its text. $ref: "#/components/schemas/CoachMemoryNoteMeta" planProposal: description: v1.41 — the plan this reply proposes, without its text. $ref: "#/components/schemas/CoachPlanProposalMeta" required: - windows - metrics additionalProperties: false description: Provenance envelope attached to an assistant message — labels and counts only, plus the optional evidence key-values and cards. No raw timestamps and no table values. CoachSuggestion: type: object properties: cadenceId: type: string measurementType: type: string label: type: string required: - cadenceId - measurementType - label additionalProperties: false description: A cadence-suggestion card. Accepting it posts `cadenceId` to `POST /api/measurement-reminders`. CoachSuggestedActionCard: type: object properties: actionType: type: string enum: - checkup.create - reminder.note summary: type: string titleKey: type: string params: oneOf: - type: object properties: actionType: type: string const: checkup.create label: type: string interval: type: string required: - actionType - label - interval additionalProperties: false - type: object properties: actionType: type: string const: reminder.note note: type: string when: type: string metric: type: string required: - actionType - note additionalProperties: false type: object required: - actionType - summary - titleKey - params additionalProperties: false description: A confirm-to-apply action card from a closed allowlist. Nothing is created until the person confirms it through `POST /api/coach/suggested-actions`. CoachStep: type: object properties: id: type: string pattern: ^s[1-9]\d{0,2}$ description: "`s1`..`s64`, unique within a turn." tool: type: string enum: - get_metric_series - get_glucose_panel - get_sleep - get_medication_compliance - get_labs - get_illness_recovery - get_workouts - get_cycle - get_correlations - get_metric_table - get_environment - get_day - show_result - snapshot labelKey: type: string description: Closed catalog key (`coach.step.*`) the client localises. label: type: string description: The label rendered on the server in the request locale. Render it when the client has no string for `labelKey`. domain: $ref: "#/components/schemas/CoachStepDomain" window: type: string enum: - last7days - last30days - last90days - lastYear - allTime period: type: string enum: - current - previous - yearAgo granularity: type: string enum: - day - week - month status: type: string enum: - running - done - empty - failed count: description: Readings or rows the server counted. Never a health value. type: integer minimum: -9007199254740991 maximum: 9007199254740991 reason: type: string enum: - no_data - outside_window - module_disabled - retrieval_failed - invalid_arguments resultRef: description: The table this step produced (`r1`..), when it produced one. type: string pattern: ^r[1-8]$ day: description: The calendar date a `get_day` step read (`YYYY-MM-DD`), so a view can offer the days an answer looked at. type: string required: - id - tool - labelKey - label - status additionalProperties: false description: "One thing the Coach read on a turn. Sent live as `step` frames that upsert by `id` (`running`, then `done` / `empty` / `failed`), and persisted on `metricSource.steps`. Catalog labels, domains, windows and counts only: never free text, an analyte name or a value." CoachStepDomain: type: string enum: - bp - weight - pulse - mood - compliance - hrv - sleep - resting_hr - steps - active_energy - flights - distance - vo2_max - body_temp - walking_hr - respiratory_rate - spo2 - pulse_wave_velocity - vascular_age - body_fat - fat_mass - fat_free_mass - muscle_mass - lean_body_mass - bone_mass - total_body_water - bmi - visceral_fat - glucose - walking_steadiness - walking_asymmetry - walking_double_support - walking_step_length - walking_speed - audio_env - audio_headphone - audio_event - daylight - skin_temp - workouts - labs - illness - cycle - correlations - environment - day - snapshot description: "The data domain a step, table or chip is about: a measurement-backed scope source, or a domain read as a whole (labs, illness, cycle, correlations, the environment, one day of the record, the full snapshot)." CoachMethod: type: object properties: entries: type: array items: type: object properties: domain: $ref: "#/components/schemas/CoachStepDomain" window: type: string enum: - last7days - last30days - last90days - lastYear - allTime period: type: string enum: - current - previous - yearAgo granularity: type: string enum: - day - week - month count: type: integer minimum: -9007199254740991 maximum: 9007199254740991 aggregation: type: string enum: - mean - median - latest - sum - count - rate absent: type: string enum: - no_data - outside_window - module_disabled required: - domain additionalProperties: false text: type: string description: Rendered on the server in the request locale. required: - entries - text additionalProperties: false description: "How an answer was reached: which sources, windows, counts and aggregation. Never a health value." CoachResultMeta: type: object properties: ref: type: string pattern: ^r[1-8]$ description: "`r1`..`r8`, unique within a message." source: type: object properties: tool: type: string enum: - get_metric_series - get_glucose_panel - get_sleep - get_medication_compliance - get_labs - get_illness_recovery - get_workouts - get_cycle - get_correlations - get_metric_table - get_environment - get_day domain: $ref: "#/components/schemas/CoachStepDomain" window: type: string enum: - last7days - last30days - last90days - lastYear - allTime period: type: string enum: - current - previous - yearAgo granularity: type: string enum: - day - week - month required: - tool - domain - window - period additionalProperties: false shape: type: string enum: - timeSeries - categoryCounts - distribution - single titleKey: type: string title: type: string rowCount: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: The full row count, before any trim. chartKind: anyOf: - type: string enum: - line - compare - bar - histogram - type: "null" displayed: type: boolean description: True when the answer referenced the table (shown expanded); false when it sits under the data-used disclosure. reusedFrom: description: Set when the table was copied from an earlier message. type: object properties: messageId: type: string pattern: ^[A-Za-z0-9_-]{1,64}$ ref: type: string pattern: ^r[1-8]$ required: - messageId - ref additionalProperties: false view: description: "`table` when the answer asked for the table view of a table that has a chart; the chart stays for the toggle. Absent: the chart shows first when there is one." type: string const: table required: - ref - source - shape - titleKey - title - rowCount - chartKind - displayed additionalProperties: false description: "A table's metadata, persisted in the plaintext provenance (`metricSource.results`). The values are not here: fetch them from `GET /api/insights/chat/{id}/messages/{messageId}/results`." CoachFollowUp: type: object properties: id: type: string pattern: ^f[1-3]$ description: "`f1`..`f3`." kind: type: string enum: - widen_window - previous_period - year_ago - as_chart - as_table - related_metric - continue - change_assumption labelKey: type: string label: type: string description: Rendered on the server from a catalog; never model text. anchor: type: object properties: ref: type: string pattern: ^(?:r[1-8])?$ domain: $ref: "#/components/schemas/CoachStepDomain" window: type: string enum: - last7days - last30days - last90days - lastYear - allTime granularity: type: string enum: - day - week - month period: type: string enum: - current - previous - yearAgo required: - ref - domain additionalProperties: false reuse: type: boolean description: True when the chip is answered from a stored table without a model call. origin: type: string enum: - server - model assumption: description: "v1.41 — on a `change_assumption` chip: the assumption it replaces and the alternative it picks." type: object properties: kind: type: string enum: - metric - window - comparison value: type: object properties: metric: type: string enum: - bp - weight - pulse - mood - compliance - hrv - sleep - resting_hr - steps - active_energy - flights - distance - vo2_max - body_temp - walking_hr - respiratory_rate - spo2 - pulse_wave_velocity - vascular_age - body_fat - fat_mass - fat_free_mass - muscle_mass - lean_body_mass - bone_mass - total_body_water - bmi - visceral_fat - glucose - walking_steadiness - walking_asymmetry - walking_double_support - walking_step_length - walking_speed - audio_env - audio_headphone - audio_event - daylight - skin_temp - workouts window: type: string enum: - last7days - last30days - last90days - lastYear - allTime comparison: description: v1.41 — what a comparison is drawn against. type: string enum: - previous_period - year_ago - baseline_90d goal: description: v1.41 — an active plan or a goal fact, by id. type: string pattern: ^[A-Za-z0-9_-]{1,64}$ anchor: description: v1.41 — a medication start, an illness episode or a cycle phase, by id. type: string pattern: ^[A-Za-z0-9_-]{1,64}$ additionalProperties: false description: "What a choice stands for: catalog tokens and ids, never text." required: - kind - value additionalProperties: false required: - id - kind - labelKey - label - reuse - origin additionalProperties: false description: "A chip offered under the latest assistant reply. Send it back as `followUp: { messageId, id }` with the chip's label as `message`." CoachClarification: type: object properties: kind: type: string enum: - metric - window - comparison - goal - anchor - context description: "`comparison`, `goal` and `anchor` since v1.41." choices: type: array items: type: object properties: id: type: string pattern: ^c[1-4]$ labelKey: type: string label: type: string value: type: object properties: metric: type: string enum: - bp - weight - pulse - mood - compliance - hrv - sleep - resting_hr - steps - active_energy - flights - distance - vo2_max - body_temp - walking_hr - respiratory_rate - spo2 - pulse_wave_velocity - vascular_age - body_fat - fat_mass - fat_free_mass - muscle_mass - lean_body_mass - bone_mass - total_body_water - bmi - visceral_fat - glucose - walking_steadiness - walking_asymmetry - walking_double_support - walking_step_length - walking_speed - audio_env - audio_headphone - audio_event - daylight - skin_temp - workouts window: type: string enum: - last7days - last30days - last90days - lastYear - allTime comparison: description: v1.41 — what a comparison is drawn against. type: string enum: - previous_period - year_ago - baseline_90d goal: description: v1.41 — an active plan or a goal fact, by id. type: string pattern: ^[A-Za-z0-9_-]{1,64}$ anchor: description: v1.41 — a medication start, an illness episode or a cycle phase, by id. type: string pattern: ^[A-Za-z0-9_-]{1,64}$ additionalProperties: false description: "What a choice stands for: catalog tokens and ids, never text." required: - id - labelKey - label - value additionalProperties: false description: At most 4. Metric choices are always ones the record holds. freeText: type: boolean description: Whether a typed answer is welcome. assumption: description: v1.41 — the choice that applies when the person does not answer. The question names it, and it is the first choice. type: string pattern: ^c[1-4]$ required: - kind - choices - freeText additionalProperties: false description: "The choices for a clarifying question. The question itself is the assistant message's text. Answer with `clarification: { messageId, choiceId? }`." CoachActivityMeta: type: object properties: id: type: string pattern: ^a[1-9]\d?$ description: "`a1`..`a99`, unique within a turn." phase: type: string enum: - thinking - memory - fetch - digest - checkpoint - remember - plan - asking - stop - answer status: type: string enum: - running - done - empty - failed round: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: The tool round, from 1. labelKey: type: string description: Closed catalog key (`insights.coach.activity.*`). label: type: string description: Rendered on the server in the request locale from the catalog; never model text. stepRef: description: The `step` a `fetch` entry stands for. type: string pattern: ^s[1-9]\d{0,2}$ count: description: Readings, lookups or entries the server counted. type: integer minimum: -9007199254740991 maximum: 9007199254740991 durationMs: type: integer minimum: -9007199254740991 maximum: 9007199254740991 stop: description: Set on the `stop` entry. type: string enum: - budget - time - cap - no_progress required: - id - phase - status - round - labelKey - label additionalProperties: false description: "v1.41 — one entry of a turn's trail as persisted on `metricSource.activity`: phase, status, round, catalog label, counts. The model-written title and text are behind `GET /api/insights/chat/{id}/messages/{messageId}/trail`." CoachStop: type: object properties: reason: type: string enum: - budget - time - cap - no_progress rounds: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: The rounds the turn ran, the final one included. required: - reason - rounds additionalProperties: false description: "v1.41 — why the Coach answered before it was done reading: the token budget, the time budget, the round cap, or no new data." CoachAssumption: type: object properties: kind: type: string enum: - metric - window - comparison value: type: object properties: labelKey: type: string label: type: string value: type: object properties: metric: type: string enum: - bp - weight - pulse - mood - compliance - hrv - sleep - resting_hr - steps - active_energy - flights - distance - vo2_max - body_temp - walking_hr - respiratory_rate - spo2 - pulse_wave_velocity - vascular_age - body_fat - fat_mass - fat_free_mass - muscle_mass - lean_body_mass - bone_mass - total_body_water - bmi - visceral_fat - glucose - walking_steadiness - walking_asymmetry - walking_double_support - walking_step_length - walking_speed - audio_env - audio_headphone - audio_event - daylight - skin_temp - workouts window: type: string enum: - last7days - last30days - last90days - lastYear - allTime comparison: description: v1.41 — what a comparison is drawn against. type: string enum: - previous_period - year_ago - baseline_90d goal: description: v1.41 — an active plan or a goal fact, by id. type: string pattern: ^[A-Za-z0-9_-]{1,64}$ anchor: description: v1.41 — a medication start, an illness episode or a cycle phase, by id. type: string pattern: ^[A-Za-z0-9_-]{1,64}$ additionalProperties: false description: "What a choice stands for: catalog tokens and ids, never text." required: - labelKey - label - value additionalProperties: false alternatives: type: array items: type: object properties: labelKey: type: string label: type: string value: type: object properties: metric: type: string enum: - bp - weight - pulse - mood - compliance - hrv - sleep - resting_hr - steps - active_energy - flights - distance - vo2_max - body_temp - walking_hr - respiratory_rate - spo2 - pulse_wave_velocity - vascular_age - body_fat - fat_mass - fat_free_mass - muscle_mass - lean_body_mass - bone_mass - total_body_water - bmi - visceral_fat - glucose - walking_steadiness - walking_asymmetry - walking_double_support - walking_step_length - walking_speed - audio_env - audio_headphone - audio_event - daylight - skin_temp - workouts window: type: string enum: - last7days - last30days - last90days - lastYear - allTime comparison: description: v1.41 — what a comparison is drawn against. type: string enum: - previous_period - year_ago - baseline_90d goal: description: v1.41 — an active plan or a goal fact, by id. type: string pattern: ^[A-Za-z0-9_-]{1,64}$ anchor: description: v1.41 — a medication start, an illness episode or a cycle phase, by id. type: string pattern: ^[A-Za-z0-9_-]{1,64}$ additionalProperties: false description: "What a choice stands for: catalog tokens and ids, never text." required: - labelKey - label - value additionalProperties: false description: At most 3. required: - kind - value - alternatives additionalProperties: false description: v1.41 — what an answer assumed instead of asking, from a catalog. Each alternative is offered as a `change_assumption` follow-up. CoachMemoryNoteMeta: type: object properties: proposal: type: boolean description: "`true`: waiting for the person, answered with `memoryDecision`. `false`: already saved; undo deletes `factId`." proposalId: type: string pattern: ^[A-Za-z0-9_-]{1,64}$ factId: type: string pattern: ^[A-Za-z0-9_-]{1,64}$ category: type: string enum: - preference - goal - context - condition - constraint - medication required: - proposal - category additionalProperties: false description: v1.41 — the fact a reply saved or proposes, as persisted on `metricSource.memoryNote`. No fact text. CoachPlanProposalMeta: type: object properties: planId: type: string pattern: ^[A-Za-z0-9_-]{1,64}$ metric: type: string maxLength: 64 reviewInDays: type: integer minimum: 7 maximum: 56 required: - planId - metric - reviewInDays additionalProperties: false description: v1.41 — the plan a reply proposes, as persisted on `metricSource.planProposal`. No plan text. CoachUsage: type: object properties: totalTokens: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" promptTokens: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" completionTokens: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" model: type: - string - "null" required: - totalTokens additionalProperties: false description: "Per-turn token usage. Server-authoritative: clients display it, never recompute it." CoachResultTable: type: object properties: ref: type: string pattern: ^r[1-8]$ description: "`r1`..`r8`, unique within a message." source: type: object properties: tool: type: string enum: - get_metric_series - get_glucose_panel - get_sleep - get_medication_compliance - get_labs - get_illness_recovery - get_workouts - get_cycle - get_correlations - get_metric_table - get_environment - get_day domain: $ref: "#/components/schemas/CoachStepDomain" window: type: string enum: - last7days - last30days - last90days - lastYear - allTime period: type: string enum: - current - previous - yearAgo granularity: type: string enum: - day - week - month required: - tool - domain - window - period additionalProperties: false shape: type: string enum: - timeSeries - categoryCounts - distribution - single titleKey: type: string title: type: string rowCount: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: The full row count, before any trim. chartKind: anyOf: - type: string enum: - line - compare - bar - histogram - type: "null" displayed: type: boolean description: True when the answer referenced the table (shown expanded); false when it sits under the data-used disclosure. reusedFrom: description: Set when the table was copied from an earlier message. type: object properties: messageId: type: string pattern: ^[A-Za-z0-9_-]{1,64}$ ref: type: string pattern: ^r[1-8]$ required: - messageId - ref additionalProperties: false view: description: "`table` when the answer asked for the table view of a table that has a chart; the chart stays for the toggle. Absent: the chart shows first when there is one." type: string const: table columns: type: array items: type: object properties: key: type: string kind: type: string enum: - period - category - number - count labelKey: type: string label: type: string unit: description: Display unit token such as `mmHg`, never free text. type: string decimals: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - key - kind - labelKey - label additionalProperties: false rows: type: array items: type: array items: type: - string - number - "null" description: Row-major cells, at most 400 rows. `null` is a period with no reading. truncated: type: boolean description: True when `rowCount` exceeds the rows. chart: anyOf: - oneOf: - type: object properties: kind: type: string const: line x: type: string series: type: array items: type: string required: - kind - x - series additionalProperties: false - type: object properties: kind: type: string const: compare mode: type: string enum: - periods - metrics x: type: string a: type: string b: type: string axes: anyOf: - type: number const: 1 - type: number const: 2 required: - kind - mode - x - a - b - axes additionalProperties: false description: "v1.41 — two series on one chart: two periods overlaid (`periods`, one axis) or two metrics (`metrics`, one or two axes). `a` and `b` name the value columns, `x` the shared column." - type: object properties: kind: type: string const: bar x: type: string series: type: array items: type: string orientation: type: string enum: - vertical - horizontal required: - kind - x - series - orientation additionalProperties: false - type: object properties: kind: type: string const: histogram column: type: string unit: type: string bins: type: array items: type: object properties: from: type: number to: type: number count: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - from - to - count additionalProperties: false required: - kind - column - bins additionalProperties: false type: object - type: "null" description: The chart the server chose for the table, or null. required: - ref - source - shape - titleKey - title - rowCount - chartKind - displayed - columns - rows - truncated - chart additionalProperties: false description: "A table of the values a turn read. Health data: encrypted at rest and sent only to the account that owns the conversation." CoachActivity: type: object properties: id: type: string pattern: ^a[1-9]\d?$ description: "`a1`..`a99`, unique within a turn." phase: type: string enum: - thinking - memory - fetch - digest - checkpoint - remember - plan - asking - stop - answer status: type: string enum: - running - done - empty - failed round: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: The tool round, from 1. labelKey: type: string description: Closed catalog key (`insights.coach.activity.*`). label: type: string description: Rendered on the server in the request locale from the catalog; never model text. stepRef: description: The `step` a `fetch` entry stands for. type: string pattern: ^s[1-9]\d{0,2}$ count: description: Readings, lookups or entries the server counted. type: integer minimum: -9007199254740991 maximum: 9007199254740991 durationMs: type: integer minimum: -9007199254740991 maximum: 9007199254740991 stop: description: Set on the `stop` entry. type: string enum: - budget - time - cap - no_progress title: description: A screened reasoning title or checkpoint sentence. type: string maxLength: 80 text: description: A screened reasoning summary for the round. type: string maxLength: 400 required: - id - phase - status - round - labelKey - label additionalProperties: false description: v1.41 — one entry of the live trail, sent as `activity` frames that upsert by `id`. `title` and `text` are model text, screened, and sent only to the account that owns the conversation. CoachMemoryNote: type: object properties: proposal: type: boolean description: "`true`: waiting for the person, answered with `memoryDecision`. `false`: already saved; undo deletes `factId`." proposalId: type: string pattern: ^[A-Za-z0-9_-]{1,64}$ factId: type: string pattern: ^[A-Za-z0-9_-]{1,64}$ category: type: string enum: - preference - goal - context - condition - constraint - medication fact: type: string maxLength: 160 required: - proposal - category - fact additionalProperties: false description: v1.41 — a fact the Coach saved during the turn, or proposes to save (health facts are never saved without a tap). Owner only. CoachPlanProposal: type: object properties: planId: type: string pattern: ^[A-Za-z0-9_-]{1,64}$ metric: type: string maxLength: 64 reviewInDays: type: integer minimum: 7 maximum: 56 ifCue: type: string thenAction: type: string target: type: string required: - planId - metric - reviewInDays - ifCue - thenAction additionalProperties: false description: "v1.41 — a plan the Coach proposes, written as `proposed`. Answer with `planDecision: { messageId, planId, accept }`. Owner only." CoachMessageResultsResponse: type: object properties: data: type: object properties: results: type: array items: $ref: "#/components/schemas/CoachResultEntry" required: - results additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false CoachResultEntry: anyOf: - $ref: "#/components/schemas/CoachResultTable" - type: object properties: ref: type: string pattern: ^r[1-8]$ withheld: type: string enum: - module_disabled - unavailable description: "`module_disabled`: the table's domain is switched off for this record. `unavailable`: the stored tables could not be read." required: - ref - withheld additionalProperties: false description: "One stored table, or the reason it is not served. Absence is explicit: a withheld table names why." CoachMessageTrailResponse: type: object properties: data: type: object properties: trail: anyOf: - $ref: "#/components/schemas/CoachTrail" - type: "null" ai: description: The `coach` capability. While unavailable `trail.entries` is empty. $ref: "#/components/schemas/AiCapabilityState" required: - trail - ai additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false CoachTrail: type: object properties: entries: type: array items: type: object properties: id: type: string pattern: ^a[1-9]\d?$ title: type: string maxLength: 80 text: type: string maxLength: 400 required: - id additionalProperties: false recalled: type: array items: type: string maxLength: 160 proposal: type: object properties: proposalId: type: string pattern: ^[A-Za-z0-9_-]{1,64}$ category: type: string enum: - preference - goal - context - condition - constraint - medication fact: type: string maxLength: 160 required: - proposalId - category - fact additionalProperties: false required: - entries additionalProperties: false description: v1.41 — the model-written text of a turn's trail and the fact texts it touched. Encrypted at rest and served only to the account that owns the conversation. CoachConversationDetailResponse: type: object properties: data: $ref: "#/components/schemas/CoachConversationDetail" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false CoachConversationDetail: type: object properties: id: type: string title: type: string createdAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ updatedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ messageCount: type: integer minimum: -9007199254740991 maximum: 9007199254740991 messages: type: array items: $ref: "#/components/schemas/CoachMessage" description: Every message in the conversation, decrypted, oldest-first. summary: description: Rolling summary of turns elided past the history window; null when none is on file. type: - string - "null" fenced: type: boolean description: v1.29.x (S7) — the sticky fence flag; the client routes a fenced thread's turns through the hardened fenced endpoint, never the tool route. attachments: description: v1.29.x (S7) — the LIVE set of attached documents (join rows), attach-time order. type: array items: $ref: "#/components/schemas/CoachConversationAttachment" attachmentCount: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: v1.29.x (S7) — count of live attachment rows (equal to attachments.length). documentTitle: description: v1.29.x (S7) — the first attachment's resolved title for the rail badge. Null on a health thread. type: - string - "null" required: - id - title - createdAt - updatedAt - messageCount - messages - fenced - attachmentCount additionalProperties: false description: Full conversation with every message decrypted server-side. The client renders the bodies directly; no decryption key is involved. CoachMessage: type: object properties: id: type: string role: type: string enum: - user - assistant content: type: string description: Decrypted message body — the server decrypts on read. createdAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ metricSource: anyOf: - $ref: "#/components/schemas/CoachProvenance" - type: "null" description: Provenance envelope (assistant turns); null for user turns. providerType: description: 'Provider that produced the reply (e.g. anthropic, openai, local, refusal). The sentinel `cancelled` marks an empty assistant row closing a turn the client aborted mid-generation (navigation away); clients should render it as an interrupted turn with a retry affordance. v1.39.4 — the sentinel `reuse` marks a turn answered from a table already stored in the conversation (a follow-up chip such as "as a chart"), with no model call: render it as a normal assistant turn without a token footer. Null for user turns.' type: - string - "null" promptVersion: description: Coach prompt version that produced the reply; null for user turns. type: - string - "null" tokensUsed: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" description: v1.18.9 — total tokens this assistant turn cost, persisted so the per-message token footer survives a reload. Null on user turns, refusals, and pre-feature rows. model: description: v1.18.9 — the provider model that produced the reply (e.g. gpt-4o). Null when unknown (user turns, refusals, older rows). type: - string - "null" required: - id - role - content - createdAt - metricSource - providerType - promptVersion - tokensUsed - model additionalProperties: false description: One Coach chat message, decrypted server-side. Ordered oldest-first within a conversation. CoachConversationRenamed: type: object properties: data: type: object properties: id: type: string title: type: string required: - id - title additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false CoachConversationDeleted: type: object properties: data: type: object properties: deleted: type: boolean const: true description: Always true on success; a foreign / unknown id is a 404. required: - deleted additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false CoachAttachmentsResponse: type: object properties: data: $ref: "#/components/schemas/CoachAttachments" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false CoachAttachments: type: object properties: attachments: type: array items: $ref: "#/components/schemas/CoachConversationAttachment" description: The conversation's refreshed live attachment set. fenced: type: boolean description: The sticky fence flag — always true after an attach; unchanged (stays true) by a detach. required: - attachments - fenced additionalProperties: false CoachAttachmentsResponseDetach: type: object properties: data: $ref: "#/components/schemas/CoachAttachments" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false InsightsLayout: type: object properties: data: $ref: "#/components/schemas/InsightsLayoutResult" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false InsightsLayoutResult: type: object properties: version: anyOf: - type: number const: 1 - type: number const: 2 sections: maxItems: 50 type: array items: type: object properties: id: type: string enum: - wellness-scores - daily-briefing - vitals - trends - period-review - cycle-summary - signals - rhythm-events - health-status - breathing - labs-changes - ecg visible: type: boolean order: type: integer minimum: 0 maximum: 99 required: - id - visible - order additionalProperties: false tiles: minItems: 1 maxItems: 63 type: array items: type: object properties: id: type: string enum: - overview - blood-pressure - pulse - oxygen - body-temperature - respiratory-rate - pain - weight - bmi - body-fat - body-water - bone-mass - fat-free-mass - fat-mass - muscle-mass - visceral-fat - lean-body-mass - waist - waist-to-height - steps - active-energy - workouts - flights-climbed - walking-distance - walking-steadiness - walking-heart-rate - walking-asymmetry - double-support-time - step-length - walking-speed - cardio-fitness - falls - six-minute-walk - stair-ascent-speed - stair-descent-speed - grip-strength - sleep - breathing-disturbances - resting-pulse - hrv - pulse-wave-velocity - vascular-age - cardio-recovery - environmental-audio - headphone-audio - audio-events - daylight - blood-glucose - skin-temperature - wrist-temperature - mood - medications - nutrients - blutdruck - puls - sauerstoff - koerpertemperatur - gewicht - aktive-energie - schlaf - ruhepuls - stimmung - medikamente visible: type: boolean order: type: integer minimum: 0 maximum: 99 required: - id - visible - order additionalProperties: false updatedAt: description: "Optimistic-concurrency token: the stored row's `updatedAt` at read/write time. Echo it back as `baseUpdatedAt` on the next write. Opaque." type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - version additionalProperties: false description: Resolved Insights layout plus the optimistic-concurrency `updatedAt` token. InsightsLayoutSaved: type: object properties: data: $ref: "#/components/schemas/InsightsLayoutResult" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false InsightsLayoutReset: type: object properties: data: $ref: "#/components/schemas/InsightsLayoutResult" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false CoachFactsList: type: object properties: data: type: object properties: facts: type: array items: type: object properties: id: type: string category: type: string enum: - preference - goal - context - condition - constraint - medication description: "App-side closed category: preference | goal | context | condition | constraint | medication (v1.41)." text: type: string description: Decrypted fact text. confidence: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: 0..100 server-assigned extraction confidence. source: type: string enum: - user - coach - extracted - pattern - proposed description: "v1.41 — where the fact came from: `user` (the remember button, or a proposal the caller confirmed), `coach` (saved during a turn), `extracted` (the background extraction; every fact older than v1.41), `pattern` (the deterministic matcher). `proposed` is never listed." sourceConversationId: description: The conversation the fact came out of, when known. type: - string - "null" sourceMessageId: description: v1.41 — the message the fact came out of, when known. type: - string - "null" lastUsedAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" description: v1.41 — when the fact last went into a Coach turn. createdAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ updatedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ description: v1.41 — when the fact was last written or edited. required: - id - category - text - confidence - source - sourceConversationId - sourceMessageId - lastUsedAt - createdAt - updatedAt additionalProperties: false description: The caller's active facts, highest-confidence then newest first. Undecryptable rows are omitted. required: - facts additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false CoachFactAlreadyKnown: type: object properties: data: type: object properties: fact: type: object properties: id: type: string category: type: string enum: - preference - goal - context - condition - constraint - medication description: "App-side closed category: preference | goal | context | condition | constraint | medication (v1.41)." text: type: string description: The fact as stored. source: type: string enum: - user - coach - extracted - pattern - proposed description: "v1.41 — where the fact came from: `user` (the remember button, or a proposal the caller confirmed), `coach` (saved during a turn), `extracted` (the background extraction; every fact older than v1.41), `pattern` (the deterministic matcher). `proposed` is never listed." required: - id - category - text - source additionalProperties: false created: type: boolean description: False when the caller's memory already held the same fact (or a proposal for it, now confirmed); the existing fact is returned. required: - fact - created additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false CoachFactCreated: type: object properties: data: type: object properties: fact: type: object properties: id: type: string category: type: string enum: - preference - goal - context - condition - constraint - medication description: "App-side closed category: preference | goal | context | condition | constraint | medication (v1.41)." text: type: string description: The fact as stored. source: type: string enum: - user - coach - extracted - pattern - proposed description: "v1.41 — where the fact came from: `user` (the remember button, or a proposal the caller confirmed), `coach` (saved during a turn), `extracted` (the background extraction; every fact older than v1.41), `pattern` (the deterministic matcher). `proposed` is never listed." required: - id - category - text - source additionalProperties: false created: type: boolean description: False when the caller's memory already held the same fact (or a proposal for it, now confirmed); the existing fact is returned. required: - fact - created additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false CoachFactsCleared: type: object properties: data: type: object properties: cleared: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Number of active facts soft-deleted by the bulk clear. required: - cleared additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false CoachFactUpdated: type: object properties: data: type: object properties: fact: type: object properties: id: type: string category: type: string enum: - preference - goal - context - condition - constraint - medication description: "App-side closed category: preference | goal | context | condition | constraint | medication (v1.41)." text: type: string description: The fact as stored. source: type: string enum: - user - coach - extracted - pattern - proposed description: "v1.41 — where the fact came from: `user` (the remember button, or a proposal the caller confirmed), `coach` (saved during a turn), `extracted` (the background extraction; every fact older than v1.41), `pattern` (the deterministic matcher). `proposed` is never listed." updatedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - id - category - text - source - updatedAt additionalProperties: false required: - fact additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false CoachFactDeleted: type: object properties: data: type: object properties: deleted: type: boolean description: True when a fact owned by the caller was soft-deleted; false for an unknown / cross-user / already-deleted id (idempotent no-op). required: - deleted additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false CoachNudgeStatus: type: object properties: data: type: object properties: nudgedAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" description: Timestamp of the newest Coach assistant message; null when none exists. unread: type: boolean description: True when that message is newer than the last time the caller opened the Coach. conversationId: description: Conversation holding the newest assistant message; null when none exists. The client deep-links into it (`/coach?c=`) on the unread path. type: - string - "null" ai: description: The `coach` capability. While unavailable the three fields above are the quiet empty values. $ref: "#/components/schemas/AiCapabilityState" required: - nudgedAt - unread - conversationId - ai additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false CoachSeenResponse: type: object properties: data: type: object properties: seenAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ description: The server-minted open timestamp. required: - seenAt additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false GetCoachAboutMeResponse: type: object properties: data: type: object properties: aboutMe: type: - string - "null" conditions: type: - string - "null" allergies: type: - string - "null" coachFocus: type: - string - "null" aiIncludedSections: type: array items: type: string enum: - ABOUT_ME - CONDITIONS - ALLERGIES - COACH_FOCUS - FAMILY_HISTORY - SMOKING_STATUS - ALCOHOL_PATTERN - SHIFT_SCHEDULE pendingQuestions: type: array items: type: string updatedAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" maxChars: type: integer minimum: -9007199254740991 maximum: 9007199254740991 fieldMaxChars: type: integer minimum: -9007199254740991 maximum: 9007199254740991 ai: description: The `aboutMeQuestions` capability behind model-written questions. $ref: "#/components/schemas/AiCapabilityState" required: - aboutMe - conditions - allergies - coachFocus - aiIncludedSections - pendingQuestions - updatedAt - maxChars - fieldMaxChars - ai additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false PutCoachAboutMeResponse: type: object properties: data: type: object properties: aboutMe: type: - string - "null" conditions: type: - string - "null" allergies: type: - string - "null" coachFocus: type: - string - "null" aiIncludedSections: type: array items: type: string enum: - ABOUT_ME - CONDITIONS - ALLERGIES - COACH_FOCUS - FAMILY_HISTORY - SMOKING_STATUS - ALCOHOL_PATTERN - SHIFT_SCHEDULE pendingQuestions: type: array items: type: string updatedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ maxChars: type: integer minimum: -9007199254740991 maximum: 9007199254740991 fieldMaxChars: type: integer minimum: -9007199254740991 maximum: 9007199254740991 ai: description: The `aboutMeQuestions` capability the derivation ran under. $ref: "#/components/schemas/AiCapabilityState" required: - aboutMe - conditions - allergies - coachFocus - aiIncludedSections - pendingQuestions - updatedAt - maxChars - fieldMaxChars - ai additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false GetAnamnesisFactsResponse: type: object properties: data: type: object properties: current: type: object properties: SMOKING_STATUS: anyOf: - type: object properties: id: type: string kind: type: string enum: - SMOKING_STATUS - ALCOHOL_PATTERN - SHIFT_SCHEDULE value: type: - string - "null" unreadable: type: boolean validFrom: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ validUntil: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" provenance: type: string enum: - USER_REPORTED - USER_CORRECTION supersededByRevisionId: type: - string - "null" createdAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - id - kind - value - unreadable - validFrom - validUntil - provenance - supersededByRevisionId - createdAt additionalProperties: false - type: "null" ALCOHOL_PATTERN: anyOf: - type: object properties: id: type: string kind: type: string enum: - SMOKING_STATUS - ALCOHOL_PATTERN - SHIFT_SCHEDULE value: type: - string - "null" unreadable: type: boolean validFrom: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ validUntil: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" provenance: type: string enum: - USER_REPORTED - USER_CORRECTION supersededByRevisionId: type: - string - "null" createdAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - id - kind - value - unreadable - validFrom - validUntil - provenance - supersededByRevisionId - createdAt additionalProperties: false - type: "null" SHIFT_SCHEDULE: anyOf: - type: object properties: id: type: string kind: type: string enum: - SMOKING_STATUS - ALCOHOL_PATTERN - SHIFT_SCHEDULE value: type: - string - "null" unreadable: type: boolean validFrom: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ validUntil: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" provenance: type: string enum: - USER_REPORTED - USER_CORRECTION supersededByRevisionId: type: - string - "null" createdAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - id - kind - value - unreadable - validFrom - validUntil - provenance - supersededByRevisionId - createdAt additionalProperties: false - type: "null" required: - SMOKING_STATUS - ALCOHOL_PATTERN - SHIFT_SCHEDULE additionalProperties: false history: type: array items: type: object properties: id: type: string kind: type: string enum: - SMOKING_STATUS - ALCOHOL_PATTERN - SHIFT_SCHEDULE value: type: - string - "null" unreadable: type: boolean validFrom: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ validUntil: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" provenance: type: string enum: - USER_REPORTED - USER_CORRECTION supersededByRevisionId: type: - string - "null" createdAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - id - kind - value - unreadable - validFrom - validUntil - provenance - supersededByRevisionId - createdAt additionalProperties: false required: - current - history additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false CreateAnamnesisFactResponse: type: object properties: data: anyOf: - type: object properties: id: type: string kind: type: string enum: - SMOKING_STATUS - ALCOHOL_PATTERN - SHIFT_SCHEDULE value: type: - string - "null" unreadable: type: boolean validFrom: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ validUntil: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" provenance: type: string enum: - USER_REPORTED - USER_CORRECTION supersededByRevisionId: type: - string - "null" createdAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - id - kind - value - unreadable - validFrom - validUntil - provenance - supersededByRevisionId - createdAt additionalProperties: false - type: object properties: id: type: string kind: type: string enum: - SMOKING_STATUS - SHIFT_SCHEDULE - TOBACCO_DETAIL - ACTIVITY_HABIT - WORK_CONTEXT - FREE_WEEKDAYS - HABITS - HEALTH_GOALS value: description: "`SMOKING_STATUS` and `SHIFT_SCHEDULE` carry their string value; every other kind an object with `schema: 1` whose shape is fixed per kind (see the life-circumstances contract). `null` when `unreadable`." unreadable: type: boolean description: The sealed value could not be opened on this host. validFrom: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ validUntil: type: "null" provenance: type: string enum: - USER_REPORTED - USER_CORRECTION createdAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - id - kind - value - unreadable - validFrom - validUntil - provenance - createdAt additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false CorrectAnamnesisFactResponse: type: object properties: data: anyOf: - type: object properties: id: type: string kind: type: string enum: - SMOKING_STATUS - ALCOHOL_PATTERN - SHIFT_SCHEDULE value: type: - string - "null" unreadable: type: boolean validFrom: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ validUntil: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" provenance: type: string enum: - USER_REPORTED - USER_CORRECTION supersededByRevisionId: type: - string - "null" createdAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - id - kind - value - unreadable - validFrom - validUntil - provenance - supersededByRevisionId - createdAt additionalProperties: false - type: object properties: id: type: string kind: type: string enum: - SMOKING_STATUS - SHIFT_SCHEDULE - TOBACCO_DETAIL - ACTIVITY_HABIT - WORK_CONTEXT - FREE_WEEKDAYS - HABITS - HEALTH_GOALS value: description: "`SMOKING_STATUS` and `SHIFT_SCHEDULE` carry their string value; every other kind an object with `schema: 1` whose shape is fixed per kind (see the life-circumstances contract). `null` when `unreadable`." unreadable: type: boolean description: The sealed value could not be opened on this host. validFrom: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ validUntil: type: "null" provenance: type: string enum: - USER_REPORTED - USER_CORRECTION createdAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - id - kind - value - unreadable - validFrom - validUntil - provenance - createdAt additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false RemoveAnamnesisFactResponse: type: object properties: data: type: object properties: id: type: string kind: type: string enum: - SMOKING_STATUS - ALCOHOL_PATTERN - SHIFT_SCHEDULE - TOBACCO_DETAIL - ACTIVITY_HABIT - WORK_CONTEXT - FREE_WEEKDAYS - HABITS - HEALTH_GOALS removedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - id - kind - removedAt additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false GetEmergencyProfileResponse: type: object properties: data: $ref: "#/components/schemas/EmergencyProfile" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false EmergencyProfile: type: object properties: bloodType: anyOf: - type: string enum: - A_POS - A_NEG - B_POS - B_NEG - AB_POS - AB_NEG - O_POS - O_NEG - UNKNOWN - type: "null" organDonor: anyOf: - type: string enum: - YES - NO - UNKNOWN - type: "null" advanceDirective: anyOf: - type: string enum: - EXISTS - NONE - UNKNOWN - type: "null" contacts: type: - string - "null" contactsUnreadable: type: boolean implants: type: - string - "null" implantsUnreadable: type: boolean note: type: - string - "null" noteUnreadable: type: boolean required: - bloodType - organDonor - advanceDirective - contacts - contactsUnreadable - implants - implantsUnreadable - note - noteUnreadable additionalProperties: false description: "The caller's emergency profile: three closed-enum facts plus three decrypted free-text fields. A free-text field is null when unset; its `*Unreadable` flag is true when ciphertext was present but could not be decrypted (a key-rotation gap, fail-soft rather than 500)." UpdateEmergencyProfileResponse: type: object properties: data: $ref: "#/components/schemas/EmergencyProfile" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false GetCoachAboutMeQuestionsResponse: type: object properties: data: type: object properties: questions: type: array items: type: string required: - questions additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DeleteCoachAboutMeQuestionsResponse: type: object properties: data: type: object properties: questions: type: array items: type: string required: - questions additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false CoachAboutMeAdoptResponse: type: object properties: data: type: object properties: adopted: type: boolean field: type: string enum: - conditions - allergies - coachFocus - aboutMe description: Where the answer went. `aboutMe` appears when the matched structured field was at its cap and the text overflowed into the free-text slot. reason: description: "Present only on the `adopted: false` arm." type: string const: duplicate required: - adopted - field additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false CoachPlansList: type: object properties: data: type: object properties: plans: type: array items: $ref: "#/components/schemas/CoachPlan" description: The caller's plans, newest first. Undecryptable rows are omitted from the list endpoint. No `status` filter returns the non-terminal set (proposed + active). required: - plans additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false CoachPlan: type: object properties: id: type: string metric: type: string description: The metric this plan moves (e.g. WEIGHT, SLEEP). ifCue: description: Decrypted if-cue (the trigger). Null when the row's key id is no longer in the map. type: - string - "null" thenAction: description: Decrypted then-action. Null when the row's key id is no longer in the map. type: - string - "null" target: description: Decrypted optional target; null when none / undecryptable. type: - string - "null" status: type: string enum: - proposed - active - met - abandoned - review_due - reviewed description: "App-side lifecycle: proposed | active | met | abandoned." reviewDate: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" description: Optional check-in checkpoint; null when none. sourceConversationId: description: Id of the conversation the proposal was extracted from; null for a plan without conversation provenance. Lets a chat surface show only the proposals born in the open thread. type: - string - "null" createdAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ updatedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - id - metric - ifCue - thenAction - target - status - reviewDate - sourceConversationId - createdAt - updatedAt additionalProperties: false description: One Coach goal / if-then plan, decrypted server-side. The free-text fields read null only when the encryption key id has rotated out of the map. CoachPlanUpdated: type: object properties: data: type: object properties: plan: description: The plan after the lifecycle update. $ref: "#/components/schemas/CoachPlan" required: - plan additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false CoachPlanDeleted: type: object properties: data: type: object properties: deleted: type: boolean description: True when a plan owned by the caller was soft-deleted; false for an unknown / cross-user / already-deleted id (idempotent no-op). required: - deleted additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false CoachReminderSuggestionResultOk: type: object properties: data: $ref: "#/components/schemas/CoachReminderSuggestionResult" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false CoachReminderSuggestionResult: type: object properties: ok: type: boolean const: true action: type: string enum: - accept - dismiss - stop reminder: description: "On `accept` of a fresh suggestion: the created MeasurementReminder DTO. Null when the action was a prefs-only dismiss/stop, or when an accept hit the structural dedup (`duplicate: true`)." anyOf: - {} - type: "null" duplicate: description: True when an `accept` matched an already-live COACH reminder for the same metric (idempotent no-op). type: boolean required: - ok - action additionalProperties: false description: "Outcome of a Coach cadence-suggestion action. `accept` returns 201 with the created reminder (or 200 + `duplicate: true` when one already exists); `dismiss`/`stop` return 200." CoachReminderSuggestionResultCreated: type: object properties: data: $ref: "#/components/schemas/CoachReminderSuggestionResult" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false CoachRemindersList: type: object properties: data: type: object properties: reminders: type: array items: $ref: "#/components/schemas/CoachReminder" description: The caller's reminders, soonest-due first. Undecryptable rows are omitted. No `status` filter returns the non-terminal set (proposed + active + due + surfaced). required: - reminders additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false CoachReminder: type: object properties: id: type: string note: description: Decrypted note (the user's own framing). Null when the row's key id is no longer in the map. type: - string - "null" metric: type: - string - "null" triggerKind: type: string enum: - date - context dueAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" contextCue: type: - string - "null" status: type: string enum: - proposed - active - due - surfaced - done - dismissed source: type: string enum: - sentinel - extractor - manual - action createdAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ updatedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - id - note - metric - triggerKind - dueAt - contextCue - status - source - createdAt - updatedAt additionalProperties: false description: One Coach episodic reminder, decrypted server-side. The note reads null only when the encryption key id has rotated out of the map. CoachReminderCreated: type: object properties: data: type: object properties: reminder: $ref: "#/components/schemas/CoachReminder" required: - reminder additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false CoachReminderUpdated: type: object properties: data: type: object properties: reminder: $ref: "#/components/schemas/CoachReminder" required: - reminder additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false CoachReminderDeleted: type: object properties: data: type: object properties: deleted: type: boolean description: True when a reminder owned by the caller was soft-deleted; false for an unknown / cross-user / already-deleted id (idempotent no-op). required: - deleted additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false CoachSuggestedActionResultCreated: type: object properties: data: $ref: "#/components/schemas/CoachSuggestedActionResult" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false CoachSuggestedActionResult: type: object properties: ok: type: boolean const: true actionType: type: string enum: - checkup.create - reminder.note reminder: {} reminderId: type: string required: - ok - actionType additionalProperties: false description: Outcome of confirming a Coach action card. `checkup.create` returns the created MeasurementReminder DTO; `reminder.note` returns the created CoachReminder id. AdminInviteList: type: object properties: data: type: array items: type: object properties: id: type: string createdAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ expiresAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ usedAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" revokedAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" uses: type: integer minimum: -9007199254740991 maximum: 9007199254740991 maxUses: type: integer minimum: -9007199254740991 maximum: 9007199254740991 creator: anyOf: - type: object properties: id: type: string username: type: string required: - id - username additionalProperties: false - type: "null" consumer: anyOf: - type: object properties: id: type: string username: type: string required: - id - username additionalProperties: false - type: "null" redemptions: type: array items: type: object properties: id: type: string redeemedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ user: anyOf: - type: object properties: id: type: string username: type: string email: type: - string - "null" required: - id - username - email additionalProperties: false - type: "null" required: - id - redeemedAt - user additionalProperties: false required: - id - createdAt - expiresAt - usedAt - revokedAt - uses - maxUses - creator - consumer - redemptions additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false AdminInviteCreated: type: object properties: data: type: object properties: id: type: string createdAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ expiresAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ uses: type: integer minimum: -9007199254740991 maximum: 9007199254740991 maxUses: type: integer minimum: -9007199254740991 maximum: 9007199254740991 token: type: string description: Raw invite token (`hlv_<64hex>`). Shown exactly once — never persisted in plaintext. url: type: string description: Composed invite universal-link (`/invite/`). On iOS it opens the app onto onboarding registration; in a browser it redirects to `/auth/register?invite=…`. required: - id - createdAt - expiresAt - uses - maxUses - token - url additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false AdminInviteRevoked: type: object properties: data: type: object properties: revoked: type: boolean required: - revoked additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false CoachMessageFeedbackResponse: type: object properties: data: type: object properties: id: type: string createdAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - id - createdAt additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false AdminMeasurementMaintenanceStatusResponse: type: object properties: data: $ref: "#/components/schemas/AdminMeasurementMaintenanceStatus" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false AdminMeasurementMaintenanceStatus: type: object properties: available: type: boolean description: False when there is no background queue to ask (a web-only deployment); nothing can be started then. purgePending: type: boolean description: The compaction-tombstone purge is queued or running. A run started now finishes at once with `refused_purge_running`. run: anyOf: - type: object properties: state: type: string enum: - queued - running - completed - failed requestedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z))$ startedAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z))$ - type: "null" finishedAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z))$ - type: "null" outcome: anyOf: - type: string enum: - completed - refused_purge_running - already_running - type: "null" description: "How a `completed` run ended: `completed` did the work, the two refusals finished without touching the table. Null for any other state." required: - state - requestedAt - startedAt - finishedAt - outcome additionalProperties: false - type: "null" description: The newest run pg-boss still keeps (terminal rows live seven days), or null when there is none. sizes: anyOf: - type: object properties: tableBytes: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: "`measurements` with its indexes and TOAST." indexBytes: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Its indexes alone. required: - tableBytes - indexBytes additionalProperties: false - type: "null" required: - available - purgePending - run - sizes additionalProperties: false AdminMeasurementMaintenanceResponse: type: object properties: data: $ref: "#/components/schemas/AdminMeasurementMaintenanceResult" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false AdminMeasurementMaintenanceResult: type: object properties: enqueued: type: boolean description: False when a maintenance run is already queued or running; nothing new was queued. required: - enqueued additionalProperties: false AdminReminderCheckResponse: type: object properties: data: $ref: "#/components/schemas/AdminReminderCheckResult" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false AdminReminderCheckResult: type: object properties: timestamp: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ missedThresholdMinutes: type: integer minimum: -9007199254740991 maximum: 9007199254740991 scoped: type: boolean description: True when the request named a `userId` and the sweep therefore walked one account only. medications: type: array items: $ref: "#/components/schemas/AdminReminderMedication" notificationsSent: type: integer minimum: -9007199254740991 maximum: 9007199254740991 message: type: string required: - timestamp - missedThresholdMinutes - scoped - medications - notificationsSent - message additionalProperties: false description: What the sweep found and what it sent. `notificationsSent` counts DISPATCHES, not deliveries — each one enters the channel cascade and the per-channel verdict lands in the `push_attempts` ledger the notification diagnostic surfaces. AdminReminderMedication: type: object properties: name: type: string dose: type: string user: type: string timezone: type: string localTime: type: string dayOfWeek: type: string notificationsEnabled: type: boolean schedules: type: array items: $ref: "#/components/schemas/AdminReminderScheduleStatus" eventsToday: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - name - dose - user - timezone - localTime - dayOfWeek - notificationsEnabled - schedules - eventsToday additionalProperties: false description: One active medication the sweep walked, with the account it belongs to and that account's local clock — the sweep classifies every window against the owner's timezone, never the operator's. AdminReminderScheduleStatus: type: object properties: window: type: string days: type: string status: type: string enum: - open - late - threshold - missed - skipped label: type: string minutesPastEnd: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" notificationSent: type: boolean required: - window - days - status - label - minutesPastEnd - notificationSent additionalProperties: false description: One dose window as the sweep classified it against the OWNING account's wall clock, and whether this run dispatched for it. `notificationSent` is false for a window that is still open, for a day the schedule does not cover, and for a slot that already has an intake event today. AdminDiagnosticResponse: type: object properties: data: $ref: "#/components/schemas/AdminDiagnosticData" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false AdminDiagnosticData: type: object properties: devices: type: array items: $ref: "#/components/schemas/AdminDiagnosticDevice" notificationChannels: type: array items: $ref: "#/components/schemas/AdminDiagnosticChannel" recentPushAttempts: type: array items: $ref: "#/components/schemas/AdminDiagnosticPushAttempt" required: - devices - notificationChannels - recentPushAttempts additionalProperties: false description: Admin notification diagnostic snapshot for the calling user — what the dispatcher would see when targeting this account. Surfaces device tokens (masked), channel state, and recent push attempts so an operator can debug an iOS / Web Push / Telegram / ntfy issue without DB shell access. AdminDiagnosticDevice: type: object properties: id: type: string platform: type: string hasApnsToken: type: boolean apnsTokenPrefix: type: - string - "null" apnsTokenSuffix: type: - string - "null" apnsEnvironment: type: - string - "null" lastSeenAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - id - platform - hasApnsToken - apnsTokenPrefix - apnsTokenSuffix - apnsEnvironment - lastSeenAt additionalProperties: false description: Per-device APNs registration snapshot. The APNs token is masked to its 8-char hex prefix + 8-char suffix so an operator can correlate with iOS-side logs without disclosing the delivery target. AdminDiagnosticChannel: type: object properties: type: type: string enabled: type: boolean configPresent: type: boolean required: - type - enabled - configPresent additionalProperties: false description: Per-channel state. `configPresent` is true when the channel's encrypted-JSON config blob carries the field the corresponding sender will read (chatId+botToken for TELEGRAM, topic for NTFY); WEB_PUSH/APNS rely on sibling tables so the row's existence is the signal. AdminDiagnosticPushAttempt: type: object properties: eventType: type: string channel: type: string result: type: string reason: type: - string - "null" at: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - eventType - channel - result - reason - at additionalProperties: false description: Single push delivery attempt. Reserved for the v1.4.48+ follow-up that adds the dedicated PushAttempt table — currently always returned as an empty array so the response shape stays stable for iOS consumers. DashboardSnapshotResponseEnvelope: type: object properties: data: $ref: "#/components/schemas/DashboardSnapshotResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DashboardSnapshotResponse: type: object properties: user: type: object properties: username: type: string timezone: type: string heightCm: type: - number - "null" dateOfBirth: type: - string - "null" gender: anyOf: - type: string enum: - MALE - FEMALE - OTHER - type: "null" glucoseUnit: type: - string - "null" onboardingTourCompleted: type: boolean required: - username - timezone - heightCm - dateOfBirth - gender - glucoseUnit - onboardingTourCompleted additionalProperties: false layout: type: object propertyNames: type: string additionalProperties: {} layoutCatalogue: type: array items: type: object properties: id: type: string visible: type: boolean order: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - id - visible - order additionalProperties: false description: Full 27-id widget catalogue (server-known + iOS-only) with per-widget visibility + order. iOS-only ids are appended default-invisible. The web dashboard reads `layout`; this block is the cold-launch seed for the native client. metricStates: type: object propertyNames: type: string additionalProperties: type: object properties: value: type: number measuredAt: type: string unit: type: string required: - value - measuredAt - unit additionalProperties: false description: "Latest reading per chartable metric, keyed by the iOS `MetricKind` raw value (the non-obvious raws: `oxygenSaturation`, `totalBodyWater`, `heartRateVariability`, `bodyMassIndex`, `walkingAsymmetryPercentage`, `walkingDoubleSupportPercentage`, `environmentalAudioExposure`, `headphoneAudioExposure`, `activeEnergyBurned`). Each entry carries `value`, `measuredAt` (ISO8601), and the canonical `unit`. Types the user has never logged are omitted." targetBands: type: object properties: bpTargets: anyOf: - type: object properties: sysLow: type: number sysHigh: type: number diaLow: type: number diaHigh: type: number required: - sysLow - sysHigh - diaLow - diaHigh additionalProperties: false - type: "null" description: "Blood-pressure target numbers: the user's own target (`/api/user/thresholds`) where set, else the age-banded default. Null when there is neither a target nor a date of birth." bpSysRange: anyOf: - type: object properties: greenMin: type: number greenMax: type: number orangeMin: type: number orangeMax: type: number required: - greenMin - greenMax - orangeMin - orangeMax additionalProperties: false - type: "null" description: Systolic traffic-light range, the user's own target winning over the age default. Null when there is neither a target nor a date of birth. bpDiaRange: anyOf: - type: object properties: greenMin: type: number greenMax: type: number orangeMin: type: number orangeMax: type: number required: - greenMin - greenMax - orangeMin - orangeMax additionalProperties: false - type: "null" description: Diastolic traffic-light range, the user's own target winning over the age default. Null when there is neither a target nor a date of birth. pulseDisplayRange: type: object properties: greenMin: type: number greenMax: type: number orangeMin: type: number orangeMax: type: number required: - greenMin - greenMax - orangeMin - orangeMax additionalProperties: false description: "Resting-pulse display range: the user's own target where set, else the age / sex percentile band (the AHA reference range with no date of birth). Always present." pulseBands: type: array items: type: object properties: min: type: number max: type: number color: type: string opacity: type: number strokeOpacity: type: number required: - min - max - color additionalProperties: false description: Resting-pulse chart bands, drawn from the same range as `pulseDisplayRange`. Always present. bodyFatRange: type: object properties: min: type: number max: type: number required: - min - max additionalProperties: false description: "Body-fat target range: the user's own target where set, else the gender-aware default. Always present." bodyFatBands: type: array items: type: object properties: min: type: number max: type: number color: type: string opacity: type: number strokeOpacity: type: number required: - min - max - color additionalProperties: false description: Body-fat chart bands, drawn from the same range as `bodyFatRange`. Always present. weightRange: anyOf: - type: object properties: greenMin: type: number greenMax: type: number orangeMin: type: number orangeMax: type: number required: - greenMin - greenMax - orangeMin - orangeMax additionalProperties: false - type: "null" description: "Weight traffic-light range: the user's own target band when set, else the height-derived WHO band, else null (no height and no target)." weightBands: anyOf: - type: array items: type: object properties: min: type: number max: type: number color: type: string opacity: type: number strokeOpacity: type: number required: - min - max - color additionalProperties: false - type: "null" description: Weight chart bands, from the same range. Null when `weightRange` is null. required: - bpTargets - bpSysRange - bpDiaRange - pulseDisplayRange - pulseBands - bodyFatRange - bodyFatBands - weightRange - weightBands additionalProperties: false description: Server-computed band / target math resolved from the user's profile facts, so the client reads these numbers instead of recomputing them. Every sub-field is null only when the driving profile fact is missing (no date of birth for blood pressure, no height and no weight target for weight). tiles: type: object properties: directions: description: v1.43 — per measurement type, which way a change counts. `weightTrend` stays. type: object propertyNames: type: string additionalProperties: $ref: "#/components/schemas/TrendVerdict" summaries: type: object propertyNames: type: string additionalProperties: {} lastSeenByType: type: object propertyNames: type: string additionalProperties: {} mood: type: object properties: summary: anyOf: - type: object propertyNames: type: string additionalProperties: {} - type: "null" entries: type: array items: type: object properties: date: type: string score: type: number samples: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - date - score - samples additionalProperties: false required: - summary - entries additionalProperties: false sleepSourceDiscrepancy: description: Non-null when two writer buckets reported clearly different asleep totals for the latest night's main session (> 45 min apart and > 20% of the larger total). Observational only — the served summary stays the winning writer's totals; clients may show a discreet 'sources disagree' hint next to the sleep tile's headline. Null when the writers agree or the sleep module is off; optional for older cached snapshots. anyOf: - type: object properties: deltaMinutes: type: integer minimum: 0 maximum: 9007199254740991 sources: type: array items: type: object properties: source: type: string deviceType: type: - string - "null" asleepMinutes: type: integer minimum: 0 maximum: 9007199254740991 required: - source - deviceType - asleepMinutes additionalProperties: false required: - deltaMinutes - sources additionalProperties: false - type: "null" weightTrend: description: Which way a weight change counts as progress, judged against the person's own stored target rather than assuming a fall is good. Render the arrow, 7-day delta and comparison caption from `direction`; do not recompute it. Optional for older cached snapshots; when absent, treat it as `up-bad`. type: object properties: direction: type: string enum: - up-good - up-bad - hold description: "How the weight tile colours a change. `up-good`: a rise is progress (below the target band). `up-bad`: a fall is progress (above the band, and the reading when no target is stored). `hold`: the weight is inside the band, so a flat change is progress and a move either way is neutral, never a setback." targetPosition: anyOf: - type: string enum: - below - inside - above - type: "null" description: Where the 7-day average (else the latest reading) sits against the person's own stored weight target. Null when no target is stored or there is no reading. required: - direction - targetPosition additionalProperties: false required: - summaries - lastSeenByType - mood additionalProperties: false extras: anyOf: - type: object properties: bpInTargetPct: type: - number - "null" bpInTargetPct7d: type: - number - "null" bpInTargetPct30d: type: - number - "null" bpInTargetPctAllTime: type: - number - "null" bpInTargetPctPriorMonth: type: - number - "null" bpInTargetPctPriorYear: type: - number - "null" bpInTargetCount90: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" bpInTargetSpanDays90: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" glucoseByContext: type: object propertyNames: type: string additionalProperties: {} glucoseClinical: $ref: "#/components/schemas/GlucoseClinicalMetrics" required: - bpInTargetPct - bpInTargetPct7d - bpInTargetPct30d - bpInTargetPctAllTime - bpInTargetPctPriorMonth - bpInTargetPctPriorYear - bpInTargetCount90 - bpInTargetSpanDays90 - glucoseByContext - glucoseClinical additionalProperties: false - type: "null" medsToday: type: object properties: activeCount: type: integer minimum: -9007199254740991 maximum: 9007199254740991 scheduledToday: type: integer minimum: -9007199254740991 maximum: 9007199254740991 takenToday: type: integer minimum: -9007199254740991 maximum: 9007199254740991 skippedToday: type: integer minimum: -9007199254740991 maximum: 9007199254740991 nextDueAt: type: - string - "null" nextDueOverdue: type: boolean nextDueMedicationName: type: - string - "null" nextDueMedicationId: type: - string - "null" dueCandidates: type: array items: type: object properties: medicationId: type: string medicationName: type: string dueAt: type: string overdue: type: boolean availableFrom: type: string required: - medicationId - medicationName - dueAt - overdue - availableFrom additionalProperties: false required: - activeCount - scheduledToday - takenToday - skippedToday - nextDueAt - nextDueOverdue - nextDueMedicationName - nextDueMedicationId additionalProperties: false description: "Today's medication block: active-medication count, today-window tally (scheduled / taken / skipped), every active medication's current display-due candidate, and legacy scalar fields projected from candidate zero. `overdue: true` marks an OPEN overdue slot (anchor passed, still inside its catch-up band); `availableFrom` is the canonical cadence- and dose-window-derived attribution-band start. `dueCandidates` is optional so older cached snapshots remain valid. Medication ids let consumers deep-link straight to the relevant card." healthScore: anyOf: - type: object properties: score: type: integer minimum: 0 maximum: 100 band: type: string enum: - green - yellow - red delta: type: - number - "null" confidence: $ref: "#/components/schemas/DerivedConfidence" composition: type: array items: type: string enum: - BLOOD_PRESSURE - GLYCAEMIA - ACTIVITY - SLEEP - ADIPOSITY - WELLBEING - LIPIDS configured: description: "True when the account's own recipe narrows the score's composition below what its defaults would resolve to today. Server-resolved, so a client never interprets a configuration blob: an account that kept every pillar reads false, and so does one whose disabled modules alone narrow the set. The configuration itself is never on this wire. Optional so older cached snapshots without the field stay valid." type: boolean scoreBasis: description: What the number rests on. Since v1.38 a score is served from one or two areas of health as well as three, computed identically at each, so this is the only field that tells a narrow score from a broad one — render it beside the number. Optional so older cached snapshots without the field stay valid; the live builder always sets it. $ref: "#/components/schemas/HealthScoreBasis" deltaReason: anyOf: - type: string enum: - algorithm_changed - config_changed - composition_changed - first_eligibility_window - below_noise_floor - no_previous_window - no_current_score - type: "null" scoreVersion: type: integer exclusiveMinimum: 0 maximum: 9007199254740991 bandSetter: anyOf: - type: string enum: - BLOOD_PRESSURE - GLYCAEMIA - ACTIVITY - SLEEP - ADIPOSITY - WELLBEING - LIPIDS - type: "null" restMode: anyOf: - type: object properties: active: type: boolean since: type: - string - "null" episodeCount: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - active - since - episodeCount additionalProperties: false - type: "null" tension: description: "Locale-agnostic tension verdict: present only when the readiness composite's contributors disagree. `positive`/`negative` carry readiness contributor keys the client maps to localised labels. Null on a coherent day or when a clinical red-flag suppresses it. Optional so older cached snapshots without the field stay valid." anyOf: - type: object properties: band: type: string enum: - green - yellow - red positive: type: array items: type: string enum: - rhr - hrv - sleep - respiratory - mood negative: type: array items: type: string enum: - rhr - hrv - sleep - respiratory - mood required: - band - positive - negative additionalProperties: false - type: "null" returnToBand: description: "Locale-agnostic return-to-baseline event: present only when a salient metric (resting heart rate, HRV, respiratory rate, or weight) has come back inside the user's own personal range after a genuine prior out-of-band run. At most one, the most salient. `metricType` is the measurement type the client maps to a localised metric name; `daysInside` is how long it has now sat back inside range. Null otherwise. Optional so older cached snapshots without the field stay valid." anyOf: - type: object properties: metricType: $ref: "#/components/schemas/MeasurementType" daysInside: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - metricType - daysInside additionalProperties: false - type: "null" required: - score - band - delta additionalProperties: false - type: "null" description: "Derived reference score summary. Since v1.38 it is null only when no pillar is readable at all: one or two areas of health produce a score too, computed identically, with `scoreBasis` saying how broad it is — render that beside the number rather than assuming three areas. Additive metadata names confidence, ordered composition, the worst-pillar band setter, score version, and why a delta was suppressed. Rest Mode annotates but never changes the score." scoreRings: description: "User-selected hero score rings (max 3, `selectedScoreRings` on the dashboard layout), resolved server-side: READINESS / RECOVERY_SCORE / SLEEP_SCORE via the derived engines (module-gated like `/api/insights/derived`); MED_COMPLIANCE is TODAY's dose progress off the snapshot's medsToday tally — `score` is the rounded 0..100 progress, `doses` carries the taken/scheduled pair, the band is progress semantics (green once every scheduled dose is taken, yellow while doses remain, never red), and the ring is absent when no dose is scheduled today. Only rings with data appear, in selection order — a missing entry means no data or a disabled module, never zero. Clients render what arrives and never recompute." type: array items: type: object properties: id: type: string enum: - READINESS - RECOVERY_SCORE - SLEEP_SCORE - MED_COMPLIANCE score: type: integer minimum: -9007199254740991 maximum: 9007199254740991 band: type: string enum: - green - yellow - red doses: description: MED_COMPLIANCE only — today's dose tally behind the progress score, for a 'taken/scheduled' ring display (e.g. 1/3). Absent on the derived score rings. type: object properties: taken: type: integer minimum: -9007199254740991 maximum: 9007199254740991 scheduled: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - taken - scheduled additionalProperties: false required: - id - score - band additionalProperties: false briefing: anyOf: - type: object propertyNames: type: string additionalProperties: {} - type: "null" briefingMemory: description: "The briefing recall + forward-look: `recall` is the prior period's narrative headline, `forward` points ahead to the most salient trend drift (or a calm 'holding steady' line). Both are already-localised prose the client renders verbatim. Null when no prior narrative is on file or no briefing is shown. Optional so older cached snapshots without the field stay valid." anyOf: - type: object properties: recall: type: string forward: type: string required: - recall - forward additionalProperties: false - type: "null" briefingState: type: string enum: - ready - preparing - disabled - no-provider description: "`disabled` whenever the `briefing` AI capability is unavailable for a reason other than a missing provider (then `briefingAi` names it); `no-provider` when no provider can serve it." briefingUpdatedAt: type: - string - "null" briefingStale: type: boolean description: True when `briefing` carries the last good (expired-TTL) briefing while a refresh is pending (`preparing`). Render the stale content with its `briefingUpdatedAt` timestamp instead of a blank tile. briefingAi: description: "The `briefing` AI capability for this record, applied on every read after the cache: while it is unavailable `briefing`, `briefingMemory` and `briefingUpdatedAt` are null, whatever the cache holds. Nullable and optional so older cached bodies stay valid." anyOf: - $ref: "#/components/schemas/AiCapabilityState" - type: "null" generatedAt: type: string required: - user - layout - layoutCatalogue - metricStates - targetBands - tiles - extras - medsToday - healthScore - briefing - briefingState - briefingUpdatedAt - briefingStale - generatedAt additionalProperties: false description: "Unified above-the-fold dashboard payload. `tiles` always arrives (slim summaries + mood + resolved widget layout); `extras` (BD-in-target + per-context glucose) is null on a rollup-coverage miss so the strip never waits on the slowest read. `briefing` is lifted read-only from the pre-generated insight cache — never generated synchronously — and reports `ready` / `preparing` / `disabled` / `no-provider` via `briefingState` (`no-provider` = stale-or-missing cache with no AI provider configured anywhere, so no warm pass will fill it; stop polling and surface a connect-provider hint). A stale-but-parseable briefing is still delivered with `briefingStale: true`. `layoutCatalogue` (full 27-id widget catalogue) and `metricStates` (latest reading per metric, keyed by iOS `MetricKind` raw value) are additive cold-launch seeds for the native client; both derive in-process from data already fetched, adding no DB round-trip." GlucoseClinicalMetrics: type: object properties: stillLearning: type: boolean stillLearningReason: type: - string - "null" windowDays: type: integer minimum: -9007199254740991 maximum: 9007199254740991 actualSpanDays: type: number readingCount: type: integer minimum: -9007199254740991 maximum: 9007199254740991 meanMgdl: type: - number - "null" distribution: anyOf: - type: object properties: tir: type: number tbrLevel1: type: number tbrLevel2: type: number tarLevel1: type: number tarLevel2: type: number minutesEquivalent: type: object properties: tir: type: number tbrLevel1: type: number tbrLevel2: type: number tarLevel1: type: number tarLevel2: type: number required: - tir - tbrLevel1 - tbrLevel2 - tarLevel1 - tarLevel2 additionalProperties: false required: - tir - tbrLevel1 - tbrLevel2 - tarLevel1 - tarLevel2 - minutesEquivalent additionalProperties: false - type: "null" gmi: type: - number - "null" estimatedA1c: type: - number - "null" variability: anyOf: - type: object properties: sd: type: number cv: type: number unstable: type: boolean required: - sd - cv - unstable additionalProperties: false - type: "null" advanced: anyOf: - type: object properties: jIndex: type: - number - "null" lbgi: type: number hbgi: type: number required: - jIndex - lbgi - hbgi additionalProperties: false - type: "null" isSpotEstimate: type: boolean required: - stillLearning - stillLearningReason - windowDays - actualSpanDays - readingCount - meanMgdl - distribution - gmi - estimatedA1c - variability - advanced - isSpotEstimate additionalProperties: false description: Server-authoritative glucose clinical panel over the trailing 30-day window. Figures from sparse spot data are a SPOT-READING ESTIMATE (a % of readings), not a CGM time-in-range AGP; `isSpotEstimate` is derived from reading density (true below ~hourly, false for a continuous CGM stream such as Nightscout) and `stillLearning` gates assertion when the window has too few readings or too short a span. `distribution` carries the Battelino 2019 TIR/TBR/TAR fractions (level-2 nested in level-1) plus minutes-of-a-day equivalents; `gmi` (Bergenstal 2018) + `estimatedA1c` (Nathan 2008 ADAG) derive from the mean; `variability` is SD + CV% with the Monnier 2017 ≥36% instability flag; `advanced` is the disclosure tier — J-index (Wojcicki 1995) + LBGI/HBGI (Kovatchev hypo/hyper risk). All blocks are null when there are no readings; `advanced.jIndex` is null for a single-reading window. DerivedConfidence: type: object properties: score: type: number description: 0..100 confidence; feeds the shared coverage meter unchanged. band: type: string enum: - high - medium - low - draft description: Confidence band the meter renders. required: - score - band additionalProperties: false HealthScoreBasis: type: object properties: domains: type: integer minimum: 1 maximum: 9007199254740991 description: Distinct areas of health counted. NOT the length of `composition` — three of the seven pillars share the cardiometabolic area, so a three-pillar score can rest on one area. recommended: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: "How many distinct areas the method recommends. A recommendation, not a floor: a score below it is computed and served, it is just narrower." tier: type: string enum: - full - partial - minimal description: "`full` = at or above the recommended breadth, `partial` = two areas, `minimal` = one. The arithmetic is identical at every tier — the same mean, the same worst-pillar band drag, the same noise floor — so this is the only thing that distinguishes a narrow score from a broad one on the wire. Render it beside the number." physiological: type: boolean description: "Whether any counted pillar rests on a physiological measurement (blood pressure, glucose, sleep, body shape, cholesterol). False means the score is built from activity and/or wellbeing alone. A statement of scope, not a warning: the score still exists and is still honest about what produced it." required: - domains - recommended - tier - physiological additionalProperties: false description: What the Health Score rests on, resolved server-side. Present on every score computed from at least one usable pillar; optional on every shape that carries it, so payloads cached before v1.38 stay valid. InsightsComprehensiveResponseEnvelope: type: object properties: data: $ref: "#/components/schemas/InsightsComprehensiveResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false InsightsComprehensiveResponse: type: object properties: summaries: type: object propertyNames: type: string additionalProperties: $ref: "#/components/schemas/ComprehensiveMetricSummary" description: Keyed by `MeasurementType`. Only types with readings in the last 90 days are present. bmi: type: - number - "null" bmiClassification: anyOf: - type: object properties: category: type: string color: type: string description: A CSS colour token for the web client. severity: type: string enum: - normal - elevated - warning - danger required: - category - color - severity additionalProperties: false - type: "null" bpClassification: anyOf: - type: object properties: category: type: string color: type: string description: A CSS colour token for the web client. severity: type: string enum: - normal - elevated - warning - danger required: - category - color - severity additionalProperties: false - type: "null" bpPctInTarget: description: Share of blood-pressure readings inside the target band, 0-100. type: - number - "null" bpTargets: anyOf: - type: object properties: sysLow: type: number sysHigh: type: number diaLow: type: number diaHigh: type: number required: - sysLow - sysHigh - diaLow - diaHigh additionalProperties: false - type: "null" description: Age-banded targets; null without a date of birth. weightBpCorrelation: anyOf: - $ref: "#/components/schemas/CorrelationStat" - type: "null" scatterData: type: array items: type: object properties: weight: type: number sysBP: type: number required: - weight - sysBP additionalProperties: false description: Weight against systolic pressure, paired by day. bpMedicationCorrelation: anyOf: - type: object properties: r: type: number strength: type: string enum: - stark - moderat - schwach - keine description: Strength bucket. The tokens are GERMAN on the wire; treat them as opaque identifiers and localise client-side. n: type: integer minimum: -9007199254740991 maximum: 9007199254740991 medicationCount: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - r - strength - n - medicationCount additionalProperties: false - type: "null" bpMedicationScatterData: type: array items: type: object properties: continuityPct: type: number sysBP: type: number required: - continuityPct - sysBP additionalProperties: false moodSummary: anyOf: - $ref: "#/components/schemas/ComprehensiveMetricSummary" - type: "null" moodBpCorrelation: anyOf: - $ref: "#/components/schemas/CorrelationStat" - type: "null" moodBpScatterData: type: array items: type: object properties: mood: type: number sysBP: type: number required: - mood - sysBP additionalProperties: false moodWeightCorrelation: anyOf: - $ref: "#/components/schemas/CorrelationStat" - type: "null" moodWeightScatterData: type: array items: type: object properties: mood: type: number weight: type: number required: - mood - weight additionalProperties: false moodPulseCorrelation: anyOf: - $ref: "#/components/schemas/CorrelationStat" - type: "null" moodPulseScatterData: type: array items: type: object properties: mood: type: number pulse: type: number required: - mood - pulse additionalProperties: false medications: type: array items: type: object properties: id: type: string name: type: string dose: type: string category: type: string categoryLabel: description: The label of a custom category; null for a built-in one. type: - string - "null" compliance7: type: number description: Percent, 0-100. compliance30: type: number description: Percent, 0-100. streak: type: integer minimum: -9007199254740991 maximum: 9007199254740991 taken7: type: integer minimum: -9007199254740991 maximum: 9007199254740991 skipped7: type: integer minimum: -9007199254740991 maximum: 9007199254740991 missed7: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - id - name - dose - category - categoryLabel - compliance7 - compliance30 - streak - taken7 - skipped7 - missed7 additionalProperties: false description: Cadence-aware compliance per active medication. alerts: type: array items: type: object properties: level: type: string enum: - success - info - warning - danger title: type: string message: type: string required: - level - title - message additionalProperties: false hasProvider: type: boolean description: "Whether a configured AI provider exists for the record: provider presence and nothing else. False never means the operator switched AI off or consent is missing; `ai` says that." dataSpanDays: type: integer minimum: 0 maximum: 9007199254740991 totalMeasurements: type: integer minimum: 0 maximum: 9007199254740991 revalidating: type: boolean description: True when the body is served from last-good cache (stale-while-revalidate) while a fresh aggregation runs in the background. The client keeps polling on `revalidating` (bounded) so the open page converges on the fresh body. ai: type: object properties: briefing: $ref: "#/components/schemas/AiCapabilityState" statusText: $ref: "#/components/schemas/AiCapabilityState" required: - briefing - statusText additionalProperties: false description: "Whether the AI parts that decorate the overview can be shown: the daily briefing and the per-metric status notes. Resolved per request, never cached with the body. The overview itself is computed data and loads whatever these say." required: - summaries - bmi - bmiClassification - bpClassification - bpPctInTarget - bpTargets - weightBpCorrelation - scatterData - bpMedicationCorrelation - bpMedicationScatterData - moodSummary - moodBpCorrelation - moodBpScatterData - moodWeightCorrelation - moodWeightScatterData - moodPulseCorrelation - moodPulseScatterData - medications - alerts - hasProvider - dataSpanDays - totalMeasurements - revalidating - ai additionalProperties: false description: The Insights overview's computed read. No provider is called on this path; every figure is computed from the record. The model-written briefing is not part of it; it is read from `GET /api/insights/generate`. ComprehensiveMetricSummary: type: object properties: count: type: integer minimum: -9007199254740991 maximum: 9007199254740991 latest: type: - number - "null" min: type: - number - "null" max: type: - number - "null" mean: type: - number - "null" median: type: - number - "null" avg7: type: - number - "null" avg30: type: - number - "null" slope7: anyOf: - type: object properties: slope: type: number description: Least-squares slope in units per day. direction: type: string enum: - up - down - stable confidence: type: number description: R² of the fit, 0..1. required: - slope - direction - confidence additionalProperties: false - type: "null" slope30: anyOf: - type: object properties: slope: type: number description: Least-squares slope in units per day. direction: type: string enum: - up - down - stable confidence: type: number description: R² of the fit, 0..1. required: - slope - direction - confidence additionalProperties: false - type: "null" anomalyCount: type: integer minimum: 0 maximum: 9007199254740991 description: Readings in the series the outlier rule flags. avg30LastMonth: type: - number - "null" avg30LastYear: type: - number - "null" required: - count - latest - min - max - mean - median - avg7 - avg30 - slope7 - slope30 - anomalyCount additionalProperties: false CorrelationStat: type: object properties: r: type: number strength: type: string enum: - stark - moderat - schwach - keine description: Strength bucket. The tokens are GERMAN on the wire; treat them as opaque identifiers and localise client-side. n: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - r - strength - n additionalProperties: false description: A significant Pearson correlation (n >= 20, p < 0.05). The field holding it is null when the pairing is not significant. InsightsCardsEnvelope: type: object properties: data: $ref: "#/components/schemas/InsightsCardsResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false InsightsCardsResponse: type: array items: $ref: "#/components/schemas/InsightCard" InsightCard: type: object properties: id: type: string description: Stable per-card id (e.g. `alert-1`). title: type: string summary: type: string description: One-line alert message. body: description: Longer narrative; null on the current rule-engine cards. type: - string - "null" severity: type: string enum: - alert - caution - info - good description: Mapped from the underlying alert level (danger→alert, warning→caution, success→good, else info). recommendations: type: array items: type: object properties: id: type: string label: type: string actionURL: type: - string - "null" required: - id - label - actionURL additionalProperties: false description: Suggested follow-ups; empty on the current rule-engine cards. generatedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ provider: type: string description: "Who wrote the card: always `rules`, the threshold engine. It used to echo the account's AI provider although no model wrote any card." required: - id - title - summary - body - severity - recommendations - generatedAt - provider additionalProperties: false description: One iOS insight card, re-shaped from a server-side HealthAlert. Deterministic rule-engine output — no LLM call on this path. InsightsPregenerateResponseEnvelope: type: object properties: data: $ref: "#/components/schemas/InsightsPregenerateResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false InsightsPregenerateResponse: type: object properties: queued: type: boolean description: True when the warm was accepted and enqueued; false when neither AI capability it covers is available, in which case nothing was enqueued. locale: type: string description: The locale the assessments are being warmed in. ai: type: object properties: briefing: $ref: "#/components/schemas/AiCapabilityState" statusText: $ref: "#/components/schemas/AiCapabilityState" required: - briefing - statusText additionalProperties: false description: The two capabilities the warm covers. The worker skips the half whose capability is unavailable. required: - queued - locale - ai additionalProperties: false description: Acknowledgement of a full assessment warm for the calling user, or of why none was enqueued. The generation runs out of band; the text lands in the read-only status routes. AnalyticsEnvelope: type: object properties: data: anyOf: - $ref: "#/components/schemas/AnalyticsDefaultResponse" - $ref: "#/components/schemas/AnalyticsSummariesSlice" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false AnalyticsDefaultResponse: type: object properties: summaries: type: object propertyNames: type: string additionalProperties: $ref: "#/components/schemas/MetricDataSummary" description: Keyed by `MeasurementType`. Only types the account has data for are present. bmi: description: Derived from `summaries.WEIGHT.latest` and the profile height; null when either is missing. type: - number - "null" bpInTargetPct: description: Trailing 90 days — the headline the BP tile shows. type: - number - "null" bpInTargetPct7d: type: - number - "null" bpInTargetPct30d: type: - number - "null" bpInTargetPctAllTime: type: - number - "null" bpInTargetPctPriorMonth: type: - number - "null" bpInTargetPctPriorYear: type: - number - "null" bpInTargetCount90: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" description: Readings behind the 90-day figure, for the thin-data gate. bpInTargetSpanDays90: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" description: Effective span the 90-day window actually covers. glucoseByContext: anyOf: - type: object propertyNames: type: string additionalProperties: $ref: "#/components/schemas/MetricDataSummary" - type: "null" description: Per-context glucose summaries (FASTING / POSTPRANDIAL / RANDOM / BEDTIME, plus UNSPECIFIED for readings whose source recorded no meal-time context) over the trailing 30 days, canonical mg/dL. Buckets with no readings are omitted; the whole block is null when the glucose module is off. glucoseClinical: anyOf: - $ref: "#/components/schemas/GlucoseClinicalMetrics" - type: "null" description: Null when the glucose module is off for this account. correlations: $ref: "#/components/schemas/AnalyticsCorrelations" healthScore: $ref: "#/components/schemas/HealthScoreReport" sleepStages: anyOf: - type: object properties: windowDays: type: integer minimum: -9007199254740991 maximum: 9007199254740991 nights: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Nights inside the window carrying at least one stage entry. totalMinutes: type: number description: Sum of every value in `stages`. Nights only, naps excluded. stages: type: object propertyNames: type: string additionalProperties: type: number description: Window totals per stage, keyed by the `SleepStage` enum. `IN_BED` is the session envelope and is carried alongside the asleep stages. perNight: type: array items: type: object properties: dayKey: type: string description: Wake-day key (YYYY-MM-DD) in the reader's timezone. stages: type: object propertyNames: type: string additionalProperties: type: number napMinutes: description: Time asleep across this day's naps. ABSENT on a day without one — there is no zero to render. type: number napCount: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - dayKey - stages additionalProperties: false required: - windowDays - nights - totalMinutes - stages - perNight additionalProperties: false - type: "null" description: Trailing-30-day per-stage breakdown, or null when no session in the window carries stage data. Sessions are reconstructed and collapsed to one canonical source per night first, so a night written by two devices is counted once and a midnight-spanning night lands on its wake day. sleepSourceDiscrepancy: anyOf: - type: object properties: deltaMinutes: type: integer minimum: 0 maximum: 9007199254740991 sources: type: array items: type: object properties: source: type: string deviceType: type: - string - "null" asleepMinutes: type: integer minimum: 0 maximum: 9007199254740991 required: - source - deviceType - asleepMinutes additionalProperties: false required: - deltaMinutes - sources additionalProperties: false - type: "null" description: Non-null when two writer buckets reported clearly different asleep totals for the latest night's main session. Observational only — the served summary stays the winning writer's totals. lastSeenByType: type: object propertyNames: type: string additionalProperties: anyOf: - type: object properties: lastSeenAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ daysAgo: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Whole days since `lastSeenAt`. Re-derived per request rather than served from the cache, so it stays right across a day boundary the cached body straddles. required: - lastSeenAt - daysAgo additionalProperties: false - type: "null" description: Per-type freshness, driving the 'last value N days ago' caption. A type the account has never logged reads `null`. required: - summaries - bmi - bpInTargetPct - bpInTargetPct7d - bpInTargetPct30d - bpInTargetPctAllTime - bpInTargetPctPriorMonth - bpInTargetPctPriorYear - bpInTargetCount90 - bpInTargetSpanDays90 - glucoseByContext - glucoseClinical - correlations - healthScore - sleepStages - sleepSourceDiscrepancy - lastSeenByType additionalProperties: false description: The thick dashboard body. The BP figures are computed against the CLINICAL band even when the account keeps a personal target — the personal one is displayed beside the number and scores nothing. MetricDataSummary: type: object properties: count: type: integer minimum: -9007199254740991 maximum: 9007199254740991 latest: type: - number - "null" min: type: - number - "null" max: type: - number - "null" mean: type: - number - "null" median: description: 50th percentile over the trailing 90 days on this route (the SQL summaries path fixes that window). Linear-interpolated midpoint. type: - number - "null" avg7: type: - number - "null" avg30: type: - number - "null" slope7: anyOf: - $ref: "#/components/schemas/TrendSlope" - type: "null" slope30: anyOf: - $ref: "#/components/schemas/TrendSlope" - type: "null" avg30LastMonth: description: Mean over the 30-day window that ended 30 days ago — the prior period the tile's delta callout compares against. type: - number - "null" avg30LastYear: description: Mean over the 30-day window that ended 365 days ago. Populated only for types whose WEEK/MONTH/YEAR rollup tier reaches that far back; null otherwise, which is not the same as 'the year-ago window was empty'. type: - number - "null" required: - count - latest - min - max - mean - median - avg7 - avg30 - slope7 - slope30 additionalProperties: false description: "Per-measurement-type summary. Every statistic is null on an empty series rather than zero — a zero here would render as a real reading. `anomalyCount` and `slope90` left this wire in v1.37.19: the SQL path could never compute the first and nobody read the second." TrendSlope: type: object properties: slope: type: number description: Least-squares slope, in units per day. direction: type: string enum: - up - down - stable description: "`stable` when |slope| < 0.01, not when the slope is zero." confidence: type: number description: R² of the fit, 0–1. Reported as 0 when it cannot be formed. required: - slope - direction - confidence additionalProperties: false description: "A linear trend over a trailing window. The window is anchored on NOW, not on the newest reading, so a series that stopped weeks ago reports `null` for the slope instead of describing a trend that has not been observed since. It is also `null` when the window's readings fall on fewer than 3 calendar days (UTC) or when their first and last day lie less than half the window apart (4 days for the 7-day window, 15 for the 30-day one): readings minutes apart cannot carry a slope per day." AnalyticsCorrelations: type: object properties: bpCompliance: $ref: "#/components/schemas/FixedCorrelationResult" moodPulse: $ref: "#/components/schemas/FixedCorrelationResult" weightWeekday: $ref: "#/components/schemas/FixedCorrelationResult" degraded: type: boolean description: Reserved for a future load-shedding branch and pinned to `false` by both current branches. Read it, do not assume it will ever be true today. windowDays: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Days the runner actually scanned. 28 on both branches. path: type: string enum: - rollup - live description: "`rollup` when the SYS / PULSE / WEIGHT series came off `measurement_rollups`. Mood and medication-intake reads are always live — there is no rollup equivalent for them." required: - bpCompliance - moodPulse - weightWeekday - degraded - windowDays - path additionalProperties: false description: The three pre-defined correlation hypotheses over a 28-day window. Accepted results are also synced into the pattern ledger on this read, which is where `patternId` / `dismissed` come from. FixedCorrelationResult: anyOf: - type: object properties: kind: type: string enum: - bp-compliance - mood-pulse - weight-weekday status: type: string const: ok statistic: type: number description: Pearson r for the two paired hypotheses; eta-squared for the weekday ANOVA. n: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Paired rows behind the statistic. pValue: type: number confidenceBand: type: object properties: low: type: number high: type: number label: type: string enum: - low - moderate - high required: - low - high - label additionalProperties: false interpretation: type: string description: One conservative sentence, localised to the reader. Never claims causation. This is why the response cache is keyed per locale. points: type: array items: type: object properties: x: type: number y: type: number required: - x - y additionalProperties: false xLabel: type: string yLabel: type: string patternId: description: Present once the pattern ledger has a row for this pair — the handle `PATCH /api/insights/correlations/patterns/{id}` takes. type: string canonicalKey: type: string dismissed: description: True when the reader has dismissed this pattern. type: boolean required: - kind - status - statistic - n - pValue - confidenceBand - interpretation - points - xLabel - yLabel additionalProperties: false - type: object properties: kind: type: string enum: - bp-compliance - mood-pulse - weight-weekday status: type: string const: insufficient n: type: integer minimum: -9007199254740991 maximum: 9007199254740991 reason: type: string enum: - too_few_pairs - not_significant - no_variance points: type: array items: type: object properties: x: type: number y: type: number required: - x - y additionalProperties: false description: What was collected anyway, so the empty state can hint at progress. required: - kind - status - n - reason - points additionalProperties: false description: "One of the three fixed hypotheses. The `insufficient` arm is the normal case for a young account: the surface gate wants at least 20 paired rows and p < 0.05, and it says which of the two it missed." HealthScoreReport: type: object properties: composite: $ref: "#/components/schemas/DerivedHealthScoreComposite" pillars: type: array items: type: object properties: id: type: string enum: - BLOOD_PRESSURE - GLYCAEMIA - ACTIVITY - SLEEP - ADIPOSITY - WELLBEING - LIPIDS domain: type: string enum: - cardiometabolic - activity - sleep - adiposity - wellbeing result: $ref: "#/components/schemas/DerivedScorePillarValue" required: - id - domain - result additionalProperties: false delta: description: Move against the previous window, or null when none forms. type: - number - "null" deltaReason: anyOf: - type: string enum: - algorithm_changed - config_changed - composition_changed - first_eligibility_window - below_noise_floor - no_previous_window - no_current_score - type: "null" description: Why there is no delta, or why the one there is should not be narrated as a health event. `config_changed` is the reader changing their own recipe mid-window — the arithmetic looks comparable and is not. scoreVersion: type: integer minimum: -9007199254740991 maximum: 9007199254740991 weightGoal: $ref: "#/components/schemas/DerivedWeightGoal" algorithmNotice: anyOf: - type: object properties: itemKey: type: string dismissed: type: boolean required: - itemKey - dismissed additionalProperties: false - type: "null" compositionNotice: description: "A pillar joined or left the set behind the number since the account's last stored local day. Raised only for a change nobody chose: a method move or a recipe change is the algorithm notice's subject instead, and raising both would announce one event twice. Optional so a payload cached before the field existed stays valid; null when there is nothing to say. Dismissed through `POST /api/daily/digest/dismiss` with this `itemKey`, which is keyed on the resulting SET so it re-raises when the set moves again rather than every day." anyOf: - type: object properties: itemKey: type: string left: type: array items: type: string enum: - BLOOD_PRESSURE - GLYCAEMIA - ACTIVITY - SLEEP - ADIPOSITY - WELLBEING - LIPIDS joined: type: array items: type: string enum: - BLOOD_PRESSURE - GLYCAEMIA - ACTIVITY - SLEEP - ADIPOSITY - WELLBEING - LIPIDS dismissed: type: boolean required: - itemKey - left - joined - dismissed additionalProperties: false - type: "null" restMode: description: Attached when an illness episode was active as of the scored instant — the instant the score was computed for, not a fresh now. The score is NOT penalised; this frames it. Null when no episode was open. On this route the key is always present; it is optional in the shape because other producers of the report omit it. anyOf: - type: object properties: active: type: boolean const: true since: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" episodeCount: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - active - since - episodeCount additionalProperties: false - type: "null" required: - composite - pillars - delta - deltaReason - scoreVersion - weightGoal - algorithmNotice additionalProperties: false description: The full Health Score report as the scorer produced it. Note this is the raw report, not the flattened hero block the dashboard snapshot serves — a client that reads both should not expect the same field names. DerivedHealthScoreComposite: anyOf: - type: object properties: status: type: string const: ok value: type: object properties: score: type: number band: type: string enum: - green - yellow - red bandSetter: anyOf: - type: string enum: - BLOOD_PRESSURE - GLYCAEMIA - ACTIVITY - SLEEP - ADIPOSITY - WELLBEING - LIPIDS - type: "null" description: The pillar that pulled the band down, non-null only when the worst pillar's band is below the mean score's. composition: type: array items: type: string enum: - BLOOD_PRESSURE - GLYCAEMIA - ACTIVITY - SLEEP - ADIPOSITY - WELLBEING - LIPIDS description: "Registry-ordered eligible pillars. Part of the number's identity: two scores built from different compositions are not comparable." configured: type: boolean description: True when the account's own recipe narrows the composition below what its defaults would resolve to today. The configuration itself never rides this wire. scoreBasis: description: What the number rests on. Since v1.38 a score is produced from one or two areas of health as well as three, with identical arithmetic at each, so this block is the only thing on the wire that distinguishes them. Optional so a composite cached before v1.38 stays valid; the live scorer always sets it. $ref: "#/components/schemas/HealthScoreBasis" noiseFloor: type: number scoreVersion: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - score - band - bandSetter - composition - configured - noiseFloor - scoreVersion additionalProperties: false coverage: $ref: "#/components/schemas/DerivedCoverage" confidence: $ref: "#/components/schemas/DerivedConfidence" provenance: $ref: "#/components/schemas/DerivedProvenance" required: - status - value - coverage - confidence - provenance additionalProperties: false - type: object properties: status: type: string const: insufficient coverage: $ref: "#/components/schemas/DerivedCoverage" provenance: $ref: "#/components/schemas/DerivedProvenance" reason: type: string required: - status - coverage - provenance - reason additionalProperties: false DerivedCoverage: type: object properties: requiredInputs: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Inputs the metric wants (its full input set). presentInputs: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Inputs actually present in the user's data. historyDays: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Distinct days of history backing the value (the gating floor). missing: type: array items: type: string description: Named inputs still missing — drives the 'track N more' nudge. required: - requiredInputs - presentInputs - historyDays - missing additionalProperties: false DerivedProvenance: type: object properties: inputs: type: array items: type: string description: Named inputs that actually backed the value. source: type: string enum: - DAY - WEEK - MONTH - YEAR - live - none description: Granularity the dominant read resolved against. 'live' = a coverage-miss live-SQL fallback; 'none' = no data backed the value. windowDays: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Trailing window the value summarises, in days. computedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ description: Compute time (for cache-staleness + the 'as of' chip). required: - inputs - source - windowDays - computedAt additionalProperties: false DerivedScorePillarValue: anyOf: - type: object properties: status: type: string const: ok value: type: object properties: score: type: number observed: type: object properties: value: type: number description: The scored value in its canonical unit (fasting glucose in mg/dL). unit: type: string description: The canonical unit of `value`. label: type: string description: The complete display value, paired or panel values included, written in the reader's units (fasting glucose in the account's glucose unit). asOf: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ sources: type: array items: type: string required: - value - unit - label - asOf - sources additionalProperties: false reference: type: object properties: kind: type: string enum: - clinical-threshold - population-percentile - guideline-band low: description: Lower band edge, canonical unit. type: - number - "null" high: description: Upper band edge, canonical unit. type: - number - "null" label: type: string description: The band as display text, in the reader's units. source: type: string required: - kind - low - high - label - source additionalProperties: false personalReference: description: The reader's own yardstick, shown beside the scored reference. It is displayed, never scored — the pillar always grades against the clinical band. type: object properties: kind: type: string enum: - clinical-threshold - population-percentile - guideline-band low: type: - number - "null" high: type: - number - "null" label: type: string source: type: string required: - kind - low - high - label - source additionalProperties: false scoreBasis: description: Blood pressure only, and only because that pillar takes the WORSE of two axes — the number alone cannot say which one produced it. A pillar with nothing of the kind to say omits the key. type: object properties: axis: type: string enum: - systolic - diastolic relation: type: string enum: - above_ceiling - below_floor - in_band offsetMmHg: type: number boundaryMmHg: type: number required: - axis - relation - offsetMmHg - boundaryMmHg additionalProperties: false noiseFloor: type: number description: Score points a weekly move must clear before it is narrated. deltaEligible: type: boolean description: "False for slow markers: they set the level but never contribute to the weekly delta." deltaIdentity: type: string description: Input/scoring-mode identity. Two windows only form a delta when their identities match. required: - score - observed - reference - noiseFloor - deltaEligible - deltaIdentity additionalProperties: false coverage: $ref: "#/components/schemas/DerivedCoverage" confidence: $ref: "#/components/schemas/DerivedConfidence" provenance: $ref: "#/components/schemas/DerivedProvenance" required: - status - value - coverage - confidence - provenance additionalProperties: false - type: object properties: status: type: string const: insufficient coverage: $ref: "#/components/schemas/DerivedCoverage" provenance: $ref: "#/components/schemas/DerivedProvenance" reason: type: string required: - status - coverage - provenance - reason additionalProperties: false DerivedWeightGoal: anyOf: - type: object properties: status: type: string const: ok value: type: object properties: currentKg: type: number target: type: object properties: min: type: number max: type: number required: - min - max additionalProperties: false distanceKg: type: number deltaKg: description: Positive means the distance to the personal band narrowed. type: - number - "null" asOf: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ source: type: string required: - currentKg - target - distanceKg - deltaKg - asOf - source additionalProperties: false coverage: $ref: "#/components/schemas/DerivedCoverage" confidence: $ref: "#/components/schemas/DerivedConfidence" provenance: $ref: "#/components/schemas/DerivedProvenance" required: - status - value - coverage - confidence - provenance additionalProperties: false - type: object properties: status: type: string const: insufficient coverage: $ref: "#/components/schemas/DerivedCoverage" provenance: $ref: "#/components/schemas/DerivedProvenance" reason: type: string required: - status - coverage - provenance - reason additionalProperties: false AnalyticsSummariesSlice: type: object properties: summaries: type: object propertyNames: type: string additionalProperties: $ref: "#/components/schemas/MetricDataSummary" description: Keyed by `MeasurementType`. Only types the account has data for are present. bmi: type: "null" description: Always null on this slice — BMI needs the profile height the slim path does not load. Re-derive it client-side from `summaries.WEIGHT.latest`, or read the default slice. lastSeenByType: type: object propertyNames: type: string additionalProperties: anyOf: - type: object properties: lastSeenAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ daysAgo: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Whole days since `lastSeenAt`. Re-derived per request rather than served from the cache, so it stays right across a day boundary the cached body straddles. required: - lastSeenAt - daysAgo additionalProperties: false - type: "null" description: Per-type freshness, driving the 'last value N days ago' caption. A type the account has never logged reads `null`. sleepSourceDiscrepancy: anyOf: - type: object properties: deltaMinutes: type: integer minimum: 0 maximum: 9007199254740991 sources: type: array items: type: object properties: source: type: string deviceType: type: - string - "null" asleepMinutes: type: integer minimum: 0 maximum: 9007199254740991 required: - source - deviceType - asleepMinutes additionalProperties: false required: - deltaMinutes - sources additionalProperties: false - type: "null" description: Non-null when two writer buckets reported clearly different asleep totals for the latest night's main session. Observational only — the served summary stays the winning writer's totals. recoveryEmpty: anyOf: - type: string enum: - general - oura_permission - oura_no_resilience - type: "null" description: "Why the recovery page has nothing to chart, resolved on the server; null when any recovery type (ANS_CHARGE, DAY_STRAIN, WORKOUT_STRAIN, CARDIO_LOAD, AVERAGE_HEART_RATE, MAX_HEART_RATE, ENERGY_EXPENDITURE_KJ, RESILIENCE) has a reading. `oura_permission`: Oura is connected and its grant leaves out the scope the resilience level needs, one reconnect fixes it. `oura_no_resilience`: Oura is connected and no level has arrived yet. `general`: anyone else. Clients render the empty note from this value and never work it out from the connection status." required: - summaries - bmi - lastSeenByType - sleepSourceDiscrepancy - recoveryEmpty additionalProperties: false description: "What `?slice=summaries` answers: the per-type summaries and their freshness, the recovery page's empty reason, and nothing thick. Five keys, and the caller gets them from two SQL passes rather than the default slice's chain." InsightsGenerateReadEnvelope: type: object properties: data: $ref: "#/components/schemas/InsightsGenerateReadResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false InsightsGenerateReadResponse: type: object properties: insights: anyOf: - type: object propertyNames: type: string additionalProperties: {} - type: "null" description: "The cached insight payload from the account's cache row, with its `dailyBriefing` resolved for today: null when the text was generated on an earlier calendar day in the account's timezone (or carries no `briefingGeneratedAt`, the moment it was generated), otherwise without the signals of the day whose reading-backed metric has no reading today, and with each signal delta at its metric's display precision in the reader's number format. Null when nothing is cached or the stored row failed to parse — the two are indistinguishable here, and the second also silently enqueues a repair." cached: type: boolean description: True when a payload was served from the cache. cachedAt: description: When that payload was written. Present only on the cached branch — the empty branch omits the key rather than sending null. anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" legacyPayload: type: boolean description: "True when the cached payload predates the rationale fields, so the client can offer a regenerate. Never auto-regenerated: that would burn a rate-limit token on a cache hit without the user asking." revalidating: type: boolean description: True when this read enqueued an out-of-band warm because the cache was stale or missing AND a provider exists. Poll (bounded) until it clears rather than sitting on the stale payload. hasProvider: type: boolean description: False when no AI provider is configured anywhere. The served briefing can then NEVER refresh — no warm pass will fill it — so pair its age with a connect-a-provider affordance and stop polling. generationFailed: type: boolean description: True when the most recent generation attempt failed after the last successful one. The last good text is still served, so this is the only honest signal that what is shown is held rather than fresh. generationFailureClass: description: Coarse class of that failure, so the empty state can point at the right lever (raise the response timeout versus re-check the provider). Null when the last attempt succeeded. type: - string - "null" briefingOmittedReason: anyOf: - type: string const: ungrounded - type: "null" description: Non-null when a generation SUCCEEDED but the grounding gate withheld the briefing — a different state from `generationFailed`, and the reason the card can say 'withheld' instead of showing a silent empty tile. required: - insights - cached - legacyPayload - revalidating - hasProvider - generationFailed - generationFailureClass - briefingOmittedReason additionalProperties: false description: "The read-only advisor payload. This route NEVER calls a provider: it serves the cache and, when the cache is stale and a provider exists, enqueues a warm out of band." InsightsGeneratePostEnvelope: type: object properties: data: $ref: "#/components/schemas/InsightsGeneratePostResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false InsightsGeneratePostResponse: type: object properties: insights: type: object propertyNames: type: string additionalProperties: {} description: "The insight payload, fresh or cached depending on `cached`, with its `dailyBriefing` resolved for today exactly as on the read GET. A cached briefing generated on an earlier calendar day never short-circuits an account with a provider: like a briefingless cache, it regenerates, and degrades to the cached payload on an exhausted quota." cached: type: boolean cachedAt: description: Present on the cached branch only. type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ legacyPayload: type: boolean briefingOmittedReason: description: Non-null when the grounding gate stripped the briefing from THIS generation. Present on the freshly-generated branch. anyOf: - type: string const: ungrounded - type: "null" required: - insights - cached - legacyPayload additionalProperties: false description: "The generating POST's answer. A 200 does not mean a provider was called — a cache hit inside the 24-hour window, and an exhausted hourly quota on a briefingless cache, both answer 200 with `cached: true`." ProviderChainEnvelope: type: object properties: data: $ref: "#/components/schemas/ProviderChainResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false ProviderChainResponse: type: object properties: activeProvider: description: The provider type the runner would reach first — the head of the RESOLVED chain, which drops disabled entries. Null when the account has none configured. type: - string - "null" cachedActiveProvider: description: "The last provider that actually worked, from the in-process cache. Present when the runner has rerouted to a fallback, so the settings surface can say `active: codex (cached: openai)` instead of leaving the reroute in the logs. In-process, so it resets on restart and can differ between instances." type: - string - "null" configuredChain: type: array items: type: object properties: providerType: type: string enabled: type: boolean description: Whether the runner may reach this entry. available: type: boolean description: Always `true` today. Reserved for rendering an unconfigured slot with a needs-setup pill; do not branch on it as if it reported credential presence. required: - providerType - enabled - available additionalProperties: false description: "The PERSISTED chain in priority order, disabled entries INCLUDED — that is the difference from `activeProvider`, and it is deliberate: reading the resolved list here once made a disabled entry vanish from the settings list along with its toggle position." required: - activeProvider - cachedActiveProvider - configuredChain additionalProperties: false description: The account's AI-provider chain as the settings surface reads it. Provider TYPES and boolean state only — no key, token or account id is ever on this wire. ProviderChainSavedEnvelope: type: object properties: data: type: object properties: saved: type: boolean const: true required: - saved additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false InsightsFeedbackCreatedEnvelope: type: object properties: data: type: object properties: id: type: string createdAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - id - createdAt additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false CoachReadStripEnvelope: type: object properties: data: $ref: "#/components/schemas/CoachReadStrip" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false CoachReadStrip: type: object properties: baseline: anyOf: - type: object properties: low: type: number description: Robust lower edge of the personal range. high: type: number description: Robust upper edge. latest: type: number description: The latest reading, same units. Today's only when `latestIsToday` is true; otherwise it is from `latestDate`. latestDate: type: string pattern: ^\d{4}-\d{2}-\d{2}$ description: Calendar day of the latest reading in the account's time zone, `YYYY-MM-DD`. latestIsToday: type: boolean description: True when `latestDate` is today in the account's time zone. When false, a client names the reading's date instead of calling it today's. placement: type: string enum: - within - above - below sampleDays: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Distinct days behind the band, for transparency. basis: description: Present when today is still in progress for a metric whose day mean moves with the hour (blood glucose). `latest` is then today's mean so far and `low` / `high` the usual range for the same hours of the earlier days, not the whole-day band; a client naming the range says it is the one for this time of day. Absent for the whole-day comparison. type: string const: sameHours required: - low - high - latest - latestDate - latestIsToday - placement - sampleDays additionalProperties: false - type: "null" description: Line one. Null when the band could not be established or there is no reading to place — and then `learning` is true. Mutually exclusive with it. learning: type: boolean description: True when history is below the engine's seven-day floor or there are no readings. Render 'still learning your range' rather than inventing one. driver: anyOf: - type: object properties: note: type: string description: An already-localised, conservative, descriptive sentence the client prints VERBATIM. Never causal. Written in the reader's language server-side, which is why it is not a key. behaviour: type: string description: Lower-cased behaviour label. outcome: type: string description: Lower-cased outcome label. required: - note - behaviour - outcome additionalProperties: false - type: "null" description: "Line two: the single strongest lagged association whose outcome is this metric. Null when none clears the effect-size floor, and null when the correlation read failed — the failure is deliberately swallowed so a hiccup there never sinks line one." required: - baseline - learning - driver additionalProperties: false description: The two server-authoritative lines a metric sub-page renders above its chart. Pure compute over the baseline and correlation engines — no provider call, no cache table — so web and native decode the same resolved DTO. NarrativeEnvelope: type: object properties: data: $ref: "#/components/schemas/NarrativeResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false NarrativeResponse: type: object properties: period: type: string description: Echoes the requested period. locale: type: string description: The locale the row was read for. narrative: anyOf: - type: object properties: text: type: string description: The retrospective prose, rendered as-is. provenance: type: string description: Where the text came from — provider prose or the deterministic non-causal fallback a provider-less account gets. updatedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - text - provenance - updatedAt additionalProperties: false - type: "null" description: Null when no row exists for this period and locale yet, and when the stored row is model-written while the `periodNarrative` capability is unavailable (a deterministic row is always served). revalidating: type: boolean description: "True when this read enqueued a warm. Always FALSE on a delegated request even when the row is stale: this route warms unconditionally rather than on a miss, so a manager's first navigation here would otherwise be an egress of the owner's record the owner never asked for. The stale row is still served; only the warm is withheld. Also false while `periodNarrative` is unavailable and a servable row exists." ai: description: The `periodNarrative` capability behind model-written narratives. $ref: "#/components/schemas/AiCapabilityState" required: - period - locale - narrative - revalidating - ai additionalProperties: false description: The latest generated period retrospective. Read-only by construction — it never blocks on a provider, serves the last good row immediately, and warms out of band. Glp1TimelineEnvelope: type: object properties: data: $ref: "#/components/schemas/Glp1TimelineResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false Glp1TimelineResponse: type: object properties: hasGlp1: type: boolean description: False when the record holds no medication classified GLP-1. `entries` is then empty and the surface hides cleanly. entries: type: array items: type: object properties: date: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ description: When it happened. A `side-effect` entry is a DAY rather than an instant, and is stamped at 12:00 UTC of that day so it sorts — do not read it as a capture time. kind: type: string enum: - dose-change - injection - inventory - side-effect medicationName: description: Absent on a `side-effect` entry. type: string doseValue: type: number doseUnit: type: string doseDelta: description: Titration direction against the previous step. anyOf: - type: string enum: - up - down - type: "null" note: description: The titration note, decrypted. type: - string - "null" injectionSite: type: - string - "null" inventoryDelta: type: integer minimum: -9007199254740991 maximum: 9007199254740991 reason: type: string tags: description: "Catalogue keys of the side effects tagged that day, deduplicated. These are STABLE KEYS, not the strings the entries were written with: the capture chip writes its label in the writer's language, and the matcher indexes every shipped locale's label back to the key, so the same recorded symptom yields the same key whether it was tagged in German, English, Spanish, French, Italian or Polish. Render the key in the reader's own language rather than echoing it. A mood tag that is not a catalogue side effect — anything the user typed freehand — is not reported here; it is not a side effect, and a therapy timeline is not the place for the rest of somebody's day." type: array items: type: string enum: - nausea - constipation - diarrhea - fatigue - appetite-loss - heartburn - headache required: - date - kind additionalProperties: false description: "Newest first, capped at `limit`. A heterogeneous union: which optional keys are present depends on `kind`, and no entry carries all of them." medications: type: array items: type: object properties: id: type: string name: type: string required: - id - name additionalProperties: false description: "Every medication classified GLP-1 in the record, whatever `limit` cut from `entries`. Read this, not the entry names, to know which medications are GLP-1: a medication whose events are all older than the newest `limit` entries has none in `entries`. Empty when `hasGlp1` is false." required: - hasGlp1 - entries - medications additionalProperties: false description: "A chronological merge of the GLP-1-relevant events the record holds: titration steps, injections with their sites, legacy stock-ledger movements, and side-effect-tagged mood days from the trailing 90 days. Inventory entries come from the legacy running-sum ledger only, so a medication tracked with per-container inventory shows none." IntradayPulseEnvelope: type: object properties: data: $ref: "#/components/schemas/IntradayPulseResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false IntradayPulseResponse: type: object properties: dateKey: type: string description: The local day that was read. timezone: type: string description: The zone the day was anchored to. bucketMinutes: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Width of one bucket. series: type: array items: type: object properties: startMinute: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Minutes since local midnight at the bucket start. mean: type: number count: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Raw samples behind the mean. A bucket below the density floor is a GAP that breaks a candidate tension run, not a thin data point — a lone reading is a point, not a bucket. min: description: Bucket low, when the source carries a spread. type: number max: description: Bucket high, same condition. type: number required: - startMinute - mean - count additionalProperties: false description: The day's shape, ascending. baseline: description: The resting baseline the day was judged against. type: - number - "null" baselineSource: type: string enum: - resting - proxy - none description: "`resting` from recorded resting heart rate, `proxy` when those rows are stale or absent and the pulse history stood in, `none` when no baseline could be formed." tension: anyOf: - type: object properties: startMinute: type: integer minimum: -9007199254740991 maximum: 9007199254740991 endMinute: type: integer minimum: -9007199254740991 maximum: 9007199254740991 partOfDay: type: string enum: - morning - afternoon - evening - night meanHr: type: number baseline: type: number hrvConfirmed: type: boolean description: True when intraday HRV independently confirmed it. required: - startMinute - endMinute - partOfDay - meanHr - baseline - hrvConfirmed additionalProperties: false - type: "null" description: At most ONE cautious elevated-at-rest window, and only when every confidence gate holds — the bar is set high and under-flagging is the intended failure mode. ALWAYS null on an `hourly` day, because a tension read needs per-sample resolution. resolution: type: string enum: - tenMin - hourly description: "`tenMin` when the series was folded from live raw samples, `hourly` when the day fell outside the dense-retention window and the coarser folded tier answered instead. An older day reads back coarser rather than empty." required: - dateKey - timezone - bucketMinutes - series - baseline - baselineSource - tension - resolution additionalProperties: false description: One local day's intraday heart-rate shape, plus at most one cautious elevated-at-rest window. Computed from raw samples through the read-swap pattern rather than persisted as ten-minute rollups for all history. Awareness only, never a diagnosis. InsightsSettingsEnvelope: type: object properties: data: $ref: "#/components/schemas/InsightsSettingsResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false InsightsSettingsResponse: type: object properties: codexStatus: type: string description: The account's own ChatGPT-OAuth connection state; `disconnected` when the account row carries none. codexConnectedAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" description: When that connection was made; null when never connected. hasAdminKey: type: boolean description: "Presence only: whether the operator has stored a shared AI key. The key itself never leaves the server." codexOauthConfigured: type: boolean description: Whether the operator configured an OAuth client id. False means the connect flow is dead end-to-end, so hide the button rather than sending the user to a login they can never complete. centralCodexAvailable: type: boolean description: "Presence only: whether the operator has connected the shared central ChatGPT account AND all three of its stored credentials are present. The encrypted credentials themselves are NEVER returned to a non-admin client." useCentralCodex: type: boolean description: The account's own opt-in to that shared connection. Off by default everywhere. privacyMode: type: string enum: - aggregated - raw description: "What leaves for the provider: aggregated figures or raw readings. `aggregated` when the account has never chosen." lastInsightAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" description: When the cached insight text was last written. required: - codexStatus - codexConnectedAt - hasAdminKey - codexOauthConfigured - centralCodexAvailable - useCentralCodex - privacyMode - lastInsightAt additionalProperties: false description: The account's AI-provider settings as the settings surface reads them. Every operator-side field is presence-only — no key, token or account id is ever on this wire. InsightsSettingsUpdatedEnvelope: type: object properties: data: type: object properties: updated: type: boolean const: true required: - updated additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false TargetsEnvelope: type: object properties: data: $ref: "#/components/schemas/TargetsResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false TargetsResponse: type: object properties: targets: type: array items: $ref: "#/components/schemas/TargetItem" description: Vital tiles in registry order with the sleep tile spliced in after pulse, then the medication tile when one applies, then the mood tiles, then the glucose tiles. pageSummary: type: object properties: targetsMetThisWeek: type: integer minimum: -9007199254740991 maximum: 9007199254740991 totalTargets: type: integer minimum: -9007199254740991 maximum: 9007199254740991 streakHighlight: anyOf: - type: object properties: metric: type: string days: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - metric - days additionalProperties: false - type: "null" required: - targetsMetThisWeek - totalTargets - streakHighlight additionalProperties: false bpDiastolic: type: object properties: current: type: - number - "null" average30: type: - number - "null" range: anyOf: - type: object properties: min: type: number max: type: number required: - min - max additionalProperties: false - type: "null" required: - current - average30 - range additionalProperties: false description: "The diastolic half, carried beside the tiles rather than as one: the blood-pressure tile grades on systolic, and this is what the card needs to render the pair." profile: type: object properties: heightCm: type: - number - "null" age: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" gender: anyOf: - type: string enum: - MALE - FEMALE - OTHER - type: "null" glucoseUnit: type: string required: - heightCm - age - gender - glucoseUnit additionalProperties: false description: The profile facts the bands were resolved from, echoed so a client can explain a band instead of guessing at it. required: - targets - pageSummary - bpDiastolic - profile additionalProperties: false description: Per-metric target tiles with their consistency strips, the page-level summary, the diastolic companion figures and the profile facts every band was resolved from. Pure compute over the record's own data; no provider anywhere on the path. TargetItem: type: object properties: type: type: string description: Target identity. A MeasurementType token for the vital tiles, and a domain token for the derived ones (sleep, medication compliance, mood, the glucose tiles). label: type: string description: Already-resolved display label. current: type: - number - "null" average30: type: - number - "null" trend: anyOf: - type: string enum: - up - down - stable - type: "null" description: Null when there is not enough series to call one. unit: type: string range: anyOf: - type: object properties: min: type: number max: type: number required: - min - max additionalProperties: false - type: "null" description: The band the tile grades against, resolved from the account's own profile facts and any personal override. Null when no band can be resolved. classification: anyOf: - type: object properties: category: type: string color: type: string required: - category - color additionalProperties: false - type: "null" description: Where the current value sits in the band; null without one. source: type: string description: Provenance token for the 'where this comes from' link. daysInRange7d: type: integer minimum: -9007199254740991 maximum: 9007199254740991 daysLogged7d: type: integer minimum: -9007199254740991 maximum: 9007199254740991 daysInRange30d: type: integer minimum: -9007199254740991 maximum: 9007199254740991 daysLogged30d: type: integer minimum: -9007199254740991 maximum: 9007199254740991 lastMetGoalAt: type: - string - "null" streakDays: type: integer minimum: -9007199254740991 maximum: 9007199254740991 insufficientData: type: boolean description: True when the consistency figures are below their floor. Render the calm learning state instead of the strip. consistency7d: type: array items: anyOf: - type: string enum: - in - near - out - type: "null" description: Seven day bands, oldest first. A null entry is an UNLOGGED day, which is not the same as an out-of-range one. details: description: Per-tile extras. Only the medication-compliance tile carries any today; every other tile omits the key. type: object properties: medications: type: array items: type: object properties: name: type: string compliance7: type: number compliance30: type: number required: - name - compliance7 - compliance30 additionalProperties: false additionalProperties: false direction: description: v1.43 — which way a change of this target counts. anyOf: - $ref: "#/components/schemas/TrendVerdict" - type: "null" trendSentiment: description: v1.43 — the verdict applied to this tile's change. anyOf: - $ref: "#/components/schemas/TrendSentiment" - type: "null" required: - type - label - current - average30 - trend - unit - range - classification - source - daysInRange7d - daysLogged7d - daysInRange30d - daysLogged30d - lastMetGoalAt - streakDays - insufficientData - consistency7d additionalProperties: false description: "One target tile: the current value against its band, plus the consistency strip behind it. The tile array's membership varies per account — a medication tile appears only with active medications, and the glucose tiles only with readings." AnalyticsRangeResponseEnvelope: type: object properties: data: $ref: "#/components/schemas/AnalyticsRangeResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false AnalyticsRangeResponse: type: object properties: range: type: string enum: - 7d - 30d - 90d - 1y description: The range that was read (echoes the request). windowDays: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Trailing-window length in days for the chosen range. granularity: type: string description: Rollup granularity the read resolved against (`DAY` / `WEEK` / `MONTH` / `YEAR`), `live` for SLEEP_DURATION (nights are reconstructed from the stage rows), or `none` when there is no data. current: description: Aggregate over the current window `[now-N, now)`. $ref: "#/components/schemas/AnalyticsWindowAggregate" previous: description: Aggregate over the previous comparable window `[now-2N, now-N)`. $ref: "#/components/schemas/AnalyticsWindowAggregate" delta: description: "`current.mean - previous.mean`; null when either window has no data (never a misleading 0)." type: - number - "null" deltaPct: description: "`delta / previous.mean` as a fraction (0.03 = +3 %); null when the prior window has no / zero mean (no divide-by-zero). The client shows 'no prior-period data' in that case." type: - number - "null" required: - range - windowDays - granularity - current - previous - delta - deltaPct additionalProperties: false description: Single-metric period-over-period aggregate. Reads the current and previous comparable windows from the rollup tier and composes a count-weighted-mean delta, one source per day. Step-like totals compare average daily totals and SLEEP_DURATION compares average nights, not per-reading means. `count/min/max/mean/sum` are linearly composable across buckets; SD/slope/r² are intentionally excluded (not composable). AnalyticsWindowAggregate: type: object properties: count: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Reading count composed across buckets. For step-like totals it is the number of days with a total, for SLEEP_DURATION the number of nights. min: description: Window minimum; null when empty. type: - number - "null" max: description: Window maximum; null when empty. type: - number - "null" mean: description: Count-weighted mean across buckets; null when empty. For step-like totals (steps, energy, distance, flights) it is the average daily total, for SLEEP_DURATION the average time asleep per night. type: - number - "null" sum: description: Cumulative total for cumulative metrics (steps, energy, distance); null when no bucket carries a sum. type: - number - "null" required: - count - min - max - mean - sum additionalProperties: false BloodPressureStatusResponseEnvelope: type: object properties: data: $ref: "#/components/schemas/InsightStatusResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false InsightStatusResponse: type: object properties: hasProvider: type: boolean description: "Whether a configured AI provider exists for the record: provider presence and nothing else. It is never false because the operator switched notes off or consent is missing; `ai` says that." text: description: The assessment note (plain text, rendered as React text children). Null while a first generation is preparing, and whenever the `statusText` capability is unavailable (see `ai`). type: - string - "null" cached: type: boolean description: True when `text` is served from cache (incl. last-good). updatedAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" description: When the served assessment was generated; null when none. preparing: description: True when a first assessment is being generated out of band and no prior text exists yet — the client polls until it lands. type: boolean revalidating: description: True when `text` is served from last-good cache (stale-while-revalidate) while a fresh generation is in flight. The client keeps polling on `preparing || revalidating` (bounded) so the open card upgrades to the warmed assessment without a remount. type: boolean ai: description: The `statusText` capability behind the note. While it is unavailable the note is null, `preparing` is false, the cache is not read and nothing is warmed. $ref: "#/components/schemas/AiCapabilityState" required: - hasProvider - text - cached - updatedAt - ai additionalProperties: false description: "Specialised per-metric assessment envelope (blood-pressure, pulse, weight, bmi, mood). Identical shape to the generic metric-status card so the `InsightStatusCard` consumes it unchanged. Read-only + stale-while-revalidate: a cache miss warms a generation out of band and serves the last-good text meanwhile." PulseStatusResponseEnvelope: type: object properties: data: $ref: "#/components/schemas/InsightStatusResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false WeightStatusResponseEnvelope: type: object properties: data: $ref: "#/components/schemas/InsightStatusResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false BmiStatusResponseEnvelope: type: object properties: data: $ref: "#/components/schemas/InsightStatusResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MoodStatusResponseEnvelope: type: object properties: data: $ref: "#/components/schemas/InsightStatusResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MedicationComplianceStatusResponseEnvelope: type: object properties: data: $ref: "#/components/schemas/MedicationComplianceStatusResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MedicationComplianceStatusResponse: type: object properties: hasProvider: type: boolean description: "Whether a configured AI provider exists for the record: provider presence and nothing else. It is never false because the operator switched notes off or consent is missing; `ai` says that." summary: description: The overall compliance note (plain text). Null while a first generation is preparing, and whenever the `statusText` capability is unavailable (see `ai`). type: - string - "null" medications: type: array items: $ref: "#/components/schemas/MedicationComplianceStatusItem" description: Per-medication compliance notes. Empty while preparing or when no medication qualifies. cached: type: boolean description: True when the envelope is served from cache (incl. last-good). updatedAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" description: When the served assessment was generated; null when none. preparing: description: True when a first assessment is being generated out of band and no prior summary exists yet — the client polls until it lands. type: boolean revalidating: description: True when the envelope is served from last-good cache (stale-while-revalidate) while a fresh generation is in flight. The client keeps polling on `preparing || revalidating` (bounded). type: boolean ai: description: The `statusText` capability behind the note. While it is unavailable `summary` is null, `medications` is empty, `preparing` is false, the cache is not read and nothing is warmed. $ref: "#/components/schemas/AiCapabilityState" required: - hasProvider - summary - medications - cached - updatedAt - ai additionalProperties: false description: Medication-compliance assessment envelope. Unlike the other six specialised cards it carries a `summary` plus a per-medication `text` array rather than a single `text` field. Read-only + stale-while-revalidate. MedicationComplianceStatusItem: type: object properties: medicationId: type: string description: The medication this note belongs to. text: type: string description: Per-medication compliance note (plain text). required: - medicationId - text additionalProperties: false MetricStatusResponseEnvelope: type: object properties: data: $ref: "#/components/schemas/MetricStatusResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MetricStatusResponse: type: object properties: hasProvider: type: boolean description: "Whether a configured AI provider exists for the record: provider presence and nothing else. It is never false because the operator switched notes off or consent is missing; `ai` says that." text: description: The assessment note (plain text, rendered as React text children). Null while a first generation is preparing, when the metric has insufficient data, and whenever the `statusText` capability is unavailable (see `ai`). type: - string - "null" cached: type: boolean description: True when `text` is served from cache (incl. last-good). updatedAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" description: When the served assessment was generated; null when none. preparing: description: True when a first assessment is being generated out of band and no prior text exists yet — the client polls until it lands. type: boolean revalidating: description: True when `text` is served from last-good cache (stale-while-revalidate) while a fresh generation is in flight. The payload is otherwise terminal; the client keeps polling on `preparing || revalidating` (bounded) so the open card upgrades to the warmed assessment without a remount. type: boolean insufficient: description: True when the metric has no readings; no assessment is generated (no LLM call). The card shows its insufficient-data state. Still computed while `ai` is unavailable. type: boolean ai: description: The `statusText` capability behind the note. While it is unavailable the note is null, `preparing` is false, the cache is not read and nothing is warmed. $ref: "#/components/schemas/AiCapabilityState" required: - hasProvider - text - cached - updatedAt - ai additionalProperties: false description: "Generic per-metric assessment envelope. Identical shape to the seven specialised `*-status` cards so the `InsightStatusCard` consumes it unchanged. Read-only + stale-while-revalidate: a cache miss warms a generation out of band and serves the last-good text meanwhile." BiomarkerAssessmentResponseEnvelope: type: object properties: data: $ref: "#/components/schemas/BiomarkerAssessmentResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false BiomarkerAssessmentResponse: type: object properties: hasProvider: type: boolean description: "Whether a configured AI provider exists for the record: provider presence and nothing else. It is never false because the operator switched notes off or consent is missing; `ai` says that." text: description: The assessment note (plain text, rendered as React text children). Null while a first generation is preparing, when the marker has no numeric readings, and whenever the `statusText` capability is unavailable (see `ai`). type: - string - "null" cached: type: boolean description: True when `text` is served from cache (incl. last-good). updatedAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" description: When the served assessment was generated; null when none. preparing: description: True when a first assessment is being generated out of band and no prior text exists yet — the client polls until it lands. type: boolean revalidating: description: True when `text` is served from last-good cache (stale-while-revalidate) while a fresh generation is in flight. The client keeps polling on `preparing || revalidating` (bounded) so the open card upgrades to the warmed assessment without a remount. type: boolean insufficient: description: True when the marker has no numeric readings; no assessment is generated (no LLM call). The card is not rendered. type: boolean ai: description: The `statusText` capability behind the note. While it is unavailable the note is null, `preparing` is false, the cache is not read and nothing is warmed. $ref: "#/components/schemas/AiCapabilityState" required: - hasProvider - text - cached - updatedAt - ai additionalProperties: false description: "Per-biomarker assessment envelope. Identical shape to the generic metric-status card so the `InsightStatusCard` consumes it unchanged. Read-only + stale-while-revalidate: a cache miss warms a generation out of band and serves the last-good text meanwhile; the assessment regenerates only when a new reading lands." DerivedMetricResponseEnvelope: type: object properties: data: $ref: "#/components/schemas/DerivedMetricResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DerivedMetricResponse: type: object properties: metric: type: string enum: - VITALS_BASELINE - FITNESS_AGE - VASCULAR_AGE_DELTA - SLEEP_SCORE - READINESS - COINCIDENT_DEVIATION - HRV_BALANCE - BMI - RECOVERY_SCORE - STRESS_SCORE - STRAIN_SCORE - WRIST_TEMPERATURE_BASELINE - STAIR_ASCENT_SPEED_BASELINE - STAIR_DESCENT_SPEED_BASELINE - SIX_MINUTE_WALK_BAND - TRAJECTORY - HEALTH_SCORE - SAME_TIME_BASELINE description: Echoes the requested derived-metric id (tags the union). status: type: string enum: - ok - insufficient description: "'ok' carries `value` + `confidence`; 'insufficient' carries `reason` and no value, but still carries `coverage` + `provenance` so the surface renders the same gating UI." value: anyOf: - type: object propertyNames: type: string additionalProperties: {} - type: "null" description: Metric-specific value object when status is 'ok'; null when 'insufficient'. The shape is chosen by `metric`, and the record stays open because one route serves eighteen of them. `RECOVERY_SCORE` / `STRESS_SCORE` / `STRAIN_SCORE` return the `WellnessScoreValue` schema — field-by-field in `components.schemas`, including the `series`, `daysInWindow` and `asOf` a client would otherwise have to discover by inspecting a live payload. `SLEEP_SCORE` returns the `SleepScoreValue` schema, including `rhythmNights` and the per-sub-score `gap`. `VITALS_BASELINE` returns { type, center, low, high, spread, sampleDays, k, series, direction }, where `series` is the trailing per-day means for the inline sparkline and `direction` (v1.43, a `TrendVerdict`) says which way a change of the vital counts for this person, with `center` placed against their own target or a profile default. The remaining metrics' value objects are not modelled here yet; their shapes are TypeScript interfaces beside their engines under `src/lib/insights/derived/`. coverage: $ref: "#/components/schemas/DerivedCoverage" confidence: anyOf: - $ref: "#/components/schemas/DerivedConfidence" - type: "null" description: Present when status is 'ok'; null when 'insufficient'. provenance: $ref: "#/components/schemas/DerivedProvenance" reason: description: Why the value could not be produced; null when status is 'ok'. type: - string - "null" assessment: anyOf: - $ref: "#/components/schemas/DerivedAssessment" - type: "null" description: "v1.13.2 — short 'why is this score what it is' explanation, keyed to the SAME requested id (only for the per-score ids READINESS, SLEEP_SCORE, RECOVERY_SCORE, STRAIN_SCORE, STRESS_SCORE). Null for any other metric and whenever status !== 'ok'. Always non-empty when present: a deterministic text fills it (so provider-less accounts + the demo always get one) and warmer AI prose overrides it once cached, but only while the `statusText` AI capability is available; otherwise the deterministic text is served and nothing is warmed." ai: description: The `statusText` capability behind the model-written assessment. Present on the single-metric route; absent on the batch route, whose assessments are always the deterministic text. $ref: "#/components/schemas/AiCapabilityState" required: - metric - status - value - coverage - confidence - provenance - reason - assessment additionalProperties: false description: Flat `Derived` envelope for one derived wellness metric. Pure compute over the rollup tier (no LLM, no narrative). iOS decodes one stable shape and combines values across metrics; coverage/confidence/provenance let it render the same honesty chips. DerivedAssessment: type: object properties: text: type: string description: Short, non-empty explanation of why the score sits where it does, referencing the score's contributors. source: type: string description: "'deterministic' for the always-on template text, or 'ai' when warmer provider prose has been cached." updatedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ description: When the text was produced / last warmed. required: - text - source - updatedAt additionalProperties: false DerivedBatchResponseEnvelope: type: object properties: data: $ref: "#/components/schemas/DerivedBatchResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DerivedBatchResponse: type: object properties: metrics: type: object propertyNames: type: string additionalProperties: $ref: "#/components/schemas/DerivedMetricResponse" description: Map keyed by the per-request token (`` or `:`). Each value is the same flat `Derived` envelope the single-metric route returns, so a client decodes one shape and reads back exactly the tokens it asked for. required: - metrics additionalProperties: false description: Batched derived-metric values. One request resolves the whole dashboard grid (the wellness scores + the derived re-frames + one baseline per vital) instead of N concurrent single-metric requests sharing the Prisma pool. Pure compute over the rollup tier — no LLM, no narrative, no cache table. ScoreHistoryResponseEnvelope: type: object properties: data: $ref: "#/components/schemas/ScoreHistoryResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false ScoreHistoryResponse: type: object properties: score: type: string enum: - HEALTH_SCORE - READINESS - SLEEP_SCORE days: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: The window the request asked for. points: type: array items: type: object properties: day: type: string description: The local calendar day the value describes, `YYYY-MM-DD`; the day the day view opens for it. value: type: integer minimum: 0 maximum: 100 seamBreak: type: boolean description: "True on the first day scored under a different recipe (what counts, or the algorithm version) than the day before. Draw no line from the previous point to this one: the values either side are averages of different things. Only the health score has seams; never true on the first point." required: - day - value - seamBreak additionalProperties: false description: One point per day that has a value, oldest first. A day without one is absent, never zero. band: anyOf: - type: object properties: lo: type: integer minimum: -9007199254740991 maximum: 9007199254740991 hi: type: integer minimum: -9007199254740991 maximum: 9007199254740991 n: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Days the range was formed from. required: - lo - hi - n additionalProperties: false - type: "null" description: "The person's usual range behind the newest point: median and scaled MAD of the values in the 30 days before it, from its own side of the last seam. Null with fewer than seven such days. Describes, does not grade." required: - score - days - points - band additionalProperties: false description: One score's daily course over a trailing window, for a history chart with a day per point. InsightsGlp1PlateauResponseEnvelope: type: object properties: data: $ref: "#/components/schemas/InsightsGlp1PlateauResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false InsightsGlp1PlateauResponse: type: object properties: plateau: anyOf: - type: object properties: drug: type: string description: Display drug name ("Mounjaro"). doseValue: type: number description: Current dose value (e.g. 7.5). doseUnit: type: string description: Dose unit (e.g. "mg"). doseSince: type: string description: ISO date (YYYY-MM-DD) the current dose started. daysOnDose: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Days the user has been on the current dose. weightDeltaKg: type: number description: Weight delta in kg over the trailing window (negative = loss). readingsCount: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Number of weight readings considered. required: - drug - doseValue - doseUnit - doseSince - daysOnDose - weightDeltaKg - readingsCount additionalProperties: false - type: "null" description: Null when no plateau is detected (no active GLP-1 medication, < window days on the current dose, weight still dropping, or fewer than two readings). windowDays: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Trailing comparison window in days (currently 21). required: - plateau - windowDays additionalProperties: false description: "Deterministic weight-plateau detection for users on an active GLP-1 medication: stable dose for ≥ the window with no weight loss beyond the threshold. Association only — carries no verdict or advice." EcgListResponseEnvelope: type: object properties: data: $ref: "#/components/schemas/EcgListResponse" error: type: "null" meta: $ref: "#/components/schemas/ListCapMeta" required: - data - error - meta additionalProperties: false EcgListResponse: type: object properties: recordings: type: array items: $ref: "#/components/schemas/EcgRecordingListItem" description: The user's ECG recordings, newest first (capped at 200). hasRecordings: type: boolean description: False for an account with no recordings — the client un-mounts the whole ECG surface (data-availability floor). required: - recordings - hasRecordings additionalProperties: false description: Metadata-only ECG recording list. Never carries the waveform — the per-recording strip is fetched on demand from GET /api/insights/ecg/{id}. Read-only; no LLM call. Reflects only the device's own classification, never a HealthLog interpretation. EcgRecordingListItem: type: object properties: id: type: string description: Recording id (cuid). recordedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ description: On-device recording time (the display timestamp). durationSeconds: description: "`sampleCount / samplingFrequency` in seconds; null when the source omitted the sampling frequency." type: - number - "null" samplingFrequency: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Signal sampling rate in Hz (Withings ScanWatch = 300); 0 when the source omitted it. sampleCount: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Number of samples in the (encrypted) waveform array. averageHeartRate: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" description: Source-reported average heart rate (BPM) for the strip, when present. lead: description: Recording lead label when the source reports it; null for the single-lead ScanWatch (Lead I). type: - string - "null" classification: anyOf: - type: string enum: - IRREGULAR - NOT_DETECTED - INCONCLUSIVE - type: "null" description: "The RECORDING DEVICE's own rhythm classification, surfaced verbatim: IRREGULAR (device flagged possible atrial fibrillation), NOT_DETECTED (algorithm ran, nothing flagged), INCONCLUSIVE (poor signal / out-of-range HR). Null when the source reported none. HealthLog never re-classifies an ECG — this is the device's certified on-device result, not a HealthLog verdict." source: type: string description: Measurement source that wrote the recording (e.g. WITHINGS). hasWaveform: type: boolean description: True when a waveform is stored (`sampleCount > 0`); false for a verdict-only fallback event with no signal to fetch. required: - id - recordedAt - durationSeconds - samplingFrequency - sampleCount - averageHeartRate - lead - classification - source - hasWaveform additionalProperties: false EcgIngestResponseEnvelope: type: object properties: data: $ref: "#/components/schemas/EcgIngestResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false EcgIngestResponse: type: object properties: id: type: string description: The recording row's id (cuid). status: type: string enum: - inserted - updated - duplicate description: What the write did. `inserted` — a new recording landed (HTTP 201). `updated` — this `externalRecordingId` was already stored and the row was overwritten in place (HTTP 200); a retry is safe and creates nothing. `duplicate` — this exact recording is already stored under a DIFFERENT id, i.e. it arrived earlier through the `export.zip` importer (HTTP 200); nothing was written and `id` names the row that already holds it. A recording is the same recording when `(source, recordedAt, samplingFrequency)` match, which is enforced by a unique index rather than by convention. All three statuses mean the recording is stored and the client may advance its cursor. recordedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ description: The `recordedAt` that was submitted, echoed back. sampleCount: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: "Server-derived: the number of samples received." durationSeconds: description: "Server-derived: `sampleCount / samplingFrequency`." type: - number - "null" required: - id - status - recordedAt - sampleCount - durationSeconds additionalProperties: false description: The outcome of one ECG ingest. Reported honestly rather than optimistically — a re-post never claims to have inserted. EcgIngestCreatedEnvelope: type: object properties: data: $ref: "#/components/schemas/EcgIngestResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false EcgDetailResponseEnvelope: type: object properties: data: $ref: "#/components/schemas/EcgDetailResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false EcgDetailResponse: type: object properties: recordedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ description: On-device recording time (the display timestamp). durationSeconds: description: Strip duration in seconds; null when the sampling frequency was 0. type: - number - "null" samplingFrequency: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Signal sampling rate in Hz (300 for a ScanWatch). averageHeartRate: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" description: Source-reported average heart rate (BPM) for the strip, when present. lead: description: Recording lead label when reported; null for the single-lead ScanWatch (Lead I). type: - string - "null" classification: anyOf: - type: string enum: - IRREGULAR - NOT_DETECTED - INCONCLUSIVE - type: "null" description: "The RECORDING DEVICE's own rhythm classification, surfaced verbatim: IRREGULAR (device flagged possible atrial fibrillation), NOT_DETECTED (algorithm ran, nothing flagged), INCONCLUSIVE (poor signal / out-of-range HR). Null when the source reported none. HealthLog never re-classifies an ECG — this is the device's certified on-device result, not a HealthLog verdict." source: type: string description: Measurement source that wrote the recording. samples: type: array items: type: number description: Micro-volt waveform samples. Min/max-decimated to ~2500 points by default so R-wave peaks survive the fit-to-width compression; the raw array when `?full=1`. The x-position of each sample is implied by its index (an overview trace, not a calibrated-time axis) unless `decimated` is false. decimated: type: boolean description: True when `samples` was min/max-decimated for display; false when the raw array is returned (`?full=1`, or a strip already at or below the display budget). required: - recordedAt - durationSeconds - samplingFrequency - averageHeartRate - lead - classification - source - samples - decimated additionalProperties: false description: One ECG recording's decrypted waveform plus metadata and the DEVICE's verbatim classification. Ownership is narrowed in the query where — a foreign / unknown id 404s. The waveform is AES-256-GCM at rest, decrypted through the fail-closed codec. HealthLog does not interpret the trace, measure intervals, annotate beats, or emit a verdict of its own. no-store. RhythmEventsResponseEnvelope: type: object properties: data: $ref: "#/components/schemas/RhythmEventsResponse" error: type: "null" meta: $ref: "#/components/schemas/ListCapMeta" required: - data - error - meta additionalProperties: false RhythmEventsResponse: type: object properties: events: type: array items: $ref: "#/components/schemas/RhythmEventListItem" description: The user's device-flagged events, newest first (capped at 200). hasEvents: type: boolean description: False for an account with no event rows — the client un-mounts the whole surface (data-availability floor). required: - events - hasEvents additionalProperties: false description: Timeline of device-flagged EVENT rows (irregular-rhythm / high-HR / low-HR / walking-steadiness / breathing-disturbance) the user's wearable (Apple Watch / Withings ScanWatch) already produced and synced. AWARENESS / SCREENING of the DEVICE's own decision — HealthLog stores and reflects the classification result verbatim, never a raw waveform, and never re-classifies. Read-only; no LLM call. RhythmEventListItem: type: object properties: id: type: string description: Measurement row id (cuid). type: type: string enum: - IRREGULAR_RHYTHM_NOTIFICATION - HIGH_HEART_RATE_EVENT - LOW_HEART_RATE_EVENT - WALKING_STEADINESS_EVENT - BREATHING_DISTURBANCE_EVENT description: The HealthKit category the device flagged. IRREGULAR_RHYTHM_NOTIFICATION (`HKCategoryTypeIdentifierIrregularHeartRhythmEvent` + ScanWatch AFib screening), HIGH_HEART_RATE_EVENT / LOW_HEART_RATE_EVENT (a sustained heart rate above / below the user's configured threshold while apparently inactive), WALKING_STEADINESS_EVENT (`HKCategoryTypeIdentifierAppleWalkingSteadinessEvent`; the severity rides in `classification`), BREATHING_DISTURBANCE_EVENT (`HKCategoryTypeIdentifierSleepApneaEvent` + `AppleSleepingBreathingDisturbances`, an elevated breathing-disturbance / possible sleep-apnea screening signal). classification: anyOf: - type: string enum: - IRREGULAR - NOT_DETECTED - INCONCLUSIVE - LOW - VERY_LOW - FIRED - type: "null" description: The RECORDING DEVICE's own verdict, surfaced verbatim. IRREGULAR / NOT_DETECTED / INCONCLUSIVE are the ECG-style rhythm verdicts (device flagged a possible irregular rhythm / algorithm ran and found nothing / signal too poor to classify) and occur on an `IRREGULAR_RHYTHM_NOTIFICATION` row. LOW / VERY_LOW are walking-steadiness severities and occur on a `WALKING_STEADINESS_EVENT` row. FIRED is the neutral "event occurred" verdict with no severity gradient, and occurs on a `HIGH_HEART_RATE_EVENT` / `LOW_HEART_RATE_EVENT` / `BREATHING_DISTURBANCE_EVENT` row. Null when the source reported none. HealthLog never re-classifies — this is the device's certified on-device result. Distinct from `EcgRecordingListItem.classification` / `EcgDetailResponse.classification` above, which are scoped to the three ECG-only values. occurredAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ description: Device-reported event time (`measuredAt`). source: $ref: "#/components/schemas/MeasurementSource" deviceType: description: Device class that reported the event (`watch | band | ring | phone | scale | other | unknown`); null when the source omitted it (treated as `unknown`). type: - string - "null" required: - id - type - classification - occurredAt - source - deviceType additionalProperties: false CorrelationDiscoveryResponseEnvelope: type: object properties: data: $ref: "#/components/schemas/CorrelationDiscoveryResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false CorrelationDiscoveryResponse: type: object properties: discovered: type: array items: $ref: "#/components/schemas/DiscoveredCorrelation" description: Pairs surviving n ≥ 20, p < 0.05, AND the BH-FDR control. pairsTested: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Behaviour × outcome pairs assessed (for the honest footer). fdrQ: type: number description: The FDR target the surface used. minPairs: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Minimum paired-day count enforced per pair. emerging: description: v1.22 — rolling early-detection pass over the trailing window; emerging pairs not yet established retrospectively (no double-count). $ref: "#/components/schemas/EmergingCorrelationResult" labCorrelations: description: v1.22 — labs ↔ outcome associations (each draw vs the contemporaneous outcome window-mean), FDR-controlled; absent-degrading on sparse draws. $ref: "#/components/schemas/LabCorrelationResult" findingsBeforeSeasonalAdjustment: type: boolean description: v1.42 — true when the record held a discovery finding before the engine began removing trend and season from both series. Drives a one-time explanation of why fewer pairs surface; false for every account created since. required: - discovered - pairsTested - fdrQ - minPairs - findingsBeforeSeasonalAdjustment additionalProperties: false description: v1.10.0 — FDR-controlled correlation discovery over a curated behaviour × outcome matrix, lagged behaviour → next-day outcome. Only statistically-defensible pairs surface; descriptive, never causal. DiscoveredCorrelation: type: object properties: behaviour: type: string description: Behaviour channel (lag source), e.g. TIME_IN_DAYLIGHT, MOOD. outcome: type: string description: Outcome channel (lag target), e.g. SLEEP_DURATION, HEART_RATE_VARIABILITY. behaviourLabel: description: Display label for a dynamic behaviour channel. type: string outcomeLabel: description: Display label for a dynamic outcome channel. type: string n: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Paired-day count after the lag join (≥ 20). nEff: description: v1.42 — effective sample size after serial correlation (Pyper–Peterman), ≤ `n`. The p-value, the shrinkage and the tier use it. type: number r: type: number description: "Pearson r over the lag-joined daily series, computed on residuals: a linear trend (and, from 120 paired days, one annual harmonic) is removed from both series first (v1.42)." pValue: type: number description: Two-sided exact Student-t p-value (< 0.05) at the effective sample size. qValue: type: number description: Benjamini-Hochberg FDR-adjusted q-value (≤ the surface threshold). shrunkR: type: number description: Sample-size-shrunk effect (r shrunk toward null by depth), used for ranking and the effect-size floor. Display still uses raw `r`. tier: type: string enum: - high - moderate - faint description: "Phrasing-confidence tier the narration honours: `faint` is hedged, `high` is stated plainly, `moderate` sits between. A below-floor pair never reaches this list." interpretation: type: string description: Conservative, descriptive interpretation — never causal. lagDays: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: "Lag in days applied: 1 = behaviour day, outcome the next day; 0 = the environmental channels, whose exposure is the mean of the day before and the day itself, paired with the same day's outcome (v1.42)." window: description: v1.22 — which window surfaced the pair. Absent on the 180-day scan (retrospective default); `recent` for an emerging early-detection pair. type: string enum: - retrospective - recent provisional: description: "v1.22 — true for an emerging recent-window pair: fewer days, hedged as provisional rather than established." type: boolean patternId: description: Account-scoped persisted identity for dismissal. type: string canonicalKey: description: Stable factor + outcome + lag identity. type: string dismissed: description: Server decision to suppress unchanged evidence. type: boolean required: - behaviour - outcome - n - r - pValue - qValue - shrunkR - tier - interpretation - lagDays additionalProperties: false EmergingCorrelationResult: type: object properties: emerging: type: array items: $ref: "#/components/schemas/DiscoveredCorrelation" description: Recent-window pairs NOT already established retrospectively — the emerging signals (provisional, hedged). windowDays: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Trailing window (days) the early pass scanned. minPairs: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Paired-day floor enforced for the early pass. fdrQ: type: number description: FDR target the early pass used (tighter than the main scan). pairsTested: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Pairs tested in the early window. required: - emerging - windowDays - minPairs - fdrQ - pairsTested additionalProperties: false LabCorrelationResult: type: object properties: discovered: type: array items: $ref: "#/components/schemas/DiscoveredLabCorrelation" description: Lab ↔ outcome associations surviving the per-pair floor + BH-FDR. pairsTested: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Lab × outcome pairs assessed. fdrQ: type: number description: The FDR target the pass used. minDraws: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Minimum paired-draw count enforced per pair. required: - discovered - pairsTested - fdrQ - minDraws additionalProperties: false DiscoveredLabCorrelation: type: object properties: lab: type: string description: "`LAB:` channel key (display strips the prefix)." outcome: type: string description: Outcome channel the marker tracks with (WEIGHT, BLOOD_GLUCOSE, BLOOD_PRESSURE_SYS). n: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Draws paired with a usable contemporaneous outcome window. r: type: number description: Pearson r over (draw value, contemporaneous outcome window-mean). pValue: type: number description: Two-sided exact Student-t p-value (< 0.05). qValue: type: number description: Benjamini-Hochberg FDR-adjusted q-value (≤ the surface threshold). windowDays: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Trailing days each draw's outcome window spanned. interpretation: type: string description: Conservative, descriptive interpretation — never causal. required: - lab - outcome - n - r - pValue - qValue - windowDays - interpretation additionalProperties: false CorrelationPatternListResponseEnvelope: type: object properties: data: $ref: "#/components/schemas/CorrelationPatternListResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false CorrelationPatternListResponse: type: object properties: patterns: type: array items: $ref: "#/components/schemas/CorrelationPattern" required: - patterns additionalProperties: false CorrelationPattern: type: object properties: id: type: string canonicalKey: type: string family: type: string factorKey: type: string outcomeKey: type: string lagDays: type: integer minimum: -9007199254740991 maximum: 9007199254740991 sampleSize: type: integer minimum: -9007199254740991 maximum: 9007199254740991 effectSize: type: number pValue: type: number qValue: type: - number - "null" evidenceHash: type: string lastComputedAt: type: string dismissedAt: type: - string - "null" required: - id - canonicalKey - family - factorKey - outcomeKey - lagDays - sampleSize - effectSize - pValue - qValue - evidenceHash - lastComputedAt - dismissedAt additionalProperties: false UpdateCorrelationPatternResponseEnvelope: type: object properties: data: $ref: "#/components/schemas/UpdateCorrelationPatternResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false UpdateCorrelationPatternResponse: type: object properties: id: type: string canonicalKey: type: string dismissed: type: boolean dismissedAt: type: - string - "null" evidenceHash: type: string required: - id - canonicalKey - dismissed - dismissedAt - evidenceHash additionalProperties: false InsightsHealthStatusEnvelope: type: object properties: data: $ref: "#/components/schemas/InsightsHealthStatus" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false InsightsHealthStatus: type: object properties: present: type: boolean description: True when at least one deviation or shift is surfaced. deviations: type: array items: type: object properties: type: type: string description: MeasurementType token. value: type: number description: Today's value. center: type: number description: Personal band center (median). low: type: number high: type: number direction: type: string enum: - above - below basis: type: string enum: - day - sameHours description: "What `low`/`high` describe: the usual whole day (`day`), or the usual range for this time of day (`sameHours`), used for a day still in progress whose mean moves with the hour (glucose). Additive since v1.40.3." required: - type - value - center - low - high - direction - basis additionalProperties: false shifts: type: array items: type: object properties: metric: type: string description: MeasurementType token. breakDate: type: string description: YYYY-MM-DD of the new level's first day. beforeMean: type: number afterMean: type: number direction: type: string enum: - up - down beforeDays: description: v1.44 — days with a value before the break. type: integer minimum: -9007199254740991 maximum: 9007199254740991 afterDays: description: v1.44 — days with a value from the break on. type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - metric - breakDate - beforeMean - afterMean - direction additionalProperties: false generatedAt: type: string description: ISO-8601 instant the read was computed. required: - present - deviations - shifts - generatedAt additionalProperties: false description: Vitals drifting from the user's personal normal — band deviations plus dated, sustained level shifts. Awareness only, never a diagnosis. InsightsBreathingScreeningEnvelope: type: object properties: data: $ref: "#/components/schemas/InsightsBreathingScreening" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false InsightsBreathingScreening: type: object properties: present: type: boolean nights: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Nights with a per-night index reading. recentMeanIndex: description: Mean of the index readings (lower-better); null when none. type: - number - "null" trend: anyOf: - type: string enum: - up - down - stable - type: "null" description: Recent index vs the prior window; null when too few nights. eventCount: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Device-flagged breathing-disturbance / apnea events. classification: anyOf: - type: string enum: - not-elevated - elevated - type: "null" description: The device's own classification; null when no data. generatedAt: type: string required: - present - nights - recentMeanIndex - trend - eventCount - classification - generatedAt additionalProperties: false description: Sleep-breathing-disturbance screening signal — a screening signal only, never a diagnosis. InsightsLabsChangesEnvelope: type: object properties: data: $ref: "#/components/schemas/InsightsLabsChanges" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false InsightsLabsChanges: type: object properties: present: type: boolean latestDate: description: YYYY-MM-DD of the most-recent panel. type: - string - "null" previousDate: description: YYYY-MM-DD of the prior panel. type: - string - "null" changes: type: array items: type: object properties: analyte: type: string unit: type: string latest: type: number previous: type: number delta: type: number description: Signed latest − previous. direction: type: string enum: - up - down - flat status: type: string enum: - in-range - below - above - unknown required: - analyte - unit - latest - previous - delta - direction - status additionalProperties: false generatedAt: type: string required: - present - latestDate - previousDate - changes - generatedAt additionalProperties: false description: Per-analyte change between the two most-recent numeric lab panels. Neutral framing, never a diagnosis. MoodDailySeriesEnvelope: type: object properties: data: $ref: "#/components/schemas/MoodDailySeries" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MoodDailySeries: type: object properties: entries: type: array items: type: object properties: date: type: string description: "`YYYY-MM-DD` day key." score: type: number description: The day's mean pleasantness on the 1..5 scale. samples: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Entries behind the mean. required: - date - score - samples additionalProperties: false description: One entry per day that carries a mood, oldest first. summary: type: object properties: count: type: integer minimum: -9007199254740991 maximum: 9007199254740991 latest: type: - number - "null" min: type: - number - "null" max: type: - number - "null" mean: type: - number - "null" median: type: - number - "null" avg7: type: - number - "null" avg30: type: - number - "null" slope7: anyOf: - type: object properties: slope: type: number direction: type: string enum: - up - down - stable confidence: type: number required: - slope - direction - confidence additionalProperties: false - type: "null" slope30: anyOf: - type: object properties: slope: type: number direction: type: string enum: - up - down - stable confidence: type: number required: - slope - direction - confidence additionalProperties: false - type: "null" anomalyCount: type: integer minimum: -9007199254740991 maximum: 9007199254740991 avg30LastMonth: type: - number - "null" avg30LastYear: type: - number - "null" required: - count - latest - min - max - mean - median - avg7 - avg30 - slope7 - slope30 additionalProperties: false description: The shared summary shape computed over the DAILY MEANS, not over the raw entries — so `count` is the number of days with a mood, not the number of moods logged. Every statistic is null on an empty series rather than 0. required: - entries - summary additionalProperties: false description: The canonical daily mood series. `entryCount` and the rollup-versus-live `source` the builder also resolves are annotated for observability and deliberately NOT on this wire — the two keys below are the whole payload. MoodAggregatesEnvelope: type: object properties: data: $ref: "#/components/schemas/MoodAggregates" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MoodAggregates: type: object properties: summary: type: object properties: points: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Daily points behind the figures. mean: type: - number - "null" min: type: - number - "null" max: type: - number - "null" latest: type: - number - "null" delta: description: Latest daily mean minus the previous daily mean. type: - number - "null" inTargetPct: type: - number - "null" totalEntries: type: integer minimum: -9007199254740991 maximum: 9007199254740991 totalSpanDays: type: integer minimum: -9007199254740991 maximum: 9007199254740991 newestEntryDaysAgo: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" required: - points - mean - min - max - latest - delta - inTargetPct - totalEntries - totalSpanDays - newestEntryDaysAgo additionalProperties: false description: Headline figures. Every statistic is null on an empty window. heatmap: type: object properties: windowDays: anyOf: - type: number const: 30 - type: number const: 90 - type: number const: 365 description: Adaptive window the cells were cut to. cells: type: array items: type: object properties: date: type: string description: "`YYYY-MM-DD` day key." score: type: number description: Daily mean pleasantness for the day. samples: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Entries that fed the mean. required: - date - score - samples additionalProperties: false required: - windowDays - cells additionalProperties: false distribution: type: array items: type: object properties: score: type: number description: Rounded mood level, 1..5. count: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - score - count additionalProperties: false description: Entry counts per rounded level. dimensions: type: array items: $ref: "#/components/schemas/MoodDimensionSummary" description: "One summary per level-A dimension. A dimension nobody answered is still listed, with `present: false`." weekday: type: array items: type: object properties: weekday: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: 0 = Monday … 6 = Sunday. avgScore: type: - number - "null" count: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - weekday - avgScore - count additionalProperties: false description: Per-weekday means; an unlogged weekday carries a null mean. timeOfDay: type: object properties: buckets: type: array items: type: object properties: bucket: type: string enum: - morning - afternoon - evening - night avgScore: type: - number - "null" count: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - bucket - avgScore - count additionalProperties: false reliable: type: boolean description: False for the once-a-day logger (everything in one bucket) or a sparse history. Do not chart or narrate the pattern when this is false. best: anyOf: - type: string enum: - morning - afternoon - evening - night - type: "null" description: Null unless `reliable`. worst: anyOf: - type: string enum: - morning - afternoon - evening - night - type: "null" description: Null unless `reliable`. required: - buckets - reliable - best - worst additionalProperties: false description: Average pleasantness per part of day, bucketed in each entry's OWN timezone. All four buckets are always listed, in canonical order. stability: anyOf: - type: object properties: score: type: number description: 0..100; higher means steadier day-to-day. stdDev: type: number description: Population SD of the daily means. band: type: string enum: - verySteady - steady - variable - veryVariable days: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - score - stdDev - band - days additionalProperties: false - type: "null" description: Day-to-day steadiness over the trailing `stabilityWindowDays` days. Null below the day floor (seven days with an entry) inside that window, so a short window can be null where the year is not. stabilityWindowDays: anyOf: - type: number const: 30 - type: number const: 90 - type: number const: 180 - type: number const: 365 description: "The window `stability` covers: the `days` query parameter, or 365 (the whole daily series) when it was not sent. Added in v1.42.0; absent from older servers, whose score always covers the year." tags: type: array items: type: object properties: tag: type: string count: type: integer minimum: -9007199254740991 maximum: 9007199254740991 avgScore: type: number required: - tag - count - avgScore additionalProperties: false description: Flat free-text tag counts with their mean mood. contextComparison: type: array items: type: object properties: field: type: string description: Context field, e.g. `workStatus`. value: type: string description: The value inside that field. withDays: type: integer minimum: -9007199254740991 maximum: 9007199254740991 withoutDays: type: integer minimum: -9007199254740991 maximum: 9007199254740991 withAvg: type: number withoutAvg: type: number delta: type: number description: "`withAvg − withoutAvg`. Signed, and never an effect." pValue: type: number qValue: type: number description: Benjamini-Hochberg adjusted p-value. required: - field - value - withDays - withoutDays - withAvg - withoutAvg - delta - pValue - qValue additionalProperties: false description: How the day's pleasantness sat on days carrying each context value against the days that did not. Every row carries the two counts it was computed from so a reader can weigh it. structuredTags: type: array items: type: object properties: key: type: string categoryKey: type: string labelKey: type: string label: description: Decrypted custom-tag label; absent for catalogue tags. type: - string - "null" icon: type: - string - "null" count: type: integer minimum: -9007199254740991 maximum: 9007199254740991 avgScore: type: number required: - key - categoryKey - labelKey - icon - count - avgScore additionalProperties: false description: Taxonomy-tag counts with their mean mood. tagInfluence: type: object properties: flat: type: array items: $ref: "#/components/schemas/MoodTagInfluenceRow" structured: type: array items: $ref: "#/components/schemas/MoodTagInfluenceRow" required: - flat - structured additionalProperties: false description: With-versus-without daily-mean delta plus a Welch confidence band per frequent tag, ranked by |delta| descending. betterDays: type: array items: type: object properties: source: type: string enum: - tag - metric key: type: string description: Tag key, or the correlation channel key for a metric. labelKey: type: - string - "null" label: type: - string - "null" categoryKey: type: - string - "null" icon: type: - string - "null" direction: type: string enum: - up - down description: Associated with higher / lower mood. n: type: integer minimum: -9007199254740991 maximum: 9007199254740991 confidence: $ref: "#/components/schemas/MoodInfluenceConfidence" effectSize: type: number description: Unified 0..1 ranking strength (|r| for a metric, min(1, |delta| / 2) for a tag). Ranking only — surface `delta` or `r`, not this. delta: description: Raw mood-point delta for a tag row; null for a metric row. type: - number - "null" r: description: Raw Pearson r for a metric row; null for a tag row. type: - number - "null" required: - source - key - labelKey - categoryKey - icon - direction - n - confidence - effectSize - delta - r additionalProperties: false description: The effect-size-ranked, confidence-gated board folding the tag deltas and the mood x metric correlations into one observational list. tagMetricCrosstab: type: array items: type: object properties: tag: type: string labelKey: type: string description: Structured tags only — this board never carries a flat tag. label: type: - string - "null" categoryKey: type: string icon: type: - string - "null" metricKey: type: string description: Metric channel. Currently `activeEnergy`, `sleepDuration`, `nextDayRecovery`, `nextDayRestingHeartRate` or `nextDayHeartRateVariability`; the registry is a plain map, so treat this as an open token set rather than a closed enum. display: type: string description: Display-unit hint for the client formatter (`hours` / `kcal` / `score` / `bpm` / `ms`). The values are ALREADY in that unit. mode: type: string enum: - sameDay - nextDay description: Pairing lag the row was computed at. `nextDay` is the D → D+1 join used for overnight-recovery channels; caption it, because a next-day row says something different from a same-day one. withDays: type: integer minimum: -9007199254740991 maximum: 9007199254740991 withoutDays: type: integer minimum: -9007199254740991 maximum: 9007199254740991 withAvg: type: number withoutAvg: type: number delta: type: number description: "`withAvg − withoutAvg`, display unit." pValue: type: number qValue: type: number confidence: $ref: "#/components/schemas/MoodInfluenceConfidence" patternId: description: Persisted pattern id; absent when the row is not persisted. type: string canonicalKey: type: string dismissed: description: Whether the user dismissed the persisted pattern. type: boolean required: - tag - labelKey - categoryKey - icon - metricKey - display - mode - withDays - withoutDays - withAvg - withoutAvg - delta - pValue - qValue - confidence additionalProperties: false description: "Per structured tag x health metric: the metric's mean on tag-present versus tag-absent days, Welch-tested and BH-corrected. Association, never cause." factorCrosstab: type: array items: type: object properties: factor: type: string description: Stable RATED-factor key. labelKey: type: string categoryKey: type: string icon: type: - string - "null" inverse: type: boolean description: True for an inverse-scaled factor (stress, conflict). The split already runs on the flipped series, so a `low` row always means a worse day; the flag only changes the phrasing. metricKey: type: string description: Metric channel. Currently `sleepDuration`, `steps`, `restingHeartRate`, `heartRateVariability`, `weight` or `bloodPressureSystolic`; an open token set, as above. display: type: string description: Display-unit hint (`hours` / `score` / `steps` / `bpm` / `ms` / `kg` / `mmHg`). mode: type: string enum: - sameDay - nextDay description: Pairing lag the row was computed at. `nextDay` is the D → D+1 join used for overnight-recovery channels; caption it, because a next-day row says something different from a same-day one. lowDays: type: integer minimum: -9007199254740991 maximum: 9007199254740991 highDays: type: integer minimum: -9007199254740991 maximum: 9007199254740991 lowAvg: type: number highAvg: type: number delta: type: number description: "`lowAvg − highAvg`, display unit. Negative means the vital runs lower on low-factor days." pValue: type: number qValue: type: number confidence: $ref: "#/components/schemas/MoodInfluenceConfidence" patternId: type: string canonicalKey: type: string dismissed: type: boolean required: - factor - labelKey - categoryKey - icon - inverse - metricKey - display - mode - lowDays - highDays - lowAvg - highAvg - delta - pValue - qValue - confidence additionalProperties: false description: "The cross-domain bridge: per rated factor x health metric, the vital's mean on the days the factor was rated LOW versus HIGH (median split), Welch-tested and BH-corrected. Association, never cause." narratives: type: array items: type: object properties: kind: type: string enum: - weekday-dip - weekday-peak - trend - weekend - tag-lift - tag-drop - in-target - streak - time-of-day messageKey: type: string description: i18n template key the client resolves. vars: type: object propertyNames: type: string additionalProperties: type: string description: Interpolation values, all stringified so they survive transport unchanged. strength: type: number description: Normalised ranking weight; higher is stronger. required: - kind - messageKey - vars - strength additionalProperties: false description: Ranked, threshold-gated takeaways — the read-this-first layer above the charts. The same array feeds the LLM snapshot so prose and feed cannot drift. correlations: type: object propertyNames: type: string additionalProperties: type: object properties: result: anyOf: - type: object properties: r: type: number strength: type: string enum: - stark - moderat - schwach - keine description: Strength bucket. The tokens are GERMAN on the wire — a legacy of where the helper was written; treat them as opaque identifiers and localise client-side rather than displaying them. n: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - r - strength - n additionalProperties: false - type: "null" description: Null when the pairing produced no testable series. points: type: array items: type: object properties: x: type: number y: type: number required: - x - y additionalProperties: false description: Mood (x) versus metric (y), paired by day. n: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Paired-day count. suppressed: description: "Why `result` is null: too few paired days, or a pairing that did not reach significance. Absent when `result` is set." type: string enum: - insufficientPairs - notSignificant required: - result - points - n additionalProperties: false description: "Mood against five health channels, keyed `sleep`, `steps`, `pulse`, `weight`, `bloodPressureSystolic`. Every key is always present; an empty channel carries `result: null` with no points." patterns: description: v1.43 — the patterns that passed the gate over the `?days` window, on the account's mood day. Empty means none was found. type: array items: $ref: "#/components/schemas/MoodPattern" required: - summary - heatmap - distribution - dimensions - weekday - timeOfDay - stability - stabilityWindowDays - tags - contextComparison - structuredTags - tagInfluence - betterDays - tagMetricCrosstab - factorCrosstab - narratives - correlations additionalProperties: false description: "The pre-computed Mood Insights bundle: headline summary, heatmap, distribution, per-dimension summaries, weekday and time-of-day patterns, stability, tag boards, context and crosstab comparisons, narratives and cross-metric correlations. Everything is observational — comparisons and correlations, never a cause and never a verdict. Served through a 60 s stale-while-revalidate cache; a mood write marks the cell so the account's own entry lands on the next read." MoodDimensionSummary: type: object properties: key: type: string enum: - a1 - a2 - a3 - a4 - a5 description: Level-A dimension key. present: type: boolean description: False when nobody answered this dimension in the window. The series is then empty and the surface says so rather than drawing a line through the middle of the scale. inverse: type: boolean description: True when higher is worse for this dimension. The analytics layer flips through this flag rather than inline, so a client must read it before comparing dimensions. min: type: number description: Scale floor for the dimension. max: type: number description: Scale ceiling for the dimension. count: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Entries carrying a value. avg7: type: - number - "null" avg30: type: - number - "null" avg90: description: Trailing-window means; null when that window is empty. type: - number - "null" latest: type: - number - "null" latestDate: description: "`YYYY-MM-DD` of `latest`." type: - string - "null" newestDaysAgo: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" series: type: array items: type: object properties: date: type: string value: type: number samples: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - date - value - samples additionalProperties: false description: Daily means, oldest first, over the longest window. required: - key - present - inverse - min - max - count - avg7 - avg30 - avg90 - latest - latestDate - newestDaysAgo - series additionalProperties: false MoodTagInfluenceRow: type: object properties: tag: type: string description: Stable tag key. For a flat tag this is the free text itself. labelKey: description: i18n label key for a structured tag; null for a flat one. type: - string - "null" label: description: Decrypted custom-tag label. Absent for catalogue and flat tags — the key is absent, not null. type: - string - "null" categoryKey: type: - string - "null" icon: type: - string - "null" withDays: type: integer minimum: -9007199254740991 maximum: 9007199254740991 withoutDays: type: integer minimum: -9007199254740991 maximum: 9007199254740991 withAvg: type: number description: Mean of daily means on tagged days. withoutAvg: type: number description: Mean of daily means on the other days (the counterfactual). delta: type: number description: "`withAvg − withoutAvg`, rounded. Signed, never causal." pooledSd: description: Pooled mood SD across the two groups (the Cohen's-d denominator); null when neither group has testable spread. Ranking input only — not rendered. type: - number - "null" pValue: type: number description: Welch two-sided p-value. confidence: $ref: "#/components/schemas/MoodInfluenceConfidence" required: - tag - labelKey - categoryKey - icon - withDays - withoutDays - withAvg - withoutAvg - delta - pooledSd - pValue - confidence additionalProperties: false MoodInfluenceConfidence: type: string enum: - low - medium - high MoodPattern: type: object properties: id: type: string description: "Stable: `previous_day`, `weekend`, `best_weekday`, `notes`, `multiple_entries` or `delayed_tag:`." kind: type: string enum: - previous_day - weekend - best_weekday - notes - delayed_tag - multiple_entries strength: type: string enum: - weak - moderate - strong direction: type: string enum: - higher - lower - none values: type: object properties: subject: type: number comparison: type: number delta: type: number weekday: type: integer minimum: 0 maximum: 6 tagKey: type: string tagLabel: type: string required: - subject - comparison - delta additionalProperties: false sampleSize: type: object properties: subject: type: integer minimum: -9007199254740991 maximum: 9007199254740991 comparison: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - subject - comparison additionalProperties: false required: - id - kind - strength - direction - values - sampleSize additionalProperties: false description: A mood pattern that passed the server's gate (at least 8 days on each side, Welch test, false discovery control). Skip an unknown `kind`. MoodPrognosisEnvelope: type: object properties: data: $ref: "#/components/schemas/MoodPrognosis" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MoodPrognosis: anyOf: - type: object properties: present: type: boolean const: true date: type: string description: The local day this is about, `YYYY-MM-DD`. predicted: type: number description: The expected pleasantness (A1) for that day, 0-10, on the account's own past pattern. A counterfactual, never a measurement — and never to be displayed without `n` and the band. ciLow: type: - number - "null" ciHigh: description: The band, from the validation residuals. It widens when the model fits badly, so a wide band is the signal to present the value cautiously. type: - number - "null" n: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: "Complete days the fit used. Display it WHEREVER `predicted` is displayed: a value built from 30 days and one built from 300 look identical otherwise." modelVersion: type: string computedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ ageDays: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" description: Whole days between that day and the reader's today. current: type: boolean description: Whether it is fresh enough to back a present-tense sentence. False means the copy must name the day it is about. provisional: type: boolean description: "True below 60 days of history: label it as provisional." stage: type: string enum: - provisional - regular - seasonal entries: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Days of history behind the ladder. seasonalUnlocked: type: boolean selfAssessment: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" description: "The person's own rating for that day, stored exactly as they gave it. It LEADS: it is the primary value on any surface that shows both, and it is never merged with `predicted`." deviation: anyOf: - type: string enum: - within - above - below - type: "null" description: Where the self-assessment sat against the BAND, not against `predicted`. `within` means the day went as the account's own past days suggest. deviationAmount: description: How far outside the band, in scale points. `0` when inside. type: - number - "null" contributions: type: array items: $ref: "#/components/schemas/MoodPrognosisContribution" required: - present - date - predicted - ciLow - ciHigh - n - modelVersion - computedAt - ageDays - current - provisional - stage - entries - seasonalUnlocked - selfAssessment - deviation - deviationAmount - contributions additionalProperties: false - type: object properties: present: type: boolean const: false reason: type: string enum: - no-output-yet - learning-phase - no-pattern description: "`no-output-yet`: too few days for anything. `learning-phase`: enough to describe, not to compare. `no-pattern`: enough days, nothing steady enough in them. None of the three is an error and none is a zero." entries: type: integer minimum: -9007199254740991 maximum: 9007199254740991 nextThreshold: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" description: Days at which the next step unlocks, when a count is missing. required: - present - reason - entries - nextThreshold additionalProperties: false description: "Two separate readings of one day: what the person recorded, and what a comparison with their own past days would have expected. They are never merged — the distance between them is the finding, and a single blended figure destroys it. The comparison is a regularised regression over the account's own history: observational only, never causal. Any copy built on it must state the value as a counterfactual (a value around X would have been expected), must carry `n`, and must not say that anything caused anything." MoodPrognosisContribution: type: object properties: feature: type: string description: "Stable feature identifier: `dimension:a2`, `context:workLoad`, `context:workStatus=off` or `linked:steps`. A client that does not recognise one skips it — an older row can name a feature a later model dropped." contribution: type: number description: "Signed: how far this feature moved the expected value away from the account's own average day. Ordered by absolute size, largest first." required: - feature - contribution additionalProperties: false BulkMoodResponseEnvelope: type: object properties: data: $ref: "#/components/schemas/BulkMoodResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false BulkMoodResponse: type: object properties: processed: type: integer minimum: 0 maximum: 9007199254740991 inserted: type: integer minimum: 0 maximum: 9007199254740991 duplicates: type: integer minimum: 0 maximum: 9007199254740991 skipped: type: array items: type: object properties: index: type: integer minimum: 0 maximum: 9007199254740991 reason: type: string required: - index - reason additionalProperties: false entries: type: array items: $ref: "#/components/schemas/BulkMoodEntryResult" required: - processed - inserted - duplicates - skipped - entries additionalProperties: false BulkMoodEntryResult: type: object properties: index: type: integer minimum: 0 maximum: 9007199254740991 status: type: string enum: - inserted - duplicate - skipped description: "`inserted`/`duplicate` — the row landed (advance the cursor). `skipped` — see `reason`." reason: type: string id: description: The upserted row id (absent on skips). type: string externalId: description: Echoed back when the entry carried one, for client mapping. type: string droppedTagKeys: description: "`tagKeys` from this request that were not stored: unknown to the catalog, archived, another account's custom tag, or a rated factor sent as a binary key. Present only when non-empty; absent means every submitted key landed. The entry itself was written." type: array items: type: string droppedFactorKeys: description: "`ratedFactors` keys from this request that were not stored: unknown, archived, or not a rated factor. Present only when non-empty; absent means every submitted factor landed." type: array items: type: string required: - index - status additionalProperties: false MoodTagTreeEnvelope: type: object properties: data: type: object properties: categories: type: array items: $ref: "#/components/schemas/MoodTagCategoryDTO" required: - categories additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MoodTagCategoryDTO: type: object properties: key: type: string labelKey: type: - string - "null" label: type: - string - "null" icon: type: - string - "null" custom: type: boolean tags: type: array items: $ref: "#/components/schemas/MoodTagDTO" required: - key - labelKey - label - icon - custom - tags additionalProperties: false description: "One group of the effective tree, already per-user ordered (layout applied server-side). `custom: true` = the caller's own group (render `label`); seeded groups resolve `labelKey`." MoodTagDTO: type: object properties: key: type: string labelKey: type: - string - "null" label: type: - string - "null" icon: type: - string - "null" kind: type: string enum: - BINARY - RATED scaleMin: type: integer minimum: -9007199254740991 maximum: 9007199254740991 scaleMax: type: integer minimum: -9007199254740991 maximum: 9007199254740991 inverse: type: boolean custom: type: boolean hidden: description: Catalogue tags only, present when `include` contains `hidden`. type: boolean archived: description: Custom tags only, present when `include` contains `archived`. type: boolean usageCount: description: Live-entry link count for this user, present when `include` contains `usage`. type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - key - labelKey - label - icon - kind - scaleMin - scaleMax - inverse - custom additionalProperties: false description: One effective mood tag. Render `label` when `custom`, else resolve `labelKey` against the locale. MoodCustomTagCreatedEnvelope: type: object properties: data: type: object properties: key: type: string labelKey: type: "null" label: type: - string - "null" icon: type: - string - "null" kind: type: string enum: - BINARY - RATED scaleMin: type: integer minimum: -9007199254740991 maximum: 9007199254740991 scaleMax: type: integer minimum: -9007199254740991 maximum: 9007199254740991 inverse: type: boolean custom: type: boolean const: true required: - key - labelKey - label - icon - kind - scaleMin - scaleMax - inverse - custom additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MoodCustomTagPatchEnvelope: type: object properties: data: type: object properties: key: type: string labelKey: type: "null" label: type: - string - "null" icon: type: - string - "null" kind: type: string enum: - BINARY - RATED scaleMin: type: integer minimum: -9007199254740991 maximum: 9007199254740991 scaleMax: type: integer minimum: -9007199254740991 maximum: 9007199254740991 inverse: type: boolean custom: type: boolean const: true isActive: type: boolean required: - key - labelKey - label - icon - kind - scaleMin - scaleMax - inverse - custom - isActive additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MoodCustomTagDeleteEnvelope: type: object properties: data: type: object properties: key: type: string purged: type: boolean required: - key - purged additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MoodTagHiddenEnvelope: type: object properties: data: type: object properties: key: type: string hidden: type: boolean required: - key - hidden additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MoodTagGroupCreatedEnvelope: type: object properties: data: type: object properties: key: type: string labelKey: type: "null" label: type: - string - "null" icon: type: - string - "null" custom: type: boolean const: true required: - key - labelKey - label - icon - custom additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MoodTagGroupPatchEnvelope: type: object properties: data: type: object properties: key: type: string labelKey: type: "null" label: type: - string - "null" icon: type: - string - "null" custom: type: boolean const: true isActive: type: boolean required: - key - labelKey - label - icon - custom - isActive additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MoodTagGroupDeleteEnvelope: type: object properties: data: type: object properties: key: type: string purged: type: boolean rehomedCount: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - key - purged - rehomedCount additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MoodTagLayoutGetEnvelope: type: object properties: data: $ref: "#/components/schemas/MoodTagLayoutResolved" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MoodTagLayoutResolved: type: object properties: groupOrder: type: array items: type: string placements: type: object propertyNames: type: string additionalProperties: type: array items: type: string updatedAt: description: "Optimistic-concurrency token: the stored row's `updatedAt` at read/write time. Echo it back as `baseUpdatedAt` on the next write. Opaque — only ever echo a server-returned value, never parse or synthesise it." type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - groupOrder - placements additionalProperties: false description: "The stored layout merged over defaults: `groupOrder` fully resolved against the user's effective category set, `placements` as stored. `updatedAt` is the optimistic-concurrency token — echo it as `baseUpdatedAt` on the next PUT." MoodTagLayoutPutEnvelope: type: object properties: data: $ref: "#/components/schemas/MoodTagLayoutResolved" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MoodEntryListEnvelope: type: object properties: data: type: object properties: entries: type: array items: $ref: "#/components/schemas/MoodEntryDTO" meta: type: object properties: total: type: integer minimum: 0 maximum: 9007199254740991 limit: type: integer minimum: -9007199254740991 maximum: 9007199254740991 offset: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - total - limit - offset additionalProperties: false required: - entries - meta additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MoodEntryDTO: type: object properties: id: type: string userId: type: string date: type: string description: "YYYY-MM-DD, anchored to the row's `tz` (legacy rows with `tz: null` read as Europe/Berlin)." mood: type: string enum: - SUPER_GUT - GUT - OKAY - SCHLECHT - LAUSIG score: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: The legacy 1-5 axis, server-derived from `mood`. a1: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" description: Pleasantness, 0-10. Server-derived from `mood` on every write when the request carried none, so it is populated even for rows a client posted without it. Consume the value; do not re-derive it. a2: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" description: "Stress, 0-10, INVERSE-oriented: a higher value is more stress. NULL when nobody answered it." a3: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" description: Energy, 0-10, or null. a4: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" description: Connectedness, 0-10, or null. a5: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" description: Stability, 0-10, or null. tags: type: array items: type: string description: Free-text tag list, decoded from storage; `[]` when none. note: description: Free-text note, decrypted for the response. AES-256-GCM at rest; the ciphertext never rides the wire. type: - string - "null" source: type: string enum: - MANUAL - MOODLOG - WEB - TELEGRAM - DAYLIO - NOTIFICATION externalId: description: The sync client's source-stable dedup id, or null. type: - string - "null" moodLoggedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ tz: description: IANA zone the `date` label is anchored to. Null on legacy rows (read as Europe/Berlin). type: - string - "null" syncedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ createdAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ updatedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ syncVersion: type: integer minimum: 0 maximum: 9007199254740991 description: LWW reconciliation counter; mood is last-writer-wins by it. deletedAt: type: "null" description: "Always null on this surface: tombstoned rows are hidden from the list and 404 on a direct GET. Tombstones surface on the `/api/sync/changes` feed instead." tagKeys: type: array items: type: string description: Binary structured-tag keys linked to the entry. ratedFactors: type: array items: $ref: "#/components/schemas/MoodEntryRatedFactor" context: anyOf: - $ref: "#/components/schemas/MoodContextOutput" - type: "null" description: The day's context, or null when the entry carries none. Null and an object of nulls are different answers. droppedTagKeys: description: "Create and edit responses only: `tagKeys` from the request that are not on the entry (unknown, archived, another account's custom tag, or a rated factor sent as a binary key). Present only when non-empty. An archived tag the edit preserved is on the entry and is not listed." type: array items: type: string droppedFactorKeys: description: "Create and edit responses only: `ratedFactors` keys from the request that are not on the entry. Present only when non-empty." type: array items: type: string required: - id - userId - date - mood - score - a1 - a2 - a3 - a4 - a5 - tags - note - source - externalId - moodLoggedAt - tz - syncedAt - createdAt - updatedAt - syncVersion - deletedAt - tagKeys - ratedFactors - context additionalProperties: false description: One mood entry as every entry surface answers it — list rows, single GET, create and edit responses share this shape, so a client hydrating from a write response renders without a refetch. MoodEntryRatedFactor: type: object properties: key: type: string rating: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - key - rating additionalProperties: false description: One rated factor on an entry, with its per-entry score. MoodEntryEnvelope: type: object properties: data: $ref: "#/components/schemas/MoodEntryDTO" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MoodEntryDeleteEnvelope: type: object properties: data: type: object properties: deleted: type: boolean const: true required: - deleted additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MoodEntriesRestoreEnvelope: type: object properties: data: type: object properties: restored: type: integer minimum: 0 maximum: 9007199254740991 required: - restored additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MoodEntriesBulkDeleteEnvelope: type: object properties: data: type: object properties: deleted: type: integer minimum: 0 maximum: 9007199254740991 required: - deleted additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MoodLinkedContextEnvelope: type: object properties: data: $ref: "#/components/schemas/MoodLinkedContext" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MoodLinkedContext: type: object properties: day: type: string description: The local day the figures are about, YYYY-MM-DD. sleep: anyOf: - type: object properties: available: type: boolean const: false reason: type: string const: module-disabled required: - available - reason additionalProperties: false - type: object properties: available: type: boolean const: true asleep: description: Time asleep (minutes) for the night the day woke up on. $ref: "#/components/schemas/MoodLinkedFigure" inBed: description: Time in bed (minutes), when any writer recorded a bed window. $ref: "#/components/schemas/MoodLinkedFigure" required: - available - asleep - inBed additionalProperties: false activity: anyOf: - type: object properties: available: type: boolean const: false reason: type: string const: module-disabled required: - available - reason additionalProperties: false - type: object properties: available: type: boolean const: true steps: $ref: "#/components/schemas/MoodLinkedFigure" activeEnergy: $ref: "#/components/schemas/MoodLinkedFigure" required: - available - steps - activeEnergy additionalProperties: false vitals: anyOf: - type: object properties: available: type: boolean const: false reason: type: string const: module-disabled required: - available - reason additionalProperties: false - type: object properties: available: type: boolean const: true restingHeartRate: $ref: "#/components/schemas/MoodLinkedFigure" heartRateVariability: $ref: "#/components/schemas/MoodLinkedFigure" required: - available - restingHeartRate - heartRateVariability additionalProperties: false body: anyOf: - type: object properties: available: type: boolean const: false reason: type: string const: module-disabled required: - available - reason additionalProperties: false - type: object properties: available: type: boolean const: true logged: type: boolean description: Whether an illness day-log exists for this day at all. functionalImpact: description: How much the day was limited, 0-3, as the illness module records it. $ref: "#/components/schemas/MoodLinkedFigure" symptoms: type: array items: type: string description: Symptoms linked to the day-log, by catalogue key. episodeId: description: The episode to open in the illness module, when any. type: - string - "null" required: - available - logged - functionalImpact - symptoms - episodeId additionalProperties: false required: - day - sleep - activity - vitals - body additionalProperties: false description: "What the modules that own sleep, activity, vitals and body symptoms already know about one local day. Nothing here is stored on the mood row — every figure is resolved from its owning module at read time, cross-source de-duplicated to one canonical source per metric. A switched-off module blanks its whole block (`available: false`)." MoodLinkedFigure: anyOf: - type: object properties: present: type: boolean const: false required: - present additionalProperties: false - type: object properties: present: type: boolean const: true value: type: number unit: type: string required: - present - value - unit additionalProperties: false description: "A figure that either exists or honestly does not. `{ present: false }` means the data is not recorded — never zero, never an error." GetNtfySettings: type: object properties: data: $ref: "#/components/schemas/NtfySettingsResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false NtfySettingsResponse: type: object properties: enabled: type: boolean serverUrl: type: string description: Defaults to `https://ntfy.sh` when no channel row exists. topic: type: string description: Empty string when unset. hasAuthToken: type: boolean description: Whether an auth token is stored. The token itself is never returned. required: - enabled - serverUrl - topic - hasAuthToken additionalProperties: false description: Stored ntfy channel config, with the auth token reduced to a presence flag. PutNtfySettingsResponse: type: object properties: data: type: object properties: saved: type: boolean const: true required: - saved additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false GetTelegramSettings: type: object properties: data: $ref: "#/components/schemas/TelegramSettingsResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false TelegramSettingsResponse: type: object properties: enabled: type: boolean hasBotToken: type: boolean description: Whether a bot token is stored. The token itself is never returned. chatId: description: The stored chat id, returned in full. It is an addressing value, not a credential — the bot token is what authorises a send. type: - string - "null" required: - enabled - hasBotToken - chatId additionalProperties: false description: Stored Telegram channel config, with the bot token reduced to a presence flag. PutTelegramSettingsResponse: type: object properties: data: type: object properties: updated: type: boolean const: true required: - updated additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false GetWebhookSettings: type: object properties: data: $ref: "#/components/schemas/WebhookSettingsResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false WebhookSettingsResponse: type: object properties: enabled: type: boolean url: type: string description: Empty string when no channel row exists. headerName: type: string description: Empty string when unset. The header NAME is not a secret. hasHeaderValue: type: boolean description: Whether a header value (the shared secret) is stored. The value itself is never returned. format: type: string enum: - generic - gotify description: Body shape the webhook sends. `generic` when no channel row exists or the config predates the choice. required: - enabled - url - headerName - hasHeaderValue - format additionalProperties: false description: Stored generic-webhook channel config, with the secret header value reduced to a presence flag. PutWebhookSettingsResponse: type: object properties: data: type: object properties: saved: type: boolean const: true required: - saved additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false GetReminderThresholdsResponse: type: object properties: data: $ref: "#/components/schemas/ReminderThresholdsResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false ReminderThresholdsResponse: type: object properties: lateMinutes: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Minutes after the dose anchor at which an open dose tiers 'late' (operator-level singleton; default 120). missedMinutes: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Minutes after the dose anchor at which an open dose tiers 'missed' (operator-level singleton; default 240). lowStockRunwayDays: anyOf: - type: integer minimum: 1 maximum: 60 - type: "null" description: Per-user low-stock alert threshold as remaining runway days (1–60). null = the alert is off. Default 7. Written via PATCH /api/auth/me/notification-prefs. reorderLeadDays: type: integer minimum: 0 maximum: 60 description: v1.17.0 — per-user reorder lead default in days (0–60, default 10). The low-stock alert fires this lead plus one dose-interval before the supply runs out so a refill arrives before the last dose; a per-medication reorderLeadDays overrides it. Written via PATCH /api/auth/me/notification-prefs. required: - lateMinutes - missedMinutes - lowStockRunwayDays - reorderLeadDays additionalProperties: false description: "Medication reminder thresholds: the operator-level late/missed minute marks plus the calling user's low-stock runway threshold (v1.16.11)." GlobalServiceAvailabilityEnvelope: type: object properties: data: $ref: "#/components/schemas/GlobalServiceAvailability" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false GlobalServiceAvailability: type: object properties: telegramGlobal: type: boolean description: Telegram notifications are offered. ntfyGlobal: type: boolean description: ntfy notifications are offered. webPushGlobal: type: boolean description: Web Push is offered. apiGlobal: type: boolean description: The API-token surface is on. When false, `/api/tokens` and its revoke sibling answer 403 — the read included. required: - telegramGlobal - ntfyGlobal - webPushGlobal - apiGlobal additionalProperties: false description: Which optional services the operator has left switched on instance-wide. Every flag defaults to true, and a failed settings read returns all-true rather than an error, so a client must not treat this as proof a channel is configured — only that the operator has not switched it off. DeleteAccountEnvelope: type: object properties: data: type: object properties: deleted: type: boolean scheduledPurgeAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" required: - deleted - scheduledPurgeAt additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false GetEmailChannelEnvelope: type: object properties: data: $ref: "#/components/schemas/EmailChannelResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false EmailChannelResponse: type: object properties: enabled: type: boolean recipient: type: string description: The saved recipient address, or an empty string when none. smtpConfigured: type: boolean description: Whether the OPERATOR has configured an SMTP transport. False means no email can be sent from this instance whatever the user saves, so a client should hide the card rather than offer a control that cannot work. required: - enabled - recipient - smtpConfigured additionalProperties: false description: The per-user email channel. Only the recipient is per-user — host, port, credentials and sender come from the operator's environment and never enter a user's encrypted channel blob. PutEmailChannelEnvelope: type: object properties: data: type: object properties: saved: type: boolean const: true required: - saved additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false EmailTestEnvelope: type: object properties: data: $ref: "#/components/schemas/ChannelTestResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false ChannelTestResponse: type: object properties: sent: type: boolean const: true required: - sent additionalProperties: false description: The provider accepted the test message. Acceptance is not delivery — a message can still be dropped downstream. NtfyTestEnvelope: type: object properties: data: $ref: "#/components/schemas/ChannelTestResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false TelegramTestEnvelope: type: object properties: data: $ref: "#/components/schemas/ChannelTestResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false WebhookTestEnvelope: type: object properties: data: $ref: "#/components/schemas/ChannelTestResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DataWipeEnvelope: type: object properties: data: $ref: "#/components/schemas/DataWipeResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DataWipeResponse: type: object properties: cleared: type: boolean const: true deletedRows: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Total rows removed. models: type: object propertyNames: type: string additionalProperties: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Per-model row counts, listing only the models that actually had rows. A model absent from this map had nothing to delete — it was not skipped. restorableUntil: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" description: "Until when an admin can restore the record from the account copy taken immediately before the wipe; the off-host copies from before the wipe are kept until then too. Null when the instance has no deletion grace window (`ACCOUNT_DELETION_GRACE_DAYS=0`): the wipe was final." required: - cleared - deletedRows - models - restorableUntil additionalProperties: false ModuleListLayoutEnvelope: type: object properties: data: $ref: "#/components/schemas/ModuleListLayout" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false ModuleListLayout: type: object properties: version: type: number const: 1 lists: type: object properties: labs: $ref: "#/components/schemas/ModuleListPrefs" illness: $ref: "#/components/schemas/ModuleListPrefs" vorsorge: $ref: "#/components/schemas/ModuleListPrefs" required: - labs - illness - vorsorge additionalProperties: false required: - version - lists additionalProperties: false description: Per-user presentation of the labs, illness and checkups lists. Display-only; a client that does not render a list ignores it. ModuleListPrefs: type: object properties: view: type: string enum: - cards - list description: '`cards` renders the card grid, `list` the compact list. Default "list" for labs, "cards" otherwise.' order: type: array items: type: string description: Manual order as an id allow-list. Ids missing from the live data are ignored; items missing from the list follow in the server order. sortDir: type: string enum: - recentDesc - recentAsc - alphaAsc - alphaDesc - manual description: Sort of the labs list; `manual` follows `order`. Default "recentDesc". The other lists ignore it. saved: type: boolean description: True when the server holds a choice for this list; false means every field is a default. required: - view - order - sortDir - saved additionalProperties: false ModuleListLayoutSavedEnvelope: type: object properties: data: $ref: "#/components/schemas/ModuleListLayout" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false PrivacySummaryEnvelope: type: object properties: data: $ref: "#/components/schemas/PrivacySummaryResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false PrivacySummaryResponse: type: object properties: retention: type: object properties: coachMessagesDays: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: How long Coach conversation history is kept. Operator-configurable, floor 30, default 365. auditLogDays: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: How long audit history is kept. Read from the same resolver the purge job uses, so the number a person is shown is the number that is enforced. deliveryLogDays: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Fixed 90-day window on the push-attempt and mood-dispatch ledgers. accountDeletionGraceDays: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Days between asking to delete the account (or a managed profile, or every entry) and the erasure completing. The account is locked at once; until the window ends an admin can undo the deletion, or restore the copy a data wipe took first. 0 means the erasure completes immediately. Read from the same resolver the purge job uses. required: - coachMessagesDays - auditLogDays - deliveryLogDays - accountDeletionGraceDays additionalProperties: false description: Retention windows as the privacy dashboard states them. Off-host backup retention is operator infrastructure rather than an app setting and is deliberately absent — it is disclosed as prose in the UI, not as a number here. encryption: type: object properties: algorithm: type: string const: AES-256-GCM columnCount: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: How many columns are encrypted at rest. modelCount: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: How many models carry at least one of them. required: - algorithm - columnCount - modelCount additionalProperties: false description: Counted from the canonical encrypted-column registry rather than hand-maintained, so the disclosure cannot drift from what is actually encrypted. required: - retention - encryption additionalProperties: false description: Server-authoritative facts for the Data & Privacy dashboard. No key material, no secrets, no per-row data — the same numbers for every account on the instance. ConsentReceiptResponseEnvelope: type: object properties: data: $ref: "#/components/schemas/ConsentReceiptResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false ConsentReceiptResponse: type: object properties: id: type: string receipt: type: object properties: id: type: string userId: type: string kind: type: string enum: - ai_full - ai_insights_only - ai_coach - ai_extraction signedAt: type: string revokedAt: type: - string - "null" createdAt: type: string required: - id - userId - kind - signedAt - revokedAt - createdAt additionalProperties: false required: - id - receipt additionalProperties: false description: The minted receipt. `artefact` is deliberately stripped from the response — it is opaque and only read at audit time directly from the DB. ConsentValidationErrorEnvelope: type: object properties: data: type: "null" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false WebConsentGrantResponseEnvelope: type: object properties: data: $ref: "#/components/schemas/WebConsentGrantResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false WebConsentGrantResponse: type: object properties: minted: type: boolean description: True when a new `ai_full` receipt was minted; false when an active one already existed (a double tap is a no-op). kind: type: string const: ai_full required: - minted - kind additionalProperties: false description: Outcome of the explicit web `ai_full` grant. ConsentLatestForKindEnvelope: type: object properties: data: $ref: "#/components/schemas/ConsentLatestForKind" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false ConsentLatestForKind: type: object properties: kind: type: string enum: - ai_full - ai_insights_only - ai_coach - ai_extraction receipt: anyOf: - $ref: "#/components/schemas/SerialisedConsentReceipt" - type: "null" description: Null when no active receipt of that kind exists. required: - kind - receipt additionalProperties: false SerialisedConsentReceipt: type: object properties: id: type: string userId: type: string kind: type: string enum: - ai_full - ai_insights_only - ai_coach - ai_extraction signedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ revokedAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" createdAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - id - userId - kind - signedAt - revokedAt - createdAt additionalProperties: false description: A consent receipt as the API returns it. `artefact` is stripped — it is opaque, large, and only read at audit time straight from the database. ConsentLatestByKindEnvelope: type: object properties: data: $ref: "#/components/schemas/ConsentLatestByKind" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false ConsentLatestByKind: type: object properties: ai_full: anyOf: - $ref: "#/components/schemas/SerialisedConsentReceipt" - type: "null" ai_insights_only: anyOf: - $ref: "#/components/schemas/SerialisedConsentReceipt" - type: "null" ai_coach: anyOf: - $ref: "#/components/schemas/SerialisedConsentReceipt" - type: "null" ai_extraction: anyOf: - $ref: "#/components/schemas/SerialisedConsentReceipt" - type: "null" required: - ai_full - ai_insights_only - ai_coach - ai_extraction additionalProperties: false description: "The latest active receipt for every kind. Every key is always present: a null value means the kind is not granted, so a client never has to read absence as ambiguity." ConsentRevokeForKindEnvelope: type: object properties: data: $ref: "#/components/schemas/ConsentLatestForKind" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false ConsentRevokeAllEnvelope: type: object properties: data: $ref: "#/components/schemas/ConsentRevokeAllResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false ConsentRevokeAllResponse: type: object properties: revoked: type: array items: type: object properties: kind: type: string enum: - ai_full - ai_insights_only - ai_coach - ai_extraction receipt: $ref: "#/components/schemas/SerialisedConsentReceipt" required: - kind - receipt additionalProperties: false description: One entry per kind that HAD an active receipt. A kind already revoked contributes nothing, so an empty array means there was nothing left to revoke. required: - revoked additionalProperties: false HealthConnectImportKickoffEnvelope: type: object properties: data: type: object properties: jobId: type: string status: type: string enum: - queued - unpacking - parsing - upserting - done idempotent: type: boolean required: - jobId - status additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false HealthConnectImportStatusEnvelope: type: object properties: data: type: object properties: job: anyOf: - $ref: "#/components/schemas/HealthConnectImportJob" - type: "null" required: - job additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false HealthConnectImportJob: type: object properties: jobId: type: string status: type: string description: "`queued`, `unpacking`, `parsing`, `upserting`, `done` or `failed`. A plain string, like the Apple Health status." startedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ completedAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" uploadBytes: type: integer minimum: -9007199254740991 maximum: 9007199254740991 progress: type: object propertyNames: type: string additionalProperties: {} description: Free-form phase progress. Shape is not part of the contract. result: anyOf: - type: object propertyNames: type: string additionalProperties: {} - type: "null" description: "The worker's outcome summary, null until it finishes. Counts only: `perType` (read / inserted / updated / unchanged / skipped per measurement type), `perApp` (records per Android package, `leftOut` for an app whose integration the account has connected), `workouts`, `sleep`, `cycle`, `nutrients`, `skipped` (reason → count), `warnings` (codes for tables or columns the importer could not read), `totals`. Never a value, a timestamp or text from the file." failureReason: description: Why the job failed. Starts with a code the client can key on when the file was refused — `unsupported_version` (database older than version 9), `not_health_connect` (no `health_connect_export.db`, or not a Health Connect database), `unsafe_schema` (a record table is a view), `too_large`, `staging_missing` — else free text. type: - string - "null" required: - jobId - status - startedAt - completedAt - uploadBytes - progress - result - failureReason additionalProperties: false CsvImportResponseEnvelope: type: object properties: data: $ref: "#/components/schemas/CsvImportResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false CsvImportResponse: type: object properties: inserted: type: integer minimum: -9007199254740991 maximum: 9007199254740991 updated: type: integer minimum: -9007199254740991 maximum: 9007199254740991 skipped: type: integer minimum: -9007199254740991 maximum: 9007199254740991 total: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Total data rows parsed (excludes header). dryRun: type: boolean rows: type: array items: $ref: "#/components/schemas/CsvImportRowResult" required: - inserted - updated - skipped - total - dryRun - rows additionalProperties: false description: Per-row import outcome. Mirrors the batch-route per-entry envelope. In `dryRun` mode no write runs and every valid row reports its projected `inserted` status. CsvImportRowResult: type: object properties: line: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: 1-based source line number (the header is line 1). status: type: string enum: - inserted - updated - skipped reason: description: Machine-readable reason for a non-inserted row (e.g. unknown_type, unknown_unit, missing_timezone_offset, value_out_of_range, implausible_timestamp, duplicate). type: string required: - line - status additionalProperties: false CsvImportTooLargeEnvelope: type: object properties: data: type: "null" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false LabsCsvImportEnvelope: type: object properties: data: $ref: "#/components/schemas/RecordImportResult" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false RecordImportResult: type: object properties: kind: type: string enum: - labs - medications dryRun: type: boolean rows: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Data rows read (the header excluded). valid: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Rows that passed every check. inserted: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Rows written; in a dry run, the rows that would be. duplicates: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Rows skipped because the record already holds them, or an earlier row of the file does. A re-upload of the same file therefore writes nothing. failed: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Rows refused; each is listed in `errors`. errors: type: array items: type: object properties: line: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: 1-based line of the file. reason: type: string enum: - missing_value - invalid_number - ambiguous_number - missing_unit - invalid_date - invalid_time - ambiguous_date - future_date - text_too_long - unit_mismatch - invalid_reference_range - invalid_times - missing_times - invalid_yes_no - as_needed_with_times - end_before_start - invalid_medication - oversized_row - row_limit - duplicate field: description: The mapped field at fault. type: string required: - line - reason additionalProperties: false description: Refused and duplicate rows, in file order, at most 500. errorsTruncated: type: boolean markers: description: "Lab imports only: how the file's names meet the markers." type: object properties: matched: type: array items: type: object properties: name: type: string description: The name as the file writes it. marker: type: string description: The marker it resolves to. how: type: string enum: - name - alias - catalog required: - name - marker - how additionalProperties: false description: File names that resolve to an existing marker, at most 200. created: type: array items: type: string description: Names that become new markers, at most 200. required: - matched - created additionalProperties: false preview: type: array items: type: object propertyNames: type: string additionalProperties: type: - string - number - boolean - "null" description: The first 20 valid rows as they will be stored (dry run only). required: - kind - dryRun - rows - valid - inserted - duplicates - failed - errors - errorsTruncated - preview additionalProperties: false MedicationsCsvImportEnvelope: type: object properties: data: $ref: "#/components/schemas/RecordImportResult" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false JsonImportStatsEnvelope: type: object properties: data: $ref: "#/components/schemas/JsonImportStats" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false JsonImportStats: type: object properties: measurements: type: integer minimum: 0 maximum: 9007199254740991 description: Measurement rows written (inserted or updated in place). moodEntries: type: integer minimum: 0 maximum: 9007199254740991 description: Mood-entry rows written (inserted or updated in place). skipped: type: integer minimum: 0 maximum: 9007199254740991 description: "Rows not written: duplicates of existing rows, rows carrying an unstable `externalId`, mood entries with a factor rating outside its own scale, and a backup's deletion records." droppedTagKeys: description: Tag keys of imported mood entries this account cannot resolve (another account's custom tag, a retired key). The entries landed without them. Present only when non-empty. type: array items: type: string droppedFactorKeys: description: Rated-factor keys this account cannot resolve, as `droppedTagKeys`. type: array items: type: string keptMoodTagKeys: description: Keys of the account's own tags the file names differently. The account's tag is kept as it is. Present only when non-empty. type: array items: type: string keptMoodCategoryKeys: description: The same, for the account's own tag groups. type: array items: type: string required: - measurements - moodEntries - skipped additionalProperties: false description: Per-section outcome counts of a JSON import. AppleHealthImportKickoffEnvelope: type: object properties: data: type: object properties: jobId: type: string status: type: string enum: - queued - unpacking - parsing - upserting - done description: "`queued` on a fresh enqueue. On a content match it is whatever the matched job is doing now — never `failed`, because a failed job is not matched." idempotent: description: "`true` only when these exact bytes matched a still-viable job and nothing was queued. ABSENT (not `false`) on a fresh enqueue." type: boolean required: - jobId - status additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false AppleHealthImportStatusEnvelope: type: object properties: data: type: object properties: jobId: type: string status: type: string description: "`queued`, `unpacking`, `parsing`, `upserting`, `done` or `failed`. A plain string on the wire, not a closed enum — the column is untyped and a future phase name would arrive here without a schema change." startedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ completedAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" uploadBytes: type: integer minimum: -9007199254740991 maximum: 9007199254740991 exportedAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" description: The instant the Health app stamped on the export itself, once the worker has read it. Null before then. progress: type: object propertyNames: type: string additionalProperties: {} description: Free-form phase progress written by the worker. `{}` before the first phase reports. Shape is not part of the contract. result: anyOf: - type: object propertyNames: type: string additionalProperties: {} - type: "null" description: The worker's outcome summary, null until it finishes. Free-form except for `ecg`, whose five counters (`discovered` / `imported` / `updated` / `skipped` / `failed`) are normalised to bounded non-negative integers before serving. failureReason: description: Free text on `failed`; null otherwise. type: - string - "null" required: - jobId - status - startedAt - completedAt - uploadBytes - exportedAt - progress - result - failureReason additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MeasurementReminderListEnvelope: type: object properties: data: type: array items: $ref: "#/components/schemas/MeasurementReminderDTO" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MeasurementReminderDTO: type: object properties: id: type: string label: type: string measurementType: anyOf: - $ref: "#/components/schemas/MeasurementReminderType" - type: "null" intervalDays: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" rrule: type: - string - "null" anchorDate: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" endsOn: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" origin: type: string enum: - VORSORGE - COACH notifyHour: type: integer minimum: -9007199254740991 maximum: 9007199254740991 location: type: - string - "null" nextDueAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" lastSatisfiedAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" snoozedUntil: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" lastSkippedAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" skipCount: type: integer minimum: -9007199254740991 maximum: 9007199254740991 enabled: type: boolean createdAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ updatedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - id - label - measurementType - intervalDays - rrule - anchorDate - endsOn - origin - notifyHour - location - nextDueAt - lastSatisfiedAt - snoozedUntil - lastSkippedAt - skipCount - enabled - createdAt - updatedAt additionalProperties: false description: "A Vorsorge reminder. nextDueAt is server-computed (server-authoritative). A free-text reminder carries measurementType=null and resolves only on a manual satisfy. origin distinguishes a user-created (VORSORGE) reminder from one minted by a Coach cadence suggestion (COACH); endsOn bounds a finite course window (null = open-ended). snoozedUntil/lastSkippedAt/skipCount are resolved values: snoozed means snoozedUntil > now, a skipped cycle means lastSkippedAt > lastSatisfiedAt; clients compare against the clock and never recompute cadence. lastSatisfiedAt stays the only completed signal — skip and snooze never move it." MeasurementReminderCreatedEnvelope: type: object properties: data: $ref: "#/components/schemas/MeasurementReminderDTO" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MeasurementReminderEnvelope: type: object properties: data: $ref: "#/components/schemas/MeasurementReminderDTO" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MeasurementReminderPatchEnvelope: type: object properties: data: $ref: "#/components/schemas/MeasurementReminderDTO" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MeasurementReminderDeleteEnvelope: type: object properties: data: type: object properties: deleted: type: boolean required: - deleted additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MeasurementReminderSatisfyEnvelope: type: object properties: data: $ref: "#/components/schemas/MeasurementReminderDTO" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MeasurementReminderCompleteEnvelope: type: object properties: data: $ref: "#/components/schemas/MeasurementReminderCompletion" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MeasurementReminderCompletion: type: object properties: completed: type: boolean reminder: $ref: "#/components/schemas/MeasurementReminderDTO" required: - completed - reminder additionalProperties: false description: Result of an explicit reminder completion. completed=true when this call advanced lastSatisfiedAt; completed=false when an earlier satisfy or a matching reading already fulfilled the current cycle (idempotent no-op). reminder is the canonical post-completion DTO. MeasurementReminderSkipEnvelope: type: object properties: data: $ref: "#/components/schemas/MeasurementReminderSkip" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MeasurementReminderSkip: type: object properties: skipped: type: boolean reminder: $ref: "#/components/schemas/MeasurementReminderDTO" required: - skipped - reminder additionalProperties: false description: Result of skipping the current due cycle. skipped=true when this call recorded the skip and restarted the interval from the skip instant; skipped=false when a concurrent skip or satisfy already advanced the row (idempotent no-op). A skip never touches lastSatisfiedAt — it is recorded honestly as a skip, not a completion. MeasurementReminderSnoozeEnvelope: type: object properties: data: type: object properties: reminder: $ref: "#/components/schemas/MeasurementReminderDTO" required: - reminder additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MeasurementReminderHistoryEnvelope: type: object properties: data: $ref: "#/components/schemas/MeasurementReminderHistory" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MeasurementReminderHistory: type: object properties: events: type: array items: $ref: "#/components/schemas/MeasurementReminderEvent" meta: type: object properties: total: type: integer minimum: -9007199254740991 maximum: 9007199254740991 limit: type: integer minimum: -9007199254740991 maximum: 9007199254740991 offset: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - total - limit - offset additionalProperties: false required: - events - meta additionalProperties: false description: Paginated completion ledger for one reminder, newest first. MeasurementReminderEvent: type: object properties: id: type: string kind: type: string enum: - SATISFIED - SKIPPED occurredAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ onTime: type: boolean source: type: string enum: - manual - auto_measurement - auto_lab - telegram - vaccination - encounter - skip createdAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - id - kind - occurredAt - onTime - source - createdAt additionalProperties: false description: "One completion-ledger row: an honest fulfilment (SATISFIED) or an honest skip (SKIPPED). onTime is derived server-side at write time against the due instant that was current when the event landed — clients never re-derive it. History begins at the release that introduced the ledger." ListLabResultsEnvelope: type: object properties: data: $ref: "#/components/schemas/ListLabResultsResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false ListLabResultsResponse: type: object properties: results: type: array items: $ref: "#/components/schemas/LabResult" meta: type: object properties: total: type: number limit: type: number offset: type: number required: - total - limit - offset additionalProperties: false required: - results - meta additionalProperties: false LabResult: type: object properties: id: type: string biomarkerId: type: - string - "null" panel: type: - string - "null" analyte: type: string value: type: - number - "null" valueText: type: - string - "null" unit: type: string converted: type: boolean sourceValue: type: - number - "null" sourceUnit: type: - string - "null" referenceLow: type: - number - "null" referenceHigh: type: - number - "null" catalogReferenceLow: type: - number - "null" catalogReferenceHigh: type: - number - "null" sourceReferenceLow: type: - number - "null" sourceReferenceHigh: type: - number - "null" sourceReferenceText: type: - string - "null" printedReferenceText: type: - string - "null" referenceOrigin: $ref: "#/components/schemas/LabReferenceRangeOrigin" referenceDivergesFromCatalog: type: boolean takenAt: type: string source: type: string hasNote: type: boolean rangeStatus: $ref: "#/components/schemas/LabReferenceRangeStatus" createdAt: type: string updatedAt: type: string required: - id - biomarkerId - panel - analyte - value - valueText - unit - converted - sourceValue - sourceUnit - referenceLow - referenceHigh - catalogReferenceLow - catalogReferenceHigh - sourceReferenceLow - sourceReferenceHigh - sourceReferenceText - printedReferenceText - referenceOrigin - referenceDivergesFromCatalog - takenAt - source - hasNote - rangeStatus - createdAt - updatedAt additionalProperties: false description: >- A stored lab result. Exactly one of `value` (numeric) or `valueText` (qualitative, e.g. "negativ") is non-null; `rangeStatus` is always `unknown` for a qualitative row (no number to classify). `biomarkerId` links the user-scoped catalog marker (null for legacy free-text rows); when linked, `analyte` / `unit` are RESOLVED server-side from the Biomarker. Three reference windows ride on the row and the server says which one applies. `referenceLow` / `referenceHigh` are the window IN FORCE and the one `rangeStatus` was computed from — read these to render the range and you are correct without doing any resolution of your own. `sourceReferenceLow` / `sourceReferenceHigh` / `sourceReferenceText` are the range this reading's own report printed (the text verbatim, kept even when no numbers could be read out of it). `catalogReferenceLow` / `catalogReferenceHigh` are the Biomarker band. `referenceOrigin` names the winner and `referenceDivergesFromCatalog` is true when the report's window and the catalog band state different limits — a surface showing only one of the two is showing a partial answer. The client renders these values and never recomputes the range. `converted` is true when the stored `value` was converted into the marker's `unit` from another unit; `sourceValue` and `sourceUnit` are then what the report printed (full precision, round for display), and the report's own window was converted with it: `sourceReferenceLow` / `sourceReferenceHigh` are then in the marker's `unit`. The printed range text is not converted and stays in `sourceUnit`, so for a converted reading `sourceReferenceText` is null and the verbatim text is in `printedReferenceText` instead, never mixed with the converted numbers. A reading stored as stated has `converted: false`, two nulls and a null `printedReferenceText`. The encrypted note is never echoed in list rows; `hasNote` flags its presence and the single-resource GET returns the decrypted `note`. LabReferenceRangeOrigin: type: string enum: - source - catalog - none description: Which window `referenceLow` / `referenceHigh` came from. `source` = the range printed on this reading's own report, which wins for that reading because it is the window the physician evaluated the value against. `catalog` = the user's Biomarker band, the net for readings with no printed range. `none` = no window on file. LabReferenceRangeStatus: type: string enum: - in-range - below - above - unknown description: "Server-computed, NEUTRAL reference-range verdict, always against `referenceLow` / `referenceHigh` (the window in force for that reading). `unknown` when no usable bounds are on file. Inclusive bounds: a value on the limit reads in-range. The badge that renders this must stay calm and informative — not an alarming red." CreateLabResultResponse: type: object properties: data: $ref: "#/components/schemas/LabResult" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false LabOverviewEnvelope: type: object properties: data: $ref: "#/components/schemas/LabOverviewResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false LabOverviewResponse: type: object properties: markers: type: array items: $ref: "#/components/schemas/LabOverviewMarker" required: - markers additionalProperties: false LabOverviewMarker: type: object properties: key: type: string count: type: integer minimum: -9007199254740991 maximum: 9007199254740991 trend: type: array items: type: - number - "null" latest: $ref: "#/components/schemas/LabResult" required: - key - count - trend - latest additionalProperties: false description: One biomarker in the caller's lab history. `key` is `bm:` for a catalog-linked marker or `analyte:` for a legacy unlinked one. `count` is every live reading of the marker. `trend` holds the values of its newest readings (at most 50), oldest first, null for a qualitative reading. `latest` is the newest reading, serialised exactly like a `GET /api/labs` row. GetLabResultResponse: type: object properties: data: allOf: - $ref: "#/components/schemas/LabResultDetail" - type: object properties: related: description: v1.43 — records related to this one, resolved on the server. Absent on older servers. type: array items: $ref: "#/components/schemas/RelatedLink" additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false LabResultDetail: type: object properties: id: type: string biomarkerId: type: - string - "null" panel: type: - string - "null" analyte: type: string value: type: - number - "null" valueText: type: - string - "null" unit: type: string converted: type: boolean sourceValue: type: - number - "null" sourceUnit: type: - string - "null" referenceLow: type: - number - "null" referenceHigh: type: - number - "null" catalogReferenceLow: type: - number - "null" catalogReferenceHigh: type: - number - "null" sourceReferenceLow: type: - number - "null" sourceReferenceHigh: type: - number - "null" sourceReferenceText: type: - string - "null" printedReferenceText: type: - string - "null" referenceOrigin: $ref: "#/components/schemas/LabReferenceRangeOrigin" referenceDivergesFromCatalog: type: boolean takenAt: type: string source: type: string rangeStatus: $ref: "#/components/schemas/LabReferenceRangeStatus" createdAt: type: string updatedAt: type: string note: type: - string - "null" required: - id - biomarkerId - panel - analyte - value - valueText - unit - converted - sourceValue - sourceUnit - referenceLow - referenceHigh - catalogReferenceLow - catalogReferenceHigh - sourceReferenceLow - sourceReferenceHigh - sourceReferenceText - printedReferenceText - referenceOrigin - referenceDivergesFromCatalog - takenAt - source - rangeStatus - createdAt - updatedAt - note additionalProperties: false description: Single lab result including its decrypted free-text `note` (or null). UpdateLabResultResponse: type: object properties: data: $ref: "#/components/schemas/LabResult" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DeleteLabResultResponse: type: object properties: data: type: object properties: deleted: type: boolean required: - deleted additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false RestoreLabResultsResponse: type: object properties: data: type: object properties: restored: type: number required: - restored additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false OcrCapabilityEnvelope: type: object properties: data: $ref: "#/components/schemas/OcrCapabilityResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false OcrCapabilityResponse: type: object properties: available: type: boolean mode: anyOf: - type: string enum: - vision - text - type: "null" reason: anyOf: - type: string enum: - no-provider - enable-local-ocr - type: "null" pdfSupported: type: boolean ai: $ref: "#/components/schemas/AiCapabilityState" required: - available - mode - reason - pdfSupported - ai additionalProperties: false description: "Whether the caller's configured AI provider can ingest a lab report (drives the UI's scan affordance). `mode` is `vision` when the provider reads the image directly, `text` when the image is OCR'd in-browser and only the extracted text is sent (opt-in local OCR for text-only providers), or null when unavailable. `reason` explains an unavailable state; `pdfSupported` is true when the provider reads PDFs natively (Anthropic) or the server-side rasterizer can render the pages for any other vision provider. `ai` is the `labsOcr` capability for the record: when the operator turned reading documents off, the labs module is off, or the record is somebody else's, `available` is false with a null `reason` and `ai.reason` says why. A missing consent receipt leaves the scan offered (`ai.reason = \"consent_required\"`), because the scan is where the person is asked for it. No provider call is made." AiRunAcceptedEnvelope: type: object properties: data: $ref: "#/components/schemas/AiRunAccepted" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false AiRunAccepted: type: object properties: runId: type: string status: type: string const: QUEUED pollAfterMs: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: How long to wait before the first poll. required: - runId - status - pollAfterMs additionalProperties: false description: The read was queued. Poll `GET /api/ai-runs/{runId}`. A second request for the same document while a read of it is still queued or running answers with that run instead of starting another. OcrCommitEnvelope: type: object properties: data: $ref: "#/components/schemas/OcrCommitResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false OcrCommitResponse: type: object properties: inserted: type: array items: $ref: "#/components/schemas/OcrCommittedLabResult" skipped: type: array items: type: object properties: analyte: type: string reason: type: string enum: - duplicate - unit_mismatch - unknown_biomarker description: "`duplicate`: the row matched a live reading at commit time. `unit_mismatch`: the row states a unit other than the one its marker is tracked in and the pair cannot be converted, so it was not written. A pair that can be converted is written in the marker's unit and reported with `converted: true`. `unknown_biomarker` (v1.43): the row named a `biomarkerId` that is not one of the record's markers." required: - analyte - reason additionalProperties: false outcome: type: string enum: - empty - failed - partial - success description: How the commit reads, resolved from the counts rather than the status code. `success` only when every confirmed row was written; `partial` when some were skipped; `failed` when nothing was written and at least one row was skipped; `empty` when there was nothing to write. required: - inserted - skipped - outcome additionalProperties: false description: 'The rows written (`source: "OCR"`), those skipped (commit-time duplicates, or a unit that differs from that of the marker), and the resolved outcome.' OcrCommittedLabResult: type: object properties: id: type: string biomarkerId: type: - string - "null" panel: type: - string - "null" analyte: type: string value: type: - number - "null" valueText: type: - string - "null" unit: type: string converted: type: boolean sourceValue: type: - number - "null" sourceUnit: type: - string - "null" referenceLow: type: - number - "null" referenceHigh: type: - number - "null" catalogReferenceLow: type: - number - "null" catalogReferenceHigh: type: - number - "null" sourceReferenceLow: type: - number - "null" sourceReferenceHigh: type: - number - "null" sourceReferenceText: type: - string - "null" printedReferenceText: type: - string - "null" referenceOrigin: type: string enum: - source - catalog - none referenceDivergesFromCatalog: type: boolean takenAt: type: string source: type: string hasNote: type: boolean rangeStatus: type: string enum: - in-range - below - above - unknown createdAt: type: string updatedAt: type: string required: - id - biomarkerId - panel - analyte - value - valueText - unit - converted - sourceValue - sourceUnit - referenceLow - referenceHigh - catalogReferenceLow - catalogReferenceHigh - sourceReferenceLow - sourceReferenceHigh - sourceReferenceText - printedReferenceText - referenceOrigin - referenceDivergesFromCatalog - takenAt - source - hasNote - rangeStatus - createdAt - updatedAt additionalProperties: false description: A written reading. Its reference window resolves exactly as it does on `/api/labs` — see `LabResult`. `converted`, `sourceValue` and `sourceUnit` say when the row's unit was converted into its marker's and what the report printed; for a converted row the printed range text is in `printedReferenceText` (in `sourceUnit`) and `sourceReferenceText` is null. AiRunEnvelope: type: object properties: data: $ref: "#/components/schemas/AiRun" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false AiRun: type: object properties: id: type: string kind: type: string enum: - DOCUMENT_INDEX - LABS_OCR_EXTRACT - DOCUMENT_SUMMARY - DOCUMENT_SUGGEST - DOCUMENT_EXTRACT - SUPPLEMENT_LABEL_EXTRACT documentId: description: The document a document run reads; null for a lab scan. type: - string - "null" status: type: string enum: - QUEUED - RUNNING - SUCCEEDED - FAILED result: anyOf: - anyOf: - $ref: "#/components/schemas/DocumentIndexResponse" - $ref: "#/components/schemas/OcrExtractResponse" - $ref: "#/components/schemas/DocumentSummaryResponse" - $ref: "#/components/schemas/DocumentSuggestResponse" - $ref: "#/components/schemas/DocumentExtractRunResult" - $ref: "#/components/schemas/SupplementLabelExtractResult" - type: "null" description: "Present once `status` is `SUCCEEDED`: the body the queuing route answers with synchronously. `DOCUMENT_INDEX` carries `DocumentIndexResponse`, `LABS_OCR_EXTRACT` carries `OcrExtractResponse`, `DOCUMENT_SUMMARY` carries `DocumentSummaryResponse`, `DOCUMENT_SUGGEST` carries `DocumentSuggestResponse`, `DOCUMENT_EXTRACT` carries `DocumentExtractRunResult` (not the extract route's detail body), `SUPPLEMENT_LABEL_EXTRACT` (v1.43) carries `SupplementLabelExtractResult`. Decode by `kind`; treat an unknown `kind` as a run this client did not start." error: anyOf: - type: object properties: code: type: string message: type: string status: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - code - message - status additionalProperties: false - type: "null" description: "Present once `status` is `FAILED`: the `meta.errorCode`, message and HTTP status the queuing route would have answered with, or one of the run's own codes: `aiRuns.workerUnavailable` (no worker took the run within fifteen minutes), `aiRuns.timedOut` (the read outlived the calls the AI response time allows), `aiRuns.readerBusy` (the server's document reader was busy past the wait a read gets, or stopped before it answered; nothing was read or charged, try again in a minute), `aiRuns.failed` (anything else)." createdAt: type: string startedAt: type: - string - "null" finishedAt: type: - string - "null" queuedForMs: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" description: While queued, how long the run has waited for a worker. A long wait usually means the background worker is not running. retryAfterMs: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" description: While not terminal, when to poll again (also sent as `Retry-After`, in seconds). Null once the run has ended. required: - id - kind - documentId - status - result - error - createdAt - startedAt - finishedAt - queuedForMs - retryAfterMs additionalProperties: false description: One background document AI run. Results and failures stay readable for an hour after the run ends; the run is then deleted and answers 404. DocumentIndexResponse: type: object properties: documentId: type: string indexed: type: boolean tokenCount: type: number labFactsStaged: type: number description: How many lab facts the index run auto-staged for review (0 when the document is not a lab report, staging was not eligible, or facts already exist). Staged facts are PENDING — the confirm route remains the only write into Labs. required: - documentId - indexed - tokenCount - labFactsStaged additionalProperties: false OcrExtractResponse: type: object properties: reportDate: type: - string - "null" providerType: type: string rows: type: array items: $ref: "#/components/schemas/OcrExtractedRow" pageCoverage: description: "Present only when a PDF ran past the pages a scan reads (20): the rows come from the first `read` of `total` pages. Absent when the whole document was read." type: object properties: read: type: integer exclusiveMinimum: 0 maximum: 9007199254740991 total: type: integer exclusiveMinimum: 0 maximum: 9007199254740991 required: - read - total additionalProperties: false truncated: description: Present only when a read stopped at the provider's output limit. The rows that read produced before the cut are kept; readings after the cut may be missing. `pages` names the pages whose read was cut, or is empty when the report went to the provider as one read (a photo, a one-page PDF, OCR'd text). type: object properties: pages: type: array items: type: integer exclusiveMinimum: 0 maximum: 9007199254740991 required: - pages additionalProperties: false unreadPages: description: "Present only when part of a multi-part read produced nothing: the provider failed partway through, or a part's reply could not be read. The rows of the other parts are kept; these pages are not in `rows`. Empty when the parts were not pages (OCR'd text)." type: array items: type: integer exclusiveMinimum: 0 maximum: 9007199254740991 stoppedBy: description: "Present only when the scan stopped before its last read for a reason other than the provider: `budget`, the day's AI budget ran out (every read after the first is reserved on its own); `unavailable`, AI was turned off for the record while it ran (the wire is re-checked before every read); `error`, that check or the reservation failed itself, so the scan stopped rather than read on unchecked. The pages it did not reach are in `unreadPages`." type: string enum: - budget - unavailable - error rowsCapped: description: "Present only when the report held more readings than one scan carries (400): `rows` holds the first `kept` of the `found` readings." type: object properties: kept: type: integer exclusiveMinimum: 0 maximum: 9007199254740991 found: type: integer exclusiveMinimum: 0 maximum: 9007199254740991 required: - kept - found additionalProperties: false required: - reportDate - providerType - rows additionalProperties: false description: "The proposed rows for the human review screen, served by `GET /api/ai-runs/{id}` for a `LABS_OCR_EXTRACT` run. A PDF of more than one page is read a page at a time, each rendered page sent with its text layer as a hint the image overrules, and the pages merged in order, a reading an earlier page already listed listed once. Never written to the person's record; the commit route is the only write. A run in which no read produced a single complete row fails with 422 `labs.ocr.outputLimit` when the reads stopped at the provider's output limit, else `labs.ocr.extractFailed`; OCR'd text too long for the reads the deadline allows is refused up front with 422 `labs.ocr.tooLarge`. A scan stopped before any read produced a row answers with the stop: 429 `labs.ocr.budgetExceeded` when the day's budget refused a further read; the wire's own refusal (its status and code, e.g. 403 `consent.ai.required`, `assistant.disabled.*`, `module.disabled`) when AI was turned off for the record; 500 `aiRuns.failed` when the check before a read failed. A scan stopped after some rows succeeds with them and names the stop in `stoppedBy` (`budget`, `unavailable`, `error`) and the pages it did not reach in `unreadPages`." OcrExtractedRow: type: object properties: analyte: type: string value: type: - number - "null" valueText: type: - string - "null" unit: type: - string - "null" referenceLow: type: - number - "null" referenceHigh: type: - number - "null" referenceText: description: The reference range EXACTLY as printed on the report, verbatim ("3,5 - 5,0", "< 116", "bis 5,0", "negativ"). `referenceLow` / `referenceHigh` carry the same window reduced to numbers WHEN it reduces to numbers; this field carries it either way, so a window stated in words is not lost between the report and the reading. type: - string - "null" takenAt: type: - string - "null" confidence: type: object properties: analyte: type: number value: type: number unit: type: number range: type: number required: - analyte - value - unit - range additionalProperties: false biomarkerMatch: type: string enum: - new - existing markerUnit: description: "The unit the matched marker is tracked in, or null when the marker does not exist yet (it will adopt this row's unit). A row whose `unit` differs from it is converted on commit when the marker's analyte is known and the pair is in the unit-conversion registry; otherwise the commit skips it with `reason: unit_mismatch`, and the review screen marks it before Save." type: - string - "null" markerAnalyteKey: description: The analyte the matched marker is (the catalog slug, e.g. `fasting-glucose` or `ldl`, or a registry-only key such as `urea`), or null when the marker does not exist yet or its name matches none. With `markerUnit` it tells a client whether a row in another unit can be converted on commit. type: - string - "null" duplicateOf: type: - string - "null" biomarkerId: description: v1.43 — the marker this row was matched to automatically (by name, by an alias of the account, or by a catalogue abbreviation); null without a match. type: - string - "null" matchKind: description: v1.43 — how `biomarkerId` was found. anyOf: - type: string enum: - name - alias - catalog - type: "null" suggestions: description: v1.43 — up to three markers to choose from when nothing matched; empty when `biomarkerId` is set. maxItems: 3 type: array items: type: object properties: biomarkerId: type: string name: type: string unit: type: string matchKind: type: string enum: - catalog - similar required: - biomarkerId - name - unit - matchKind additionalProperties: false required: - analyte - value - valueText - unit - referenceLow - referenceHigh - referenceText - takenAt - confidence - biomarkerMatch - markerUnit - markerAnalyteKey - duplicateOf additionalProperties: false description: "One proposed reading transcribed from the upload. UNTRUSTED model output annotated server-side: `biomarkerMatch` flags whether the analyte links an existing catalog marker; `duplicateOf` is the id of a live reading this row likely duplicates (the review row defaults to unchecked when set); `confidence` is the model's per-field self-score (low fields are flagged for the human). Nothing is written until the commit route confirms." DocumentSummaryResponse: type: object properties: summary: type: string text: type: string persistence: type: string enum: - stored - withheld - failed additionalProperties: false DocumentSuggestResponse: type: object properties: suggestions: type: object properties: title: type: - string - "null" kind: anyOf: - type: string enum: - DOCTOR_REPORT - DISCHARGE_LETTER - LAB_RESULT - IMAGING - PRESCRIPTION - REFERRAL - INSURANCE - VACCINATION - SICK_NOTE - OTHER - type: "null" documentDate: type: - string - "null" required: - title - kind - documentDate additionalProperties: false required: - suggestions additionalProperties: false DocumentExtractRunResult: type: object properties: documentId: type: string factsStaged: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: How many facts were staged PENDING for review. Read them from `GET /api/documents/inbound/{id}`; the confirm route stays the only write into the record. status: type: string enum: - STORED - EXTRACTING - EXTRACTED - FAILED - CONFIRMED - DISCARDED incomplete: description: "Present when part of the document's facts may be missing from the staged ones: a long document is read a page or a part at a time, and a read stopped at the provider's output limit (its complete facts kept), a part could not be read or was not reached (the day's budget, the run's deadline, AI turned off meanwhile), or the document holds more facts than one review takes. The staged facts are still worth reviewing; the person should check them against the document." type: boolean const: true required: - documentId - factsStaged - status additionalProperties: false SupplementLabelExtractResult: type: object properties: ingredients: type: array items: type: object properties: nutrient: type: string amount: type: number unit: type: string confidence: type: string enum: - high - low printedText: type: string required: - nutrient - amount - unit - confidence - printedText additionalProperties: false unmatched: type: array items: type: object properties: printedText: type: string required: - printedText additionalProperties: false servingNote: description: e.g. "per 2 capsules"; shown, never stored. type: - string - "null" required: - ingredients - unmatched - servingNote additionalProperties: false description: "The `result` of an AI run of kind `SUPPLEMENT_LABEL_EXTRACT`: a proposal only. Nothing is stored until the composition is saved through `PUT /api/medications/{id}/ingredients`." ListBiomarkersEnvelope: type: object properties: data: $ref: "#/components/schemas/ListBiomarkersResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false ListBiomarkersResponse: type: object properties: biomarkers: type: array items: $ref: "#/components/schemas/Biomarker" required: - biomarkers additionalProperties: false Biomarker: type: object properties: id: type: string name: type: string unit: type: string lowerBound: type: - number - "null" upperBound: type: - number - "null" panel: type: - string - "null" analyteKey: type: - string - "null" hasContext: type: boolean context: type: - string - "null" hidden: type: boolean aliases: description: v1.43 — other spellings that resolve to this marker (folded in by a merge, chosen at an import, or typed on the marker), sorted, without the marker's own name. Absent on older servers. type: array items: type: string createdAt: type: string updatedAt: type: string required: - id - name - unit - lowerBound - upperBound - panel - analyteKey - hasContext - context - hidden - createdAt - updatedAt additionalProperties: false description: "A user-scoped catalog marker. `context` is the decrypted per-marker note (or null); `hasContext` flags its presence. `analyteKey` is the analyte the marker was recognised as: the catalog slug its name belongs to (e.g. `fasting-glucose`, `ldl`, `hdl`), or a key of the unit-conversion registry for an analyte with no catalog seed (e.g. `urea`, `uric-acid`), or null when its name matches none. It is derived from `name` and `unit`, never chosen: it is set on create and re-derived (or cleared) when an update changes either. A reading in another unit is converted only for a marker that has one. `hidden` drops the marker from the active catalog list and the lab-entry pickers without deleting it (its readings and unit/range survive). A `LabResult` linking this marker resolves its unit + reference bounds from here." CreateBiomarkerResponse: type: object properties: data: $ref: "#/components/schemas/Biomarker" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false GetBiomarkerResponse: type: object properties: data: $ref: "#/components/schemas/BiomarkerDetail" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false BiomarkerDetail: type: object properties: id: type: string name: type: string unit: type: string lowerBound: type: - number - "null" upperBound: type: - number - "null" panel: type: - string - "null" analyteKey: type: - string - "null" hasContext: type: boolean context: type: - string - "null" hidden: type: boolean aliases: description: v1.43 — other spellings that resolve to this marker (folded in by a merge, chosen at an import, or typed on the marker), sorted, without the marker's own name. Absent on older servers. type: array items: type: string createdAt: type: string updatedAt: type: string trend: description: v1.44 — absent on older servers. $ref: "#/components/schemas/BiomarkerTrend" required: - id - name - unit - lowerBound - upperBound - panel - analyteKey - hasContext - context - hidden - createdAt - updatedAt additionalProperties: false BiomarkerTrend: anyOf: - type: object properties: status: type: string const: ok n: type: integer minimum: -9007199254740991 maximum: 9007199254740991 firstAt: type: string description: YYYY-MM-DD of the first reading. lastAt: type: string description: YYYY-MM-DD of the newest reading. spanDays: type: integer minimum: -9007199254740991 maximum: 9007199254740991 slopePerYear: type: number description: Least-squares change per year, in the marker's unit. slopeCi95: type: array prefixItems: - type: number - type: number items: false minItems: 2 maxItems: 2 description: 95 % interval of the slope (Student t at n − 2 df). pValue: type: number fittedNow: type: number description: The line's value on `asOf`. asOf: type: string description: "YYYY-MM-DD the line was evaluated on: today, or `lastAt` when `projection` is `stale`." horizonYears: type: number description: "How far ahead a bound may be met and still count as `exits`: three years, or the readings' span when shorter." horizonCapped: type: boolean description: True when `horizonYears` is the three-year cap, false when the readings' shorter span set it. projection: type: string enum: - exits - beyond_horizon - no_exit - already_outside - no_clear_trend - no_band - stale exitDate: description: "Only for `exits`: the day the straight line meets `exitBound`. A projection, never a forecast." type: - string - "null" exitBound: anyOf: - type: string enum: - low - high - type: "null" required: - status - n - firstAt - lastAt - spanDays - slopePerYear - slopeCi95 - pValue - fittedNow - asOf - horizonYears - horizonCapped - projection - exitDate - exitBound additionalProperties: false - type: object properties: status: type: string const: insufficient reason: type: string enum: - too_few_values - span_too_short n: type: integer minimum: -9007199254740991 maximum: 9007199254740991 spanDays: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - status - reason - n - spanDays additionalProperties: false description: "v1.44 — the least-squares line through every numeric reading of the marker (all stored in the marker's unit). Needs at least three readings spanning at least 365 days, else `insufficient`. `projection` places the line against the marker's band: `exits` (with `exitDate`) only when the slope's 95 % interval excludes zero, the line is inside the band today and reaches a bound within `horizonYears` (three years, or the readings' span when shorter). `stale` when the newest reading is more than 365 days old: the line is then reported as of `lastAt`, with no projection. Server-computed; clients render it as given." UpdateBiomarkerResponse: type: object properties: data: $ref: "#/components/schemas/Biomarker" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DeleteBiomarkerResponse: type: object properties: data: type: object properties: deleted: type: boolean required: - deleted additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MergeBiomarkersResponse: type: object properties: data: $ref: "#/components/schemas/BiomarkerMergeResult" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false BiomarkerMergeResult: type: object properties: dryRun: type: boolean target: description: The marker after the merge (unchanged on a dry run). $ref: "#/components/schemas/Biomarker" sources: type: array items: type: object properties: id: type: string name: type: string unit: type: string readingCount: type: integer minimum: -9007199254740991 maximum: 9007199254740991 conversion: type: string enum: - none - converted - refused refusal: anyOf: - type: string enum: - unknown_analyte - unknown_unit - not_finite - type: "null" required: - id - name - unit - readingCount - conversion - refusal additionalProperties: false movedReadings: type: integer minimum: -9007199254740991 maximum: 9007199254740991 convertedReadings: type: integer minimum: -9007199254740991 maximum: 9007199254740991 aliasesAdded: type: array items: type: string description: Names that become aliases of the target. mergeId: description: "`null` on a dry run." type: - string - "null" required: - dryRun - target - sources - movedReadings - convertedReadings - aliasesAdded - mergeId additionalProperties: false description: What a merge did, or would do on a dry run. Readings in another unit are converted where the analyte's conversion table knows it; the printed value stays in `sourceValue` / `sourceUnit` on each reading. ListEncountersEnvelope: type: object properties: data: $ref: "#/components/schemas/EncounterList" error: type: "null" meta: $ref: "#/components/schemas/ListCapMeta" required: - data - error - meta additionalProperties: false EncounterList: type: object properties: upcoming: type: array items: $ref: "#/components/schemas/Encounter" past: type: array items: $ref: "#/components/schemas/Encounter" required: - upcoming - past additionalProperties: false description: Visits split server-side rather than client-side. `upcoming` reads soonest-first and `past` reads newest-first — opposite directions, resolved once here so the ordering lives in one place. Encounter: type: object properties: id: type: string occurredAt: type: string status: type: string enum: - PLANNED - DONE - CANCELLED - NO_SHOW kind: type: string enum: - ROUTINE - ACUTE - SPECIALIST - PREVENTIVE - EMERGENCY - HOSPITAL - THERAPY - OTHER - PROCEDURE practitioner: anyOf: - $ref: "#/components/schemas/Practitioner" - type: "null" reason: type: - string - "null" outcome: type: - string - "null" bodySite: type: - string - "null" laterality: anyOf: - type: string enum: - LEFT - RIGHT - BOTH - type: "null" reminderNextDueAt: type: - string - "null" links: $ref: "#/components/schemas/EncounterLinks" skipped: $ref: "#/components/schemas/EncounterSkipReport" preparation: description: "On the list read only (`GET /api/encounters`): the window before this visit when it is booked (PLANNED, upcoming), null on every other row. Absent on the detail and on write responses." anyOf: - $ref: "#/components/schemas/EncounterPreparation" - type: "null" createdAt: type: string updatedAt: type: string required: - id - occurredAt - status - kind - practitioner - reason - outcome - bodySite - laterality - reminderNextDueAt - createdAt - updatedAt additionalProperties: false description: "One contact with the healthcare system: a visit that happened, or one that is booked. `practitioner` is resolved rather than an id. `reason`, `outcome` and `bodySite` are the decrypted free text (null on a key-rotation gap). `kind: PROCEDURE` marks a procedure or surgery, and `bodySite` with `laterality` say where; both are carried on every kind so switching a visit's kind away and back loses nothing. `reminderNextDueAt` is the server-computed instant a booked appointment next nudges, and is null for a visit that already happened or whose one-shot reminder is spent." Practitioner: type: object properties: id: type: string name: type: string specialty: type: - string - "null" practice: type: - string - "null" location: type: - string - "null" phone: type: - string - "null" note: type: - string - "null" createdAt: type: string updatedAt: type: string required: - id - name - specialty - practice - location - phone - note - createdAt - updatedAt additionalProperties: false description: One entry in the caller's address book. `note` is the decrypted free text, or null on a key-rotation gap — fail-soft, never a 500. EncounterLinks: type: object properties: documents: type: array items: $ref: "#/components/schemas/EncounterLinkedTarget" labResults: type: array items: $ref: "#/components/schemas/EncounterLinkedTarget" conditions: type: array items: $ref: "#/components/schemas/EncounterLinkedTarget" required: - documents - labResults - conditions additionalProperties: false description: "The three link families of one visit, each ordered by when it was filed. Present on BOTH the list and the detail: a card shows counts of what the visit produced, and an edit opened from a list row seeds its pickers from that row — a list without them would make the first read zero and the second unfile everything on save. The list resolves them in three grouped queries for the whole page, not three per row." EncounterLinkedTarget: type: object properties: id: type: string label: type: - string - "null" date: type: - string - "null" redacted: type: boolean required: - id - label - date - redacted additionalProperties: false description: "One thing filed against a visit, resolved. The label and date are computed server-side from the target itself — a document's title and filing date, a lab result's panel and analyte and draw date, a condition's label and onset — so the client never re-derives them and the two cannot drift. Both go null together, with `redacted: true`, when the caller's grant does not cover the target's own domain: a visit lives in the health background but points at the vault, the labs and the illness journal, and a grant that opened only the first was never consent for the names in the other three. The link itself is still listed — the caller may know the visit produced a lab result — and `redacted` is what tells a withheld name apart from a target that simply has no date." EncounterSkipReport: type: object properties: catalogueKeys: type: array items: type: object properties: catalogue: type: string key: type: string links: type: number required: - catalogue - key - links additionalProperties: false links: type: number required: - catalogueKeys - links additionalProperties: false description: "What the write could not do, named. `links: 0` with an empty list is the ordinary answer. Anything else is a side effect the caller's grant did not reach — today only `checkupClosure`, the closing of a preventive-care checkup, which is a measurements-domain write a health-background delegate cannot perform. The visit still saves; the checkup stays due and the response says so, because somebody who believes a checkup was closed will not look at it again." EncounterPreparation: type: object properties: from: type: string description: "First day of the window, `YYYY-MM-DD` on the record's own calendar: the day of `since`, or 90 days before today on `default`." to: type: string description: The booked visit's own day, `YYYY-MM-DD` on the same calendar as `from` (the record's, not the reader's). A report window offered for the appointment ends here. basis: type: string enum: - practitioner - specialty - kind - default description: "The rule that chose `from`, first match wins over the visits that took place (DONE) before this one: `practitioner` (the last visit with the same practitioner), `specialty` (the last visit with a practitioner of the same specialty, compared without case or extra spaces), `kind` (the last visit of the same kind, for ROUTINE, PREVENTIVE and THERAPY only), `default` (none matched: the last 90 days)." since: anyOf: - $ref: "#/components/schemas/EncounterPreparationAnchor" - type: "null" required: - from - to - basis - since additionalProperties: false description: "The window before a booked visit, resolved server-side: where the visit preparation and the doctor report offered for the appointment start, and why there. Never the last visit of any kind, which is how a dermatology appointment was offered a week of data from an internist visit. A client shows `from` with its reason and lets the person change the dates; it does not recompute the rule." EncounterPreparationAnchor: type: object properties: id: type: string occurredAt: type: string kind: type: string enum: - ROUTINE - ACUTE - SPECIALIST - PREVENTIVE - EMERGENCY - HOSPITAL - THERAPY - OTHER - PROCEDURE practitionerName: type: - string - "null" specialty: type: - string - "null" required: - id - occurredAt - kind - practitionerName - specialty additionalProperties: false description: "The earlier visit the window starts at: its id, instant and kind, and the practitioner's name and specialty as they read now." CreateEncounterEnvelope: type: object properties: data: $ref: "#/components/schemas/Encounter" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false ListProceduresEnvelope: type: object properties: data: $ref: "#/components/schemas/ProcedureList" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false ProcedureList: type: object properties: procedures: type: array items: $ref: "#/components/schemas/Encounter" bodySites: type: array items: $ref: "#/components/schemas/BodySiteFacet" total: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - procedures - bodySites - total additionalProperties: false description: The procedure history. `procedures` is the filtered list, newest first. `bodySites` and `total` are computed over the whole history before the filter, so the filter choices never shrink to the one just picked. BodySiteFacet: type: object properties: bodySite: type: string laterality: anyOf: - type: string enum: - LEFT - RIGHT - BOTH - type: "null" count: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - bodySite - laterality - count additionalProperties: false description: One body site and side in the procedure history, with how many procedures carry it. Keyed case- and space-insensitively; `bodySite` is the first spelling seen. ListBodySitesEnvelope: type: object properties: data: $ref: "#/components/schemas/BodySiteList" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false BodySiteList: type: object properties: sites: type: array items: $ref: "#/components/schemas/BodySite" selection: type: object properties: bodySite: type: string laterality: anyOf: - type: string enum: - LEFT - RIGHT - BOTH - type: "null" visits: type: array items: $ref: "#/components/schemas/Encounter" conditions: anyOf: - type: array items: $ref: "#/components/schemas/BodySiteCondition" - type: "null" required: - bodySite - laterality - visits - conditions additionalProperties: false required: - sites additionalProperties: false description: "The record's body sites, most records first, and with `site` the visits and conditions filed there, each newest first with its links. `selection.conditions` is null (not empty) when the caller may not read conditions in this record or the record has the illness module off. Links into a section the caller's grant does not cover carry `redacted: true` and no label." BodySite: type: object properties: bodySite: type: string procedures: type: integer minimum: -9007199254740991 maximum: 9007199254740991 conditions: type: integer minimum: -9007199254740991 maximum: 9007199254740991 sides: type: array items: $ref: "#/components/schemas/BodySiteSide" required: - bodySite - procedures - conditions - sides additionalProperties: false description: One body site the record holds, across visits (procedures, mostly) and conditions. Keyed case-, accent- and space-insensitively; `bodySite` is the first spelling met, newest record first. `conditions` is 0 when the caller may not read conditions. BodySiteSide: type: object properties: laterality: anyOf: - type: string enum: - LEFT - RIGHT - BOTH - type: "null" count: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - laterality - count additionalProperties: false description: "How many records sit on one side of a site. `laterality: null` counts the records whose side was not stated." BodySiteCondition: type: object properties: id: type: string label: type: string type: type: string enum: - INFECTION - ALLERGY - INJURY - MENTAL_HEALTH - AUTOIMMUNE - CHRONIC - OTHER lifecycle: type: string enum: - ACUTE - CHRONIC_ONGOING - RECURRING - FLARE onsetAt: type: string resolvedAt: type: - string - "null" bodySite: type: - string - "null" laterality: anyOf: - type: string enum: - LEFT - RIGHT - BOTH - type: "null" links: type: object properties: documents: type: array items: $ref: "#/components/schemas/EncounterLinkedTarget" visits: type: array items: $ref: "#/components/schemas/EncounterLinkedTarget" required: - documents - visits additionalProperties: false required: - id - label - type - lifecycle - onsetAt - resolvedAt - bodySite - laterality - links additionalProperties: false description: "A condition at the picked site, with the documents filed against it and the visits it was linked to. No note: that is read on the condition itself. A document link comes back with `redacted: true` and no label or date when the caller's grant does not cover documents." SuggestEncounterEnvelope: type: object properties: data: $ref: "#/components/schemas/EncounterSuggestion" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false EncounterSuggestion: oneOf: - type: object properties: kind: type: string const: one encounter: $ref: "#/components/schemas/EncounterSuggestionCandidate" required: - kind - encounter additionalProperties: false - type: object properties: kind: type: string const: many encounters: type: array items: $ref: "#/components/schemas/EncounterSuggestionCandidate" required: - kind - encounters additionalProperties: false - type: object properties: kind: type: string const: none required: - kind additionalProperties: false description: "The verdict, as a shape rather than a best guess. There is deliberately no field on the `many` arm a client could read as a pre-selection: collapsing two candidates into one is the failure this union exists to make impossible." type: object EncounterSuggestionCandidate: type: object properties: id: type: string occurredAt: type: string status: type: string enum: - PLANNED - DONE - CANCELLED - NO_SHOW kind: type: string enum: - ROUTINE - ACUTE - SPECIALIST - PREVENTIVE - EMERGENCY - HOSPITAL - THERAPY - OTHER - PROCEDURE practitionerName: type: - string - "null" required: - id - occurredAt - status - kind - practitionerName additionalProperties: false description: One visit a record might belong to, resolved far enough to render a row without a second round trip. `practitionerName` is null when the visit names no practice, which is an absence rather than a gap. GetEncounterEnvelope: type: object properties: data: allOf: - $ref: "#/components/schemas/Encounter" - type: object properties: related: description: v1.43 — records related to this one, resolved on the server. Absent on older servers. type: array items: $ref: "#/components/schemas/RelatedLink" additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false UpdateEncounterEnvelope: type: object properties: data: $ref: "#/components/schemas/Encounter" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DeleteEncounterEnvelope: type: object properties: data: type: object properties: deleted: type: boolean required: - deleted additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false RestoreEncounterEnvelope: type: object properties: data: $ref: "#/components/schemas/Encounter" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false LinkEncounterEnvelope: type: object properties: data: type: object properties: links: $ref: "#/components/schemas/EncounterLinks" unknown: type: array items: type: string required: - links - unknown additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false UnlinkEncounterEnvelope: type: object properties: data: type: object properties: links: $ref: "#/components/schemas/EncounterLinks" unknown: type: array items: type: string required: - links - unknown additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false ListPractitionersEnvelope: type: object properties: data: type: array items: $ref: "#/components/schemas/Practitioner" error: type: "null" meta: $ref: "#/components/schemas/ListCapMeta" required: - data - error - meta additionalProperties: false CreatePractitionerEnvelope: type: object properties: data: $ref: "#/components/schemas/Practitioner" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false GetPractitionerEnvelope: type: object properties: data: $ref: "#/components/schemas/Practitioner" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false UpdatePractitionerEnvelope: type: object properties: data: $ref: "#/components/schemas/Practitioner" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DeletePractitionerEnvelope: type: object properties: data: type: object properties: deleted: type: boolean required: - deleted additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false RestorePractitionerEnvelope: type: object properties: data: $ref: "#/components/schemas/Practitioner" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false ListCustomVaccinesEnvelope: type: object properties: data: type: array items: $ref: "#/components/schemas/CustomVaccine" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false CustomVaccine: type: object properties: id: type: string name: type: string components: type: array items: type: string description: Catalogue antigen slugs the product protects against, only ones this release knows. typicalSeriesDoses: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" boosterIntervalMonths: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" createdAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ updatedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - id - name - components - typicalSeriesDoses - boosterIntervalMonths - createdAt - updatedAt additionalProperties: false description: A vaccine the record's owner defined for a product the shipped catalogue does not list, in the shape a catalogue entry resolves to. A dose logged against it counts into the series of each listed antigen, settles a booster reminder keyed to one of them and offers one from `boosterIntervalMonths`, exactly as a catalogue pick does. `name` is the person's own wording. CreateCustomVaccineEnvelope: type: object properties: data: $ref: "#/components/schemas/CustomVaccine" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false UpdateCustomVaccineEnvelope: type: object properties: data: $ref: "#/components/schemas/CustomVaccine" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DeleteCustomVaccineEnvelope: type: object properties: data: type: object properties: deleted: type: boolean required: - deleted additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false ListVaccinationsEnvelope: type: object properties: data: $ref: "#/components/schemas/VaccinationList" error: type: "null" meta: $ref: "#/components/schemas/ListCapMeta" required: - data - error - meta additionalProperties: false VaccinationList: type: object properties: vaccinations: type: array items: $ref: "#/components/schemas/Vaccination" renewals: anyOf: - type: array items: $ref: "#/components/schemas/VaccinationRenewal" - type: "null" description: Each antigen's renewal state, earliest due first. Null when the caller acts under a grant that does not cover the measurements section the booster reminders live in. required: - vaccinations - renewals additionalProperties: false description: The caller's immunization history, newest dose first, with every entry's series already resolved over the whole live set — never over the filtered page, which would report the oldest dose in a window as the first ever given. Vaccination: type: object properties: id: type: string occurredAt: type: string antigenSlug: type: - string - "null" vaccineName: type: - string - "null" doseNumber: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" seriesDoses: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" lotNumber: type: - string - "null" site: anyOf: - type: string enum: - LEFT_ARM - RIGHT_ARM - LEFT_THIGH - RIGHT_THIGH - ORAL - NASAL - OTHER - type: "null" catalogEntry: anyOf: - $ref: "#/components/schemas/VaccinationCatalogEntry" - type: "null" customVaccineId: description: The record's own vaccine definition the dose names (v1.42), verbatim. type: - string - "null" customVaccine: anyOf: - $ref: "#/components/schemas/CustomVaccine" - type: "null" description: That definition, resolved, or null when the dose names none or it was removed. When `catalogEntry` is also set, the catalogue is the answer. series: type: array items: $ref: "#/components/schemas/VaccinationSeriesPosition" practitioner: anyOf: - $ref: "#/components/schemas/VaccinationPractitioner" - type: "null" encounter: anyOf: - $ref: "#/components/schemas/VaccinationEncounterStub" - type: "null" reminderId: type: - string - "null" note: type: - string - "null" documents: type: array items: $ref: "#/components/schemas/VaccinationLinkedDocument" createdAt: type: string updatedAt: type: string required: - id - occurredAt - antigenSlug - vaccineName - doseNumber - seriesDoses - lotNumber - site - catalogEntry - customVaccineId - customVaccine - series - practitioner - encounter - reminderId - note - createdAt - updatedAt additionalProperties: false description: "One administered dose. `occurredAt` is a day at UTC midnight — the source record carries dates, never times. `series` carries one entry per component antigen, so a combined preparation reports several at once and each may be at a different position: a dose can be a booster for one antigen and a first dose for another in the same act. An empty `series` means the dose has no resolvable antigen (free text only, or a slug this release does not know) and nothing is guessed from the name. `note` is the decrypted free text, or null on a key-rotation gap — fail-soft, never a 500. `reminderId` names the booster reminder this dose settled, when one matched. `documents` is present on the detail response and absent from the list." VaccinationCatalogEntry: type: object properties: slug: type: string atc: type: string category: type: string required: - slug - atc - category additionalProperties: false description: "What the catalogue this release ships knows about the record's `antigenSlug`: the slug itself, its WHO ATC code from the J07 subtree, and its picker grouping. Null when the slug does not resolve — a slug written by an older release, or one restored verbatim from an older backup. Null is the signal to render `vaccineName` instead; it is not an error and not missing data." VaccinationSeriesPosition: type: object properties: antigen: type: string position: type: integer minimum: -9007199254740991 maximum: 9007199254740991 total: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" booster: type: boolean required: - antigen - position - total - booster additionalProperties: false description: "Where this dose sits in ONE antigen's series, derived server-side over the whole history. `position` is the person's own recorded dose number when they gave one, otherwise the chronological index among their doses for that antigen. `total` is the record's own series length, then the catalogue's typical one, then null when neither knows — a seasonal vaccine has no 'of M'. `booster` is true once the series is complete and this dose comes after it. Render text from these values: both resolved reads as 'N of M', position alone as 'dose N', `booster` as a booster. Do not recompute them — a client cannot see the whole history and the two would drift." VaccinationPractitioner: type: object properties: id: type: string name: type: string specialty: type: - string - "null" practice: type: - string - "null" location: type: - string - "null" phone: type: - string - "null" note: type: - string - "null" createdAt: type: string updatedAt: type: string required: - id - name - specialty - practice - location - phone - note - createdAt - updatedAt additionalProperties: false description: The practice that administered the dose, resolved rather than an id. Null when the record names none. VaccinationEncounterStub: type: object properties: id: type: string occurredAt: type: string kind: type: string required: - id - occurredAt - kind additionalProperties: false description: The visit the dose was given at, enough to recognise it by. Null when no visit is linked. VaccinationLinkedDocument: type: object properties: id: type: string label: type: - string - "null" date: type: - string - "null" redacted: type: boolean required: - id - label - date - redacted additionalProperties: false description: "One page filed against a dose, resolved. The label and the date are computed server-side from the document itself, so the client never re-derives them. Both go null together, with `redacted: true`, when the caller's grant does not cover the document vault: a dose lives in the health background but points into the vault, and a page's filename is itself the sensitive part. The link is still listed — the caller may know the dose was transcribed from a page — and `redacted` tells a withheld name apart from a page that simply has no date." VaccinationRenewal: type: object properties: antigen: type: string reminderId: type: string dueAt: type: string daysUntil: type: integer minimum: -9007199254740991 maximum: 9007199254740991 state: type: string enum: - current - dueSoon - overdue required: - antigen - reminderId - dueAt - daysUntil - state additionalProperties: false description: "One antigen's renewal state, read from the booster reminder the person confirmed for it (`dueAt` is that reminder's next due instant). `daysUntil` counts calendar days on the person's own clock and is negative once the date has passed. `state` is `overdue` once it has passed, `dueSoon` within 30 days, `current` otherwise. Render from `state`; do not recompute it. Nothing here is derived from age, history or the catalogue: an antigen without a confirmed booster has no entry." CreateVaccinationEnvelope: type: object properties: data: $ref: "#/components/schemas/Vaccination" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false GetVaccinationEnvelope: type: object properties: data: allOf: - $ref: "#/components/schemas/Vaccination" - type: object properties: related: description: v1.43 — records related to this one, resolved on the server. Absent on older servers. type: array items: $ref: "#/components/schemas/RelatedLink" additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false UpdateVaccinationEnvelope: type: object properties: data: $ref: "#/components/schemas/Vaccination" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DeleteVaccinationEnvelope: type: object properties: data: type: object properties: deleted: type: boolean required: - deleted additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false RestoreVaccinationEnvelope: type: object properties: data: $ref: "#/components/schemas/Vaccination" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false ReanchorVaccinationBoosterEnvelope: type: object properties: data: type: object properties: reminder: $ref: "#/components/schemas/MeasurementReminderDTO" minted: type: boolean required: - reminder - minted additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MintVaccinationBoosterEnvelope: type: object properties: data: type: object properties: reminder: $ref: "#/components/schemas/MeasurementReminderDTO" minted: type: boolean required: - reminder - minted additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false VaccinationSuggestEnvelope: type: object properties: data: $ref: "#/components/schemas/VaccinationSuggestResult" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false VaccinationSuggestResult: anyOf: - type: object properties: kind: type: string const: one vaccination: $ref: "#/components/schemas/VaccinationSuggestion" required: - kind - vaccination additionalProperties: false - type: object properties: kind: type: string const: many vaccinations: type: array items: $ref: "#/components/schemas/VaccinationSuggestion" required: - kind - vaccinations additionalProperties: false - type: object properties: kind: type: string const: none required: - kind additionalProperties: false description: "The SHAPE of the answer, decided server-side: `one` pre-selects that dose, `many` offers a picker over `vaccinations` (bounded at 20 candidates), `none` offers nothing. A client renders the verdict and must not collapse `many` into a silent auto-pick — the rule lives on the server so every client agrees." VaccinationSuggestion: type: object properties: id: type: string occurredAt: type: string antigenSlug: type: - string - "null" vaccineName: type: - string - "null" required: - id - occurredAt - antigenSlug - vaccineName additionalProperties: false description: "One candidate dose, resolved enough to render without a second round trip: `antigenSlug` when the dose carries a catalogue slug (name it from the catalogue), `vaccineName` when the person's own wording is all the dose has." LinkVaccinationEnvelope: type: object properties: data: type: object properties: documents: type: array items: $ref: "#/components/schemas/VaccinationLinkedDocument" unknown: type: array items: type: string required: - documents - unknown additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false UnlinkVaccinationEnvelope: type: object properties: data: type: object properties: documents: type: array items: $ref: "#/components/schemas/VaccinationLinkedDocument" unknown: type: array items: type: string required: - documents - unknown additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false ListIllnessEpisodesEnvelope: type: object properties: data: type: array items: $ref: "#/components/schemas/IllnessEpisode" error: type: "null" meta: $ref: "#/components/schemas/ListCapMeta" required: - data - error - meta additionalProperties: false IllnessEpisode: type: object properties: id: type: string label: type: string type: type: string enum: - INFECTION - ALLERGY - INJURY - MENTAL_HEALTH - AUTOIMMUNE - CHRONIC - OTHER lifecycle: type: string enum: - ACUTE - CHRONIC_ONGOING - RECURRING - FLARE onsetAt: type: string resolvedAt: type: - string - "null" parentConditionId: type: - string - "null" note: type: - string - "null" bodySite: type: - string - "null" laterality: anyOf: - type: string enum: - LEFT - RIGHT - BOTH - type: "null" createdAt: type: string updatedAt: type: string required: - id - label - type - lifecycle - onsetAt - resolvedAt - parentConditionId - note - bodySite - laterality - createdAt - updatedAt additionalProperties: false description: A stored illness/condition episode. `note` is the decrypted free-text (or null on a key-rotation gap — fail-soft, never 500). `resolvedAt` is null while the episode is open. `parentConditionId` links a FLARE/RECURRING bout to its parent condition. `bodySite` is the decrypted body site (null when not stated or on a key-rotation gap) and `laterality` its side. CreateIllnessEpisodeEnvelope: type: object properties: data: $ref: "#/components/schemas/IllnessEpisode" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false GetIllnessEpisodeEnvelope: type: object properties: data: allOf: - $ref: "#/components/schemas/IllnessEpisode" - type: object properties: related: description: v1.43 — records related to this one, resolved on the server. Absent on older servers. type: array items: $ref: "#/components/schemas/RelatedLink" additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false UpdateIllnessEpisodeEnvelope: type: object properties: data: $ref: "#/components/schemas/IllnessEpisode" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DeleteIllnessEpisodeEnvelope: type: object properties: data: type: object properties: deleted: type: boolean required: - deleted additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false RestoreIllnessEpisodeEnvelope: type: object properties: data: $ref: "#/components/schemas/IllnessEpisode" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false ResolveIllnessEpisodeEnvelope: type: object properties: data: $ref: "#/components/schemas/IllnessEpisode" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false GetIllnessDayLogEnvelope: type: object properties: data: anyOf: - anyOf: - $ref: "#/components/schemas/IllnessDayLog" - type: "null" - $ref: "#/components/schemas/IllnessDayLogList" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false IllnessDayLog: type: object properties: id: type: string episodeId: type: string date: type: string functionalImpact: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" feverC: type: - number - "null" symptoms: type: array items: $ref: "#/components/schemas/IllnessSymptom" note: type: - string - "null" updatedAt: type: string required: - id - episodeId - date - functionalImpact - feverC - symptoms - note - updatedAt additionalProperties: false description: A stored day-log for an episode. `date` is the `YYYY-MM-DD` it covers. `functionalImpact` (0–3) and `feverC` are plaintext; `symptoms` flattens the link rows; `note` is the decrypted free-text (or null). IllnessSymptom: type: object properties: key: type: string severity: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" required: - key - severity additionalProperties: false description: "A symptom link on a day-log: the catalog `key` plus an optional 1–3 graded severity (`null` = a plain presence link; the link's presence already means 'present', so 0 is not a distinct state)." IllnessDayLogList: type: object properties: dayLogs: type: array items: $ref: "#/components/schemas/IllnessDayLog" meta: type: object properties: total: type: number limit: type: number offset: type: number required: - total - limit - offset additionalProperties: false required: - dayLogs - meta additionalProperties: false description: "The date-less day-log LIST (v1.18.3): the episode's day-logs newest-first (or oldest-first under `sortDir:'asc'`), with `meta.total` for paging the full history. Powers the detail timeline scroll + iOS healthlog-iOS#30." UpsertIllnessDayLogUpdatedEnvelope: type: object properties: data: $ref: "#/components/schemas/IllnessDayLog" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false UpsertIllnessDayLogCreatedEnvelope: type: object properties: data: $ref: "#/components/schemas/IllnessDayLog" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false IllnessCorrelationEnvelope: type: object properties: data: $ref: "#/components/schemas/IllnessCorrelationResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false IllnessCorrelationResponse: type: object properties: episodeId: type: string status: type: string enum: - ok - insufficient value: anyOf: - $ref: "#/components/schemas/IllnessCorrelationValue" - type: "null" coverage: type: object properties: requiredInputs: type: integer minimum: -9007199254740991 maximum: 9007199254740991 presentInputs: type: integer minimum: -9007199254740991 maximum: 9007199254740991 historyDays: type: integer minimum: -9007199254740991 maximum: 9007199254740991 missing: type: array items: type: string required: - requiredInputs - presentInputs - historyDays - missing additionalProperties: false confidence: anyOf: - type: object properties: score: type: number band: type: string required: - score - band additionalProperties: false - type: "null" provenance: type: object properties: inputs: type: array items: type: string source: type: string windowDays: type: integer minimum: -9007199254740991 maximum: 9007199254740991 computedAt: type: string required: - inputs - source - windowDays - computedAt additionalProperties: false reason: type: - string - "null" required: - episodeId - status - value - coverage - confidence - provenance - reason additionalProperties: false description: Coverage-gated `Derived` wire shape for the per-episode correlation. `status:"insufficient"` carries coverage + a reason and a null `value` — the surface renders 'still learning', never a fabricated number. Server-authoritative; iOS pattern-matches `status`, never recomputes. IllnessCorrelationValue: type: object properties: episodeId: type: string preOnset: type: array items: $ref: "#/components/schemas/IllnessVitalDeviation" nadir: type: array items: $ref: "#/components/schemas/IllnessVitalDeviation" returns: type: array items: $ref: "#/components/schemas/IllnessVitalReturn" recoveryGapDays: type: - number - "null" adverseCoverageDays: type: integer minimum: -9007199254740991 maximum: 9007199254740991 feltBetterDay: type: - string - "null" gapDriverType: type: - string - "null" redFlags: type: array items: $ref: "#/components/schemas/IllnessRedFlag" sleepContext: anyOf: - $ref: "#/components/schemas/IllnessSleepContext" - type: "null" required: - episodeId - preOnset - nadir - returns - recoveryGapDays - adverseCoverageDays - feltBetterDay - gapDriverType - redFlags - sleepContext additionalProperties: false description: "The retrospective correlation findings for one episode: pre-onset anomaly scan, nadir, per-vital physiological returns (incl. the user-logged symptom-burden track, `type:FUNCTIONAL_IMPACT`), the headline recovery-gap (median of per-track gaps), the driving metric (`gapDriverType`), any red flags, and an optional sleep-as-context observation (`sleepContext`, never in the gap)." IllnessVitalDeviation: type: object properties: type: type: string day: type: string value: type: number baselineCenter: type: number deviationSd: type: number direction: type: string enum: - above - below adverse: type: boolean required: - type - day - value - baselineCenter - deviationSd - direction - adverse additionalProperties: false description: "One vital's deviation finding on a day: signed deviation in robust-SD units from the user's OWN baseline (median ± MAD), the direction, and whether the move is illness-adverse for that metric." IllnessVitalReturn: type: object properties: type: type: string returnedDay: type: - string - "null" gapDays: type: - number - "null" adverse: type: boolean required: - type - returnedDay - gapDays - adverse additionalProperties: false description: "A physiological / symptom return: the first day the metric re-entered its band AND held, the signed gap (days) from the felt-better marker (positive = the body lagged the feeling), and whether the move was illness-adverse (only adverse returns name the driver). `type` is a MeasurementType or `FUNCTIONAL_IMPACT` for the user-logged symptom-burden track (functionalImpact, baseline = constant 0)." IllnessRedFlag: type: object properties: type: type: string reason: type: string enum: - sustained_low_spo2 - sustained_fever worstValue: type: number days: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - type - reason - worstValue - days additionalProperties: false description: A retrospective red-flag escalation (sustained low SpO2 or sustained fever) against absolute clinical floors. Copy must escalate ('seek care if this recurs'), never reassure. IllnessSleepContext: type: object properties: baselineMeanMinutes: type: number episodeMeanMinutes: type: number deltaMinutes: type: number nightsCounted: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - baselineMeanMinutes - episodeMeanMinutes - deltaMinutes - nightsCounted additionalProperties: false description: "A neutral sleep-as-context observation: the user's median asleep minutes over the well baseline vs the mean over the episode window, and their signed delta (positive = slept more than usual). A pure OBSERVATION surfaced alongside the gap — NEVER a recovery return and NEVER part of the headline recovery-gap. Withheld (the field is null) on thin sleep data or a sub-floor delta." IllnessInsightsEnvelope: type: object properties: data: $ref: "#/components/schemas/IllnessInsightsResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false IllnessInsightsResponse: type: object properties: windowDays: type: integer minimum: -9007199254740991 maximum: 9007199254740991 episodeCount: type: integer minimum: -9007199254740991 maximum: 9007199254740991 resolvedCount: type: integer minimum: -9007199254740991 maximum: 9007199254740991 typicalRecoveryGapDays: type: - number - "null" gapSampleSize: type: integer minimum: -9007199254740991 maximum: 9007199254740991 byMonth: type: object propertyNames: type: string additionalProperties: type: integer minimum: -9007199254740991 maximum: 9007199254740991 byType: type: object propertyNames: type: string additionalProperties: type: integer minimum: -9007199254740991 maximum: 9007199254740991 gapDriverType: type: - string - "null" required: - windowDays - episodeCount - resolvedCount - typicalRecoveryGapDays - gapSampleSize - byMonth - byType - gapDriverType additionalProperties: false description: "Cross-episode retrospective summary over the trailing window: episode + resolved counts, the typical (median) recovery gap (null below the min-sample floor — withholds a thin claim), a recurrence-by-month tally, a per-type breakdown, and the dominant driving vital (`gapDriverType`, null when no gap is surfaced). Retrospective only." ListMentalHealthAssessmentsEnvelope: type: object properties: data: $ref: "#/components/schemas/ListMentalHealthAssessmentsResponse" error: type: "null" meta: $ref: "#/components/schemas/ListCapMeta" required: - data - error - meta additionalProperties: false ListMentalHealthAssessmentsResponse: type: object properties: assessments: type: array items: $ref: "#/components/schemas/MentalHealthAssessment" trends: description: v1.44 — one per PHQ-9 / GAD-7 / WHO-5 with at least one administration. Absent on older servers. type: array items: $ref: "#/components/schemas/MentalHealthAssessmentTrend" required: - assessments additionalProperties: false MentalHealthAssessment: type: object properties: id: type: string instrument: type: string enum: - PHQ9 - GAD7 - WHO5 - SCI locale: type: string wording: anyOf: - type: string enum: - validated - unvalidated_translation - original_en - type: "null" description: "Which text the answers were given in. `validated`: the published validated version for `locale` (the original for English). `unvalidated_translation`: a translation written for HealthLog where no validated version exists; clients show that the result is orientation only. `original_en`: the English original, chosen by a reader of another language (`englishOriginal` on create). Null on administrations recorded before v1.46." version: type: string totalScore: type: number severityBand: type: string item9Flagged: type: boolean crisisShownAt: type: - string - "null" takenAt: type: string createdAt: type: string required: - id - instrument - locale - wording - version - totalScore - severityBand - item9Flagged - crisisShownAt - takenAt - createdAt additionalProperties: false description: One completed screener administration. `severityBand` is a descriptive label for the screen (never a diagnosis). `item9Flagged` is true when the PHQ-9 self-harm item was answered > 0 — the client MUST show crisis resources when it is. Raw per-item answers are intentionally absent from list rows; the by-id detail read returns them decrypted. MentalHealthAssessmentTrend: type: object properties: instrument: type: string enum: - PHQ9 - GAD7 - WHO5 n: type: integer minimum: -9007199254740991 maximum: 9007199254740991 threshold: type: number description: "Meaningful-change threshold in reported points: PHQ-9 5, GAD-7 4, WHO-5 10 (0–100 scale)." higherIsBetter: type: boolean first: type: object properties: score: type: number takenAt: type: string required: - score - takenAt additionalProperties: false previous: anyOf: - type: object properties: score: type: number takenAt: type: string required: - score - takenAt additionalProperties: false - type: "null" latest: type: object properties: score: type: number takenAt: type: string required: - score - takenAt additionalProperties: false changeFromPrevious: type: - number - "null" changeFromFirst: type: - number - "null" sincePrevious: anyOf: - type: string enum: - improved - worsened - within_threshold - type: "null" sinceFirst: anyOf: - type: string enum: - improved - worsened - within_threshold - type: "null" required: - instrument - n - threshold - higherIsBetter - first - previous - latest - changeFromPrevious - changeFromFirst - sincePrevious - sinceFirst additionalProperties: false description: v1.44 — the latest total against the previous and the first administration, classified against the instrument's meaningful-change threshold in its own direction. A change below the threshold is `within_threshold`. Server-computed over the whole history, not the page. CreateMentalHealthAssessmentEnvelope: type: object properties: data: $ref: "#/components/schemas/CreateMentalHealthAssessmentResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false CreateMentalHealthAssessmentResponse: type: object properties: assessment: $ref: "#/components/schemas/MentalHealthAssessment" actionThreshold: type: number crisis: anyOf: - $ref: "#/components/schemas/CrisisResourceSet" - type: "null" required: - assessment - actionThreshold - crisis additionalProperties: false CrisisResourceSet: type: object properties: emergencyNumber: type: string resources: type: array items: $ref: "#/components/schemas/CrisisResource" required: - emergencyNumber - resources additionalProperties: false description: Locale-aware crisis-resource signposting, present only when item-9 is flagged. Names resolve via i18n on the client; contacts are literal. CrisisResource: type: object properties: id: type: string contacts: type: array items: type: string required: - id - contacts additionalProperties: false GetMentalHealthAssessmentEnvelope: type: object properties: data: $ref: "#/components/schemas/GetMentalHealthAssessmentResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false GetMentalHealthAssessmentResponse: type: object properties: assessment: $ref: "#/components/schemas/MentalHealthAssessmentDetail" required: - assessment additionalProperties: false MentalHealthAssessmentDetail: type: object properties: id: type: string instrument: type: string enum: - PHQ9 - GAD7 - WHO5 - SCI locale: type: string wording: anyOf: - type: string enum: - validated - unvalidated_translation - original_en - type: "null" description: "Which text the answers were given in. `validated`: the published validated version for `locale` (the original for English). `unvalidated_translation`: a translation written for HealthLog where no validated version exists; clients show that the result is orientation only. `original_en`: the English original, chosen by a reader of another language (`englishOriginal` on create). Null on administrations recorded before v1.46." version: type: string totalScore: type: number severityBand: type: string item9Flagged: type: boolean crisisShownAt: type: - string - "null" takenAt: type: string createdAt: type: string items: anyOf: - type: array items: type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" functionalDifficulty: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" itemsUnavailable: type: boolean required: - id - instrument - locale - wording - version - totalScore - severityBand - item9Flagged - crisisShownAt - takenAt - createdAt - items - functionalDifficulty - itemsUnavailable additionalProperties: false description: "One administration with its decrypted per-item answers, in the order the instrument presents its items (PHQ-9/GAD-7 answers 0–3, SCI 0–4, WHO-5 0–5). `functionalDifficulty` is the PHQ-9's unscored functional follow-up when it was answered. When the stored blob cannot be decrypted the read degrades honestly: `items` is null, `itemsUnavailable` is true, and the denormalised score fields still answer." ListAllergiesEnvelope: type: object properties: data: type: array items: $ref: "#/components/schemas/Allergy" error: type: "null" meta: $ref: "#/components/schemas/ListCapMeta" required: - data - error - meta additionalProperties: false Allergy: type: object properties: id: type: string substance: type: string category: type: string enum: - FOOD - MEDICATION - ENVIRONMENT - BIOLOGIC - OTHER type: type: string enum: - ALLERGY - INTOLERANCE severity: anyOf: - type: string enum: - MILD - MODERATE - SEVERE - type: "null" status: type: string enum: - ACTIVE - INACTIVE - RESOLVED onsetAt: type: - string - "null" reaction: type: - string - "null" note: type: - string - "null" createdAt: type: string updatedAt: type: string required: - id - substance - category - type - severity - status - onsetAt - reaction - note - createdAt - updatedAt additionalProperties: false description: A stored allergy/intolerance record. `reaction` + `note` are the decrypted free-text (or null on a key-rotation gap — fail-soft, never 500). `severity`/`onsetAt` are null when not assessed/known. CreateAllergyEnvelope: type: object properties: data: $ref: "#/components/schemas/Allergy" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false GetAllergyEnvelope: type: object properties: data: $ref: "#/components/schemas/Allergy" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false UpdateAllergyEnvelope: type: object properties: data: $ref: "#/components/schemas/Allergy" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DeleteAllergyEnvelope: type: object properties: data: type: object properties: deleted: type: boolean required: - deleted additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false ListFamilyHistoryEnvelope: type: object properties: data: type: array items: $ref: "#/components/schemas/FamilyHistoryEntry" error: type: "null" meta: $ref: "#/components/schemas/ListCapMeta" required: - data - error - meta additionalProperties: false FamilyHistoryEntry: type: object properties: id: type: string relationship: type: string enum: - MOTHER - FATHER - SISTER - BROTHER - DAUGHTER - SON - GRANDMOTHER_MATERNAL - GRANDFATHER_MATERNAL - GRANDMOTHER_PATERNAL - GRANDFATHER_PATERNAL - AUNT - UNCLE - COUSIN - HALF_SIBLING - OTHER condition: type: string ageAtOnset: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" note: type: - string - "null" createdAt: type: string updatedAt: type: string required: - id - relationship - condition - ageAtOnset - note - createdAt - updatedAt additionalProperties: false description: "A stored family-history entry: one condition for one relative. `note` is the decrypted free-text (or null on a key-rotation gap — fail-soft, never 500). `ageAtOnset` is null when unknown." CreateFamilyHistoryEnvelope: type: object properties: data: $ref: "#/components/schemas/FamilyHistoryEntry" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false GetFamilyHistoryEnvelope: type: object properties: data: $ref: "#/components/schemas/FamilyHistoryEntry" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false UpdateFamilyHistoryEnvelope: type: object properties: data: $ref: "#/components/schemas/FamilyHistoryEntry" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DeleteFamilyHistoryEnvelope: type: object properties: data: type: object properties: deleted: type: boolean required: - deleted additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false InboundDocumentListEnvelope: type: object properties: data: $ref: "#/components/schemas/InboundDocumentList" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false InboundDocumentList: type: object properties: documents: type: array items: $ref: "#/components/schemas/InboundDocument" nextCursor: type: - string - "null" required: - documents - nextCursor additionalProperties: false description: A page of documents plus an opaque `nextCursor` (the id to pass back as `cursor` for the next page; null when the list is exhausted). InboundDocument: type: object properties: id: type: string kind: type: string enum: - DOCTOR_REPORT - DISCHARGE_LETTER - LAB_RESULT - IMAGING - PRESCRIPTION - REFERRAL - INSURANCE - VACCINATION - SICK_NOTE - OTHER title: type: - string - "null" filename: type: - string - "null" mimeType: type: string byteSize: type: number status: type: string enum: - STORED - EXTRACTING - EXTRACTED - FAILED - CONFIRMED - DISCARDED providerType: type: - string - "null" reportDate: type: - string - "null" documentDate: type: - string - "null" errorReason: type: - string - "null" factCount: type: number pendingCount: type: number conditionLinks: type: array items: $ref: "#/components/schemas/DocumentConditionLink" encounterLinks: type: array items: $ref: "#/components/schemas/DocumentEncounterLink" servingClass: type: string enum: - inline - attachment hasContentIndex: type: boolean contentIndexSource: anyOf: - type: string enum: - vision - text-ocr - local-pdf - local-ocr - type: "null" lastIndexAttemptAt: type: - string - "null" lastIndexOutcome: anyOf: - type: string enum: - local-empty - local-unsupported - decrypt-error - resource-limit - raster-failed - provider-error - pdf-needs-anthropic - empty-transcription - type: "null" hasThumbnail: type: boolean sourceSystem: anyOf: - type: string enum: - PAPERLESS - PAPRA - OTHER - type: "null" sourceId: type: - string - "null" createdAt: type: string updatedAt: type: string required: - id - kind - title - filename - mimeType - byteSize - status - providerType - reportDate - documentDate - errorReason - factCount - pendingCount - conditionLinks - encounterLinks - servingClass - hasContentIndex - contentIndexSource - lastIndexAttemptAt - lastIndexOutcome - hasThumbnail - sourceSystem - sourceId - createdAt - updatedAt additionalProperties: false description: A stored document. The raw bytes are stored encrypted at rest and never returned; this is the metadata + staging summary. `status` is STORED for a freshly uploaded file (no extraction run). `title` is the user label (plaintext); `documentDate` is the user filing date; `reportDate` is the model-transcribed date (null until extraction runs). `servingClass` says how `/original` delivers the file — render inline (`inline`) or download-only (`attachment`); render/download by it, never by MIME guess. `hasContentIndex` is true when the document has a content-search index (auto-indexed on upload); `contentIndexSource` says how that index was produced — `vision` means an AI provider read the original, the other values are provider-free local extractions, and it is null when `hasContentIndex` is false. `lastIndexAttemptAt` is when the most recent index attempt finished (successful or not; null when none ran); `lastIndexOutcome` says why that attempt produced no index, and is null when the attempt succeeded or none ran. `hasThumbnail` is true when a preview thumbnail has been rendered in the background (fetch it from `/api/documents/inbound/{id}/thumbnail`); false means no preview yet or an unsupported type — show a placeholder. `sourceSystem` / `sourceId` say where an imported document came from and its id there (both null for a document added in HealthLog); treat an unknown `sourceSystem` as OTHER when decoding. Treat an unknown `kind` value as OTHER when decoding. DocumentConditionLink: type: object properties: episodeId: type: string name: type: string required: - episodeId - name additionalProperties: false description: A link to one of the caller's illness/condition episodes. `name` is the episode's user-facing label. DocumentEncounterLink: type: object properties: encounterId: type: string kind: type: string occurredAt: type: - string - "null" required: - encounterId - kind - occurredAt additionalProperties: false description: "A link to one of the caller's visits — \"this letter belongs to that appointment\". `kind` is the visit-kind ENUM CONSTANT rather than a rendered name: unlike a condition label, which is the person's own words, a visit kind is one of eight closed values whose human name the reader's own bundle owns, so publishing the constant is what lets a German reader and an English one each see it named correctly." DocumentUploadEnvelope: type: object properties: data: anyOf: - $ref: "#/components/schemas/InboundDocument" - $ref: "#/components/schemas/DocumentUploadReceipt" error: type: "null" meta: type: object properties: requestId: type: string duplicate: description: "Present on a 200: the bytes or the source key were already stored, and `data` is the existing row (or the receipt)." type: boolean const: true deleted: description: Present with `duplicate` when the source key belongs to a document the owner deleted. Nothing was stored. type: boolean const: true additionalProperties: false required: - data - error additionalProperties: false DocumentUploadReceipt: type: object properties: id: type: - string - "null" duplicate: type: boolean deleted: type: boolean const: true required: - id - duplicate additionalProperties: false description: "What an upload answers instead of the stored row. A narrow `documents:write` token always gets this shape (`duplicate: false` on 201, `true` on 200): a credential that can only add files learns whether a file is stored, not its title, filename or links. Every caller gets it when the upload's source key belongs to a document the owner DELETED (`deleted: true`, 200, nothing stored); `id` is the tombstoned row, or null once the 30-day purge removed it." DocumentSourceLookupEnvelope: type: object properties: data: $ref: "#/components/schemas/DocumentSourceLookup" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DocumentSourceLookup: type: object properties: known: type: boolean id: type: - string - "null" deleted: type: boolean required: - known - id - deleted additionalProperties: false DocumentAiCapabilityEnvelope: type: object properties: data: $ref: "#/components/schemas/DocumentAiCapability" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DocumentAiCapability: type: object properties: available: type: boolean mode: anyOf: - type: string enum: - vision - text - type: "null" reason: anyOf: - type: string enum: - no-provider - enable-local-ocr - type: "null" pdfSupported: type: boolean egress: anyOf: - type: string enum: - local - external - type: "null" ai: $ref: "#/components/schemas/AiCapabilityState" required: - available - mode - reason - pdfSupported - egress - ai additionalProperties: false DocumentUsageEnvelope: type: object properties: data: $ref: "#/components/schemas/DocumentUsage" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DocumentUsage: type: object properties: usedBytes: type: number quotaBytes: type: number maxFileBytes: type: number acceptedExtensions: type: array items: type: string linkedEpisodes: type: array items: type: object properties: episodeId: type: string name: type: string required: - episodeId - name additionalProperties: false linkedProcedures: type: array items: type: object properties: encounterId: type: string occurredAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ reason: type: - string - "null" bodySite: type: - string - "null" laterality: anyOf: - type: string enum: - LEFT - RIGHT - BOTH - type: "null" practitionerName: type: - string - "null" required: - encounterId - occurredAt - reason - bodySite - laterality - practitionerName additionalProperties: false assistAvailable: type: boolean description: "Whether a document read can run for this record now: a provider can serve it and the `documentAi` capability is not closed by the operator, the module or the sharing grant. The same answer as `available` on GET /api/documents/inbound/capability." contentIndex: type: object properties: enabled: type: boolean indexedCount: type: number totalCount: type: number required: - enabled - indexedCount - totalCount additionalProperties: false deletedCount: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: How many of the caller's documents are deleted and not yet purged (Recently deleted, `GET /api/documents/inbound/deleted`). Always 0 while acting inside somebody else's record, where Recently deleted is not offered. required: - usedBytes - quotaBytes - maxFileBytes - acceptedExtensions - linkedEpisodes - linkedProcedures - assistAvailable - contentIndex - deletedCount additionalProperties: false DocumentsLayoutEnvelope: type: object properties: data: $ref: "#/components/schemas/DocumentsLayout" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DocumentsLayout: type: object properties: version: type: number const: 1 view: type: string enum: - cards - list description: '`cards` renders the preview-tile grid, `list` one compact row per document. Default "cards".' arrangement: type: string enum: - stacked - flow description: '`stacked` gives every month its own block; `flow` runs the documents across the full width and wraps, with month names inline. Default "flow"; an account that never chose keeps following the default.' required: - version - view - arrangement additionalProperties: false description: Per-user document vault presentation. Display-only; a client that does not render a vault ignores it. DocumentsLayoutSavedEnvelope: type: object properties: data: $ref: "#/components/schemas/DocumentsLayout" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DocumentBulkEnvelope: type: object properties: data: $ref: "#/components/schemas/DocumentBulkResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DocumentBulkResponse: type: object properties: results: type: array items: type: object properties: id: type: string ok: type: boolean error: type: - string - "null" required: - id - ok - error additionalProperties: false required: - results additionalProperties: false InboundDocumentDetailEnvelope: type: object properties: data: allOf: - $ref: "#/components/schemas/InboundDocumentDetail" - type: object properties: related: description: v1.43 — records related to this one, resolved on the server. Absent on older servers. type: array items: $ref: "#/components/schemas/RelatedLink" additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false InboundDocumentDetail: type: object properties: id: type: string kind: type: string enum: - DOCTOR_REPORT - DISCHARGE_LETTER - LAB_RESULT - IMAGING - PRESCRIPTION - REFERRAL - INSURANCE - VACCINATION - SICK_NOTE - OTHER title: type: - string - "null" filename: type: - string - "null" mimeType: type: string byteSize: type: number status: type: string enum: - STORED - EXTRACTING - EXTRACTED - FAILED - CONFIRMED - DISCARDED providerType: type: - string - "null" reportDate: type: - string - "null" documentDate: type: - string - "null" errorReason: type: - string - "null" factCount: type: number pendingCount: type: number conditionLinks: type: array items: $ref: "#/components/schemas/DocumentConditionLink" encounterLinks: type: array items: $ref: "#/components/schemas/DocumentEncounterLink" servingClass: type: string enum: - inline - attachment hasContentIndex: type: boolean contentIndexSource: anyOf: - type: string enum: - vision - text-ocr - local-pdf - local-ocr - type: "null" lastIndexAttemptAt: type: - string - "null" lastIndexOutcome: anyOf: - type: string enum: - local-empty - local-unsupported - decrypt-error - resource-limit - raster-failed - provider-error - pdf-needs-anthropic - empty-transcription - type: "null" hasThumbnail: type: boolean sourceSystem: anyOf: - type: string enum: - PAPERLESS - PAPRA - OTHER - type: "null" sourceId: type: - string - "null" createdAt: type: string updatedAt: type: string facts: type: array items: $ref: "#/components/schemas/ExtractedFact" vaccinationLinks: anyOf: - type: array items: $ref: "#/components/schemas/DocumentVaccinationLink" - type: "null" description: "Vaccination doses this document is filed against. Null when the caller acts inside somebody else's record under a grant that does not cover the health background the doses live in: the page is shared, the doses are not, and an empty list would claim the page records none." summary: type: - string - "null" summaryGeneratedAt: type: - string - "null" summaryState: type: string enum: - NONE - PENDING - READY - WITHHELD - UNAVAILABLE extractionIncomplete: type: boolean description: "Whether the staged facts may leave part of the document out: a part of a long document was cut at the provider's output limit or not read, the text or pages ran past what the reads take, or the document held more facts than one review takes. Kept from the last extraction (the extract route, its background run, or automatic lab staging) until the next one replaces the facts; false for a document never extracted. A review shows it whenever it is opened, not only when the run ends." required: - id - kind - title - filename - mimeType - byteSize - status - providerType - reportDate - documentDate - errorReason - factCount - pendingCount - conditionLinks - encounterLinks - servingClass - hasContentIndex - contentIndexSource - lastIndexAttemptAt - lastIndexOutcome - hasThumbnail - sourceSystem - sourceId - createdAt - updatedAt - facts - vaccinationLinks - summary - summaryGeneratedAt - summaryState - extractionIncomplete additionalProperties: false description: 'A stored document plus its staged facts. `summary` is a short (3-4 sentence) plain-language description of WHAT the document is, generated once and then served from storage; it is descriptive only, never a diagnosis. `summaryGeneratedAt` is when it was generated (null until then). `summaryState` says what became of it, because a null `summary` on its own is ambiguous: NONE = never attempted (the `documentsAutoAiRead` opt-in was off at upload, reading documents with AI was closed for the record at upload, or the document predates it — no backfill reaches these), PENDING = a job is enqueued or running, READY = stored and returned in `summary`, WITHHELD = generated but blocked by the outbound safety screen and therefore never returned as text, UNAVAILABLE = attempted and could not produce one (no vision provider, spent budget, withdrawn consent, unreadable file, provider error). Only PENDING may be presented as "being generated"; WITHHELD and UNAVAILABLE are both re-attemptable via POST `/api/documents/inbound/{id}/summary`. Treat an unknown value as UNAVAILABLE when decoding.' ExtractedFact: type: object properties: id: type: string factType: type: string enum: - CONDITION - OBSERVATION - MEDICATION_STATEMENT status: type: string enum: - PENDING - APPROVED - REJECTED confidence: type: number needsReview: type: boolean data: type: object propertyNames: type: string additionalProperties: {} provenance: type: object properties: sourceText: type: string anchored: type: boolean sourceOffset: type: - number - "null" page: type: - number - "null" confidence: type: number required: - sourceText - anchored - sourceOffset - page - confidence additionalProperties: false committedRecordId: type: - string - "null" committedRecordType: type: - string - "null" biomarkerId: description: v1.43, OBSERVATION only — the marker matched automatically (name, alias or catalogue abbreviation); null without a match. type: - string - "null" matchKind: description: v1.43, OBSERVATION only — how `biomarkerId` was found. anyOf: - type: string enum: - name - alias - catalog - type: "null" suggestions: description: v1.43, OBSERVATION only — up to three markers to choose from when nothing matched. maxItems: 3 type: array items: type: object properties: biomarkerId: type: string name: type: string unit: type: string matchKind: type: string enum: - catalog - similar required: - biomarkerId - name - unit - matchKind additionalProperties: false required: - id - factType - status - confidence - needsReview - data - provenance - committedRecordId - committedRecordType additionalProperties: false description: One STATED fact transcribed from the document, FHIR-staged (Condition / Observation / MedicationStatement) with per-field provenance + a confidence gate. STATED status only — codes (SNOMED/ICD-10/LOINC/RxNorm/ATC) are present only when the document wrote them; no range-flag, no inferred links. `needsReview` is true when the fact scored below the confidence floor (it cannot be approved until edited). DocumentVaccinationLink: type: object properties: vaccinationId: type: string occurredAt: type: - string - "null" catalogSlug: type: - string - "null" vaccineName: type: - string - "null" required: - vaccinationId - occurredAt - catalogSlug - vaccineName additionalProperties: false description: A link to one of the caller's vaccination doses — "this page records that dose". `catalogSlug` is the catalogue entry when this release still knows the dose's slug, for the reader's own bundle to name; null is the signal to show `vaccineName`, the person's own wording. The same table the dose's own `/api/vaccinations/{id}/links` writes from the other end. InboundDocumentUpdateEnvelope: type: object properties: data: $ref: "#/components/schemas/InboundDocumentDetail" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false InboundDiscardEnvelope: type: object properties: data: type: object properties: id: type: string discarded: type: boolean required: - id - discarded additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false InboundRestoreEnvelope: type: object properties: data: $ref: "#/components/schemas/InboundDocumentDetail" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DeletedDocumentListEnvelope: type: object properties: data: $ref: "#/components/schemas/DeletedDocumentList" error: type: "null" meta: $ref: "#/components/schemas/ListCapMeta" required: - data - error - meta additionalProperties: false DeletedDocumentList: type: object properties: documents: type: array items: $ref: "#/components/schemas/DeletedDocument" graceDays: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Days a deleted document stays restorable. required: - documents - graceDays additionalProperties: false DeletedDocument: type: object properties: id: type: string kind: type: string enum: - DOCTOR_REPORT - DISCHARGE_LETTER - LAB_RESULT - IMAGING - PRESCRIPTION - REFERRAL - INSURANCE - VACCINATION - SICK_NOTE - OTHER title: type: - string - "null" filename: type: - string - "null" mimeType: type: string byteSize: type: integer minimum: -9007199254740991 maximum: 9007199254740991 documentDate: description: User-set filing date (YYYY-MM-DD), or null. type: - string - "null" sourceSystem: anyOf: - type: string enum: - PAPERLESS - PAPRA - OTHER - type: "null" description: The system an imported document came from, or null for one added in HealthLog. deletedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ purgeAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ description: "When the daily purge becomes due for this document: `deletedAt` plus `graceDays`. The purge runs once a day, so the document is removed at the first run after this instant." required: - id - kind - title - filename - mimeType - byteSize - documentDate - sourceSystem - deletedAt - purgeAt additionalProperties: false DocumentPurgeResultEnvelope: type: object properties: data: $ref: "#/components/schemas/DocumentPurgeResult" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DocumentPurgeResult: type: object properties: id: type: string purged: type: boolean const: true required: - id - purged additionalProperties: false InboundExtractEnvelope: type: object properties: data: $ref: "#/components/schemas/InboundDocumentDetail" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DocumentSuggestEnvelope: type: object properties: data: $ref: "#/components/schemas/DocumentSuggestResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DocumentSummaryEnvelope: type: object properties: data: $ref: "#/components/schemas/DocumentSummaryResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DocumentIndexEnvelope: type: object properties: data: $ref: "#/components/schemas/DocumentIndexResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DocumentChatHistoryEnvelope: type: object properties: data: $ref: "#/components/schemas/DocumentChatHistoryResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DocumentChatHistoryResponse: anyOf: - $ref: "#/components/schemas/DocumentChatDetail" - $ref: "#/components/schemas/DocumentChatList" DocumentChatDetail: type: object properties: id: type: string title: type: string createdAt: type: string updatedAt: type: string messageCount: type: number messages: type: array items: $ref: "#/components/schemas/DocumentChatMessage" summary: type: - string - "null" required: - id - title - createdAt - updatedAt - messageCount - messages - summary additionalProperties: false DocumentChatMessage: type: object properties: id: type: string role: type: string enum: - user - assistant content: type: string createdAt: type: string providerType: type: - string - "null" tokensUsed: type: - number - "null" model: type: - string - "null" required: - id - role - content - createdAt - providerType - tokensUsed - model additionalProperties: false DocumentChatList: type: object properties: conversations: type: array items: $ref: "#/components/schemas/DocumentChatListItem" nextCursor: type: - string - "null" required: - conversations - nextCursor additionalProperties: false DocumentChatListItem: type: object properties: id: type: string title: type: string createdAt: type: string updatedAt: type: string messageCount: type: number fenced: type: boolean description: "Always true here: a chat about a document is a fenced thread (no tools, no health snapshot)." attachments: description: The documents the thread is about, as they are now. type: array items: type: object properties: documentId: type: string title: description: The attached document's title, falling back to its filename; null when it has neither. type: - string - "null" required: - documentId - title additionalProperties: false documentTitle: description: The first attached document's title, for a one-line badge; null when none is attached any more. type: - string - "null" required: - id - title - createdAt - updatedAt - messageCount - fenced additionalProperties: false DocumentReindexEnvelope: type: object properties: data: $ref: "#/components/schemas/DocumentReindexResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DocumentReindexResponse: type: object properties: enqueued: type: integer minimum: 0 maximum: 9007199254740991 required: - enqueued additionalProperties: false ExtractedFactEnvelope: type: object properties: data: $ref: "#/components/schemas/ExtractedFact" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false InboundConfirmEnvelope: type: object properties: data: $ref: "#/components/schemas/InboundConfirmResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false InboundConfirmResponse: type: object properties: approved: type: array items: type: object properties: factId: type: string recordType: type: string recordId: type: string required: - factId - recordType - recordId additionalProperties: false rejected: type: array items: type: string needsReview: type: array items: type: string failed: type: array items: type: object properties: factId: type: string reason: type: string required: - factId - reason additionalProperties: false required: - approved - rejected - needsReview - failed additionalProperties: false description: "The outcome per decision: `approved` (committed, with the new record ref), `rejected` (discarded), `needsReview` (refused — edit first, fail-closed), `failed` (a per-fact commit miss, e.g. a numeric observation with no unit)." ListCustomMetricsEnvelope: type: object properties: data: $ref: "#/components/schemas/ListCustomMetricsResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false ListCustomMetricsResponse: type: object properties: customMetrics: type: array items: $ref: "#/components/schemas/CustomMetric" required: - customMetrics additionalProperties: false CustomMetric: type: object properties: id: type: string name: type: string unit: type: string targetLow: type: - number - "null" targetHigh: type: - number - "null" decimals: type: - number - "null" description: type: - string - "null" correlationEnabled: type: boolean latest: anyOf: - type: object properties: value: type: number unit: type: string measuredAt: type: string required: - value - unit - measuredAt additionalProperties: false - type: "null" entryCount: type: number createdAt: type: string updatedAt: type: string required: - id - name - unit - targetLow - targetHigh - decimals - description - correlationEnabled - latest - entryCount - createdAt - updatedAt additionalProperties: false description: A user-defined custom metric. `latest` is the most recently logged value (or null); `entryCount` is the total logged values. The optional target window is the user's own good range, charted as a reference band. CreateCustomMetricResponse: type: object properties: data: $ref: "#/components/schemas/CustomMetric" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false GetCustomMetricResponse: type: object properties: data: $ref: "#/components/schemas/CustomMetric" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false UpdateCustomMetricResponse: type: object properties: data: $ref: "#/components/schemas/CustomMetric" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DeleteCustomMetricResponse: type: object properties: data: type: object properties: deleted: type: boolean required: - deleted additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false ListCustomMetricEntriesEnvelope: type: object properties: data: $ref: "#/components/schemas/ListCustomMetricEntriesResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false ListCustomMetricEntriesResponse: type: object properties: entries: type: array items: $ref: "#/components/schemas/CustomMetricEntry" meta: type: object properties: total: type: number limit: type: number offset: type: number required: - total - limit - offset additionalProperties: false required: - entries - meta additionalProperties: false CustomMetricEntry: type: object properties: id: type: string customMetricId: type: string value: type: number unit: type: string measuredAt: type: string note: type: - string - "null" createdAt: type: string required: - id - customMetricId - value - unit - measuredAt - note - createdAt additionalProperties: false description: A single logged value for a custom metric. `unit` is a snapshot of the metric's unit at write time. CreateCustomMetricEntryResponse: type: object properties: data: $ref: "#/components/schemas/CustomMetricEntry" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false UpdateCustomMetricEntryResponse: type: object properties: data: $ref: "#/components/schemas/CustomMetricEntry" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DeleteCustomMetricEntryResponse: type: object properties: data: type: object properties: deleted: type: boolean required: - deleted additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false RestoreCustomMetricEntriesResponse: type: object properties: data: type: object properties: restored: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - restored additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false OnboardingStateEnvelope: type: object properties: data: type: object properties: onboarding: $ref: "#/components/schemas/OnboardingState" required: - onboarding additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false OnboardingState: type: object properties: steps: type: array items: $ref: "#/components/schemas/OnboardingStepState" needs: $ref: "#/components/schemas/OnboardingNeeds" completedAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" description: "When the needs flow was completed. Distinct from the account payload's `onboardingCompletedAt`, which stays the first-run redirect's gate: `POST /api/onboarding/restart` clears this one and leaves that one alone." firstResult: anyOf: - $ref: "#/components/schemas/OnboardingFirstResult" - type: "null" required: - steps - needs - completedAt - firstResult additionalProperties: false description: The needs-based setup flow for one record. Always present on the account payload — a record that never entered the flow reads as empty answers with nine `pending` steps, so a client branches on the step statuses rather than on the field's existence. On the account payload it describes the ACTIVE record, so a client under an account switch reads the same record its counts and module map describe; under a scoped grant the answers come back empty. OnboardingStepState: type: object properties: id: type: string enum: - who - areas - medication - sources - visit - units - confirm - first-result - done status: type: string enum: - pending - done - skipped required: - id - status additionalProperties: false description: >- One step of the setup flow. The ids are stable; a client that meets one it does not know SKIPS it rather than refusing the list, so the flow may grow a screen without a client release. `units` carries one resolution the others do not: a record whose account already holds BOTH unit preferences reads as `done` whether or not the question was ever put, which is what stops the flow re-asking a value the account holds. OnboardingNeeds: type: object properties: recordTarget: anyOf: - type: string enum: - me - someone-else - both - type: "null" description: Q1 — whose record this is. Null until answered. areas: type: array items: type: string enum: - blood-pressure - weight-body - glucose - sleep - mood - cycle - activity - labs - illness description: Q2 — the areas to keep an eye on. Each one maps to at least one module through `ONBOARDING_AREA_MODULES`; the map is the server's, and a client must not re-derive it. medication: anyOf: - type: string enum: - yes - no - sometimes - type: "null" description: Q3 — medication on a schedule. sources: type: array items: type: string enum: - manual - apple-health - withings - oura - whoop - polar - fitbit - strava - nightscout - file description: Q4 — where the readings come from today. visit: anyOf: - type: string enum: - within-a-month - later - no - type: "null" description: Q5 — a doctor's visit coming up. units: type: object properties: glucoseUnit: anyOf: - type: string enum: - mg/dL - mmol/L - type: "null" unitPreference: anyOf: - type: string enum: - metric - imperial - type: "null" required: - glucoseUnit - unitPreference additionalProperties: false description: "Q6 — the unit answers as given. NOT the source of truth for display: `glucoseUnit` and `unitPreference` on the account payload are, and the answers step writes them. This records what the flow was told." required: - recordTarget - areas - medication - sources - visit - units additionalProperties: false description: The setup answers as given. Every field is null or empty until its question is answered. OnboardingFirstResult: type: object properties: task: type: string enum: - connect-source - add-medication - log-reading target: description: What the task is about — a source key for a connection, an area key for a reading. Null for the medication task. type: - string - "null" completedAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" description: When the offered task produced its result, or null. required: - task - target - completedAt additionalProperties: false description: The one task the flow offered at the end, and whether it produced its result. OnboardingCompleteEnvelope: type: object properties: data: type: object properties: completed: type: boolean const: true onboarding: $ref: "#/components/schemas/OnboardingState" required: - completed additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DisclaimerAckEnvelope: type: object properties: data: $ref: "#/components/schemas/DisclaimerAckResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DisclaimerAckResponse: type: object properties: acknowledgedVersion: type: string description: The canonical disclaimer version the server stamped. required: - acknowledgedVersion additionalProperties: false description: The persisted disclaimer acknowledgment version. TourUpdateEnvelope: type: object properties: data: $ref: "#/components/schemas/TourUpdateResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false TourUpdateResponse: type: object properties: onboardingTourCompleted: type: boolean progress: anyOf: - $ref: "#/components/schemas/TourProgressOutput" - type: "null" required: - onboardingTourCompleted - progress additionalProperties: false description: The persisted completion flag and resume point after the write. NutrientCatalogEnvelope: type: object properties: data: type: object properties: entries: type: array items: $ref: "#/components/schemas/NutrientCatalogEntry" required: - entries additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false NutrientCatalogEntry: type: object properties: code: type: string unit: type: string description: The canonical unit amounts are stored in. labelKey: type: string label: type: string description: The nutrient's name in the account language. syncable: type: boolean description: "Whether HealthKit may send this code in the nutrient batch. `dha` and `epa` are not syncable: they appear in responses only." inputUnits: type: array items: type: object properties: unit: type: string factorToCanonical: type: number required: - unit - factorToCanonical additionalProperties: false description: "Units an amount may be entered in, with the factor to the canonical unit (vitamin D: `IU` = 0.025 µg)." upperLimit: anyOf: - $ref: "#/components/schemas/NutrientUpperLimit" - type: "null" required: - code - unit - labelKey - label - syncable - inputUnits - upperLimit additionalProperties: false description: One nutrient of the catalogue as the composition form uses it. Clients build the form from this and never convert units themselves. NutrientUpperLimit: type: object properties: value: type: number unit: type: string scope: type: string enum: - total - supplemental form: anyOf: - type: string enum: - retinol - folic_acid - magnesium_salts - alpha_tocopherol - type: "null" description: "v1.43 — the chemical form the level is set for, where narrower than the nutrient: `retinol` (vitamin A: preformed retinol only, beta-carotene does not count), `folic_acid`, `magnesium_salts` (readily dissociable salts in supplements), `alpha_tocopherol`. `null` where the level covers the nutrient as such. Phrase it in the reader's language; skip an unknown value." source: type: string description: The bare citation (EFSA overview and the opinion it rests on), not prose. Every level here is the adult one. required: - value - unit - scope - form - source additionalProperties: false description: The tolerable upper intake level, with the unit, whether it applies to the total intake or to the supplemental share only, and the cited source. `null` where no level is established. NutrientBatchEnvelope: type: object properties: data: $ref: "#/components/schemas/NutrientBatchResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false NutrientBatchResponse: type: object properties: processed: type: integer minimum: 0 maximum: 9007199254740991 inserted: type: integer minimum: 0 maximum: 9007199254740991 updated: type: integer minimum: 0 maximum: 9007199254740991 skipped: type: array items: type: object properties: index: type: integer minimum: 0 maximum: 9007199254740991 reason: type: string required: - index - reason additionalProperties: false entries: type: array items: $ref: "#/components/schemas/NutrientEntryResult" required: - processed - inserted - updated - skipped - entries additionalProperties: false description: Always 200 when the envelope parses; per-entry failures surface as skipped entries, never a batch failure. NutrientEntryResult: type: object properties: index: type: integer minimum: 0 maximum: 9007199254740991 status: type: string enum: - inserted - updated - skipped reason: type: string required: - index - status additionalProperties: false description: "Per-entry ingest outcome. skipped reasons: unit_mismatch | value_out_of_range | day_invalid | upsert_failed. The first three are verdicts on the entry itself and terminal: resending it unchanged is skipped again, so log and drop it. `upsert_failed` is a write failure on the server's side, not a verdict on the entry: nothing was stored for it, and it should be sent again on a later sync. A write failure takes out the whole group it was written in (every new day total, or every existing one), so several entries can carry it at once. A response carrying it is marked `Cache-Control: no-store` and is not kept for `Idempotency-Key` replay, so the retry writes again rather than replaying the failure." NutrientOverviewEnvelope: type: object properties: data: $ref: "#/components/schemas/NutrientIntakeOverview" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false NutrientIntakeOverview: type: object properties: windowDays: type: integer minimum: 1 maximum: 9007199254740991 nutrients: type: array items: type: object properties: nutrient: $ref: "#/components/schemas/NutrientCode" unit: type: string latestDay: type: string latestAmount: type: number daysWithData: type: integer minimum: 1 maximum: 9007199254740991 latestBySource: description: v1.43 — `latestAmount` split by source. $ref: "#/components/schemas/NutrientAmountBySource" upperLimit: description: v1.43 — the nutrient's upper intake level, or null. anyOf: - $ref: "#/components/schemas/NutrientUpperLimit" - type: "null" latestAboveUpperLimit: description: v1.43 — whether the latest day is above the upper limit; null without one. type: - boolean - "null" required: - nutrient - unit - latestDay - latestAmount - daysWithData additionalProperties: false lastAttempt: anyOf: - type: object properties: at: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ topReason: type: string required: - at - topReason additionalProperties: false - type: "null" description: Non-null only when `nutrients` is empty AND the most recent nutrient-batch call landed nothing — the most common skip reason from that call, so the client can say a sync arrived and why instead of a bare empty state. required: - windowDays - nutrients - lastAttempt additionalProperties: false description: "Per-nutrient window summary in catalog order: latest synced day + total, and the count of days carrying data inside the window. Nutrients without data in the window are omitted." NutrientAmountBySource: type: object properties: APPLE_HEALTH: type: number MANUAL: type: number SUPPLEMENT: type: number additionalProperties: false description: "The amount split by source: synced from Apple Health, entered by hand, and counted from taken supplement doses. A source with nothing is absent." NutrientDailySeriesEnvelope: type: object properties: data: $ref: "#/components/schemas/NutrientDailySeries" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false NutrientDailySeries: type: object properties: nutrient: $ref: "#/components/schemas/NutrientCode" unit: type: string windowDays: type: integer minimum: 1 maximum: 9007199254740991 days: type: array items: type: object properties: day: type: string amount: type: number bySource: description: v1.43 — the day's amount split by source. $ref: "#/components/schemas/NutrientAmountBySource" aboveUpperLimit: description: v1.43 — whether the day is above the upper limit; null without one. type: - boolean - "null" required: - day - amount additionalProperties: false reference: anyOf: - type: object properties: kind: type: string enum: - PRI - AI - safeLevel direction: type: string enum: - target - upperGuidance value: type: number source: type: string required: - kind - direction - value - source additionalProperties: false - type: "null" upperLimit: description: v1.43 — the nutrient's upper intake level, or null. anyOf: - $ref: "#/components/schemas/NutrientUpperLimit" - type: "null" required: - nutrient - unit - windowDays - days - reference additionalProperties: false description: Dense day-bucketed series (one entry per day in the window, 0 for a day with no data) summed across sources, plus the EFSA reference resolved against the caller's profile sex (null when sex is unknown on the profile). NutrientWaterWriteEnvelope: type: object properties: data: $ref: "#/components/schemas/NutrientWaterWriteResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false NutrientWaterWriteResponse: type: object properties: day: type: string nutrient: type: string const: water source: type: string const: MANUAL amount: type: number unit: type: string required: - day - nutrient - source - amount - unit additionalProperties: false description: The MANUAL water row after the add/set write. HealthKitConfigEnvelope: type: object properties: data: $ref: "#/components/schemas/HealthKitConfigResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false HealthKitConfigResponse: type: object properties: entries: type: array items: $ref: "#/components/schemas/HealthKitEntry" lastSyncedAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" description: When HealthKit last synced for this user; null when never (and always null on the PATCH echo). lastSyncTrigger: description: What the client declared triggered the most recent accepted batch. Null when the client sent no trigger — an older build, or a batch that predates the field. Present on the GET read; omitted from the PATCH echo. anyOf: - type: string enum: - foreground - background - push - manual description: What triggered a HealthKit batch. - type: "null" lastBackgroundSyncAt: description: When a batch last arrived without the app being opened (a `background` or `push` trigger). Null when that has never happened. `lastSyncedAt` alone cannot separate a phone that delivers on its own from one that only delivers while the app is open; this field is what does. Present on the GET read; omitted from the PATCH echo. anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" syncHealth: deprecated: true description: "Deprecated, still served: read `health`. Present on the GET read; omitted from the PATCH echo." $ref: "#/components/schemas/SyncHealth" metricFreshness: description: Per-metric-type freshness for the `APPLE_HEALTH` source, with the per-type arrival facts. Present on the GET read; omitted from the PATCH echo. type: array items: $ref: "#/components/schemas/AppleHealthMetricFreshnessEntry" syncProgress: description: Backfill/sync progress summary. Present on the GET read (null when the progress read failed); omitted from the PATCH echo. anyOf: - $ref: "#/components/schemas/AppleHealthSyncProgress" - type: "null" health: description: The unified Apple Health connection state. Null when the phone has never delivered anything (or the read failed). Present on the GET read; omitted from the PATCH echo. anyOf: - $ref: "#/components/schemas/ConnectionHealth" - type: "null" required: - entries - lastSyncedAt additionalProperties: false description: "The resolved HealthKit integration config: the default metric set merged with the user's stored per-metric overrides, plus the sync-health verdict and per-metric freshness." HealthKitEntry: type: object properties: id: type: string description: Stable metric key (e.g. `bodyMass`). kind: type: string description: HealthKit sample kind (e.g. `bloodPressure`). direction: type: string enum: - bidirectional - readOnly - writeOnly - disabled description: Per-metric sync direction. enabled: type: boolean required: - id - kind - direction - enabled additionalProperties: false description: One resolved HealthKit metric mapping (defaults merged with the user's stored overrides). SyncHealth: type: object properties: verdict: type: string enum: - fresh - stale - stalled - failing - reauth_required - parked - pending_first_sync - disconnected description: "Liveness verdict. For Apple Health: `fresh` when data arrived within the last 7 days, `stale` when it did not, `pending_first_sync` when none ever arrived." since: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" description: The instant that triggered the verdict; null when none did. required: - verdict - since additionalProperties: false description: Server-resolved sync-health verdict. The single source of liveness truth — `lastSyncedAt` alone cannot express 'this pipe stopped delivering'. AppleHealthMetricFreshnessEntry: type: object properties: type: type: string description: The measurement type (e.g. `RESPIRATORY_RATE`), `WORKOUTS` for the workout leg, or (Apple Health only, since v1.42.4) `MEDICATION_DOSE` for medication doses imported from Apple Health. Clients should show or skip a type they do not know rather than fail. lastSeenAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ description: Newest recorded reading for this type from this source. stale: type: boolean description: This type has gone quiet while the source around it reads healthy — the dead-pipe signature (e.g. a single revoked HealthKit permission). Only ever true when the verdict is `fresh`. lastReceivedAt: description: "When a live sync batch last brought a new or changed row of this type. Since v1.45 a batch that carried the type with duplicates only (an unchanged `stats:` re-post or an hour posted again with its stored value included) does not move it, nor `lastTrigger` or `lastBackgroundAt`; it moves `lastSeenDuplicateAt`. Before v1.45 duplicates counted too, so a stored value can still date from a duplicates-only batch. Null when no live sync has brought anything for it yet: a type that so far came only from the export import, or from before v1.42. `lastSeenAt` is when the newest sample was TAKEN; this is when the server last RECEIVED the type. Omitted on the `WORKOUTS` entry, which the ledger does not cover, and on every entry when the ledger read failed." anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" lastTrigger: description: The `syncTrigger` of that batch. Null when the client reported none. A type whose trigger is only ever `manual` reaches the server only through a user-started full sync. Same omission rules as `lastReceivedAt`. anyOf: - type: string enum: - foreground - background - push - manual description: What triggered a HealthKit batch. - type: "null" lastNewSampleAt: description: When a live sync batch last brought a new or updated sample of this type, as opposed to duplicates only. Since v1.45 this moves together with `lastReceivedAt`; the two differ only on a row last written before. Null when none has. Same omission rules as `lastReceivedAt`. anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" lastBackgroundAt: description: 'When a batch with `syncTrigger: "background"` last carried this type (since v1.42.4). A later `foreground`, `push` or `manual` batch moves `lastReceivedAt` and `lastTrigger` but leaves this standing, so opening the app does not hide an earlier background arrival. Null when the type has never arrived in the background as far as the server recorded: before v1.42.4 only the latest arrival was kept, so a type whose latest arrival then was a background one starts with that date and every other type starts null. Same omission rules as `lastReceivedAt`.' anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" lastSeenDuplicateAt: description: v1.45 — when a live sync batch last carried this type with duplicates only, nothing new or changed. Later than `lastReceivedAt` means the phone keeps sending the type but nothing new has arrived since `lastReceivedAt`. Null when no duplicates-only batch was recorded for an existing ledger row since v1.45. Same omission rules as `lastReceivedAt`; absent on older servers. anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" lastBackfillAt: description: 'v1.43 — when a batch with `syncTrigger: "backfill"` last carried this type. A backfill moves only this and `syncProgress`, never `lastReceivedAt`, `lastTrigger`, `lastBackgroundAt` or the account-level sync stamps, so catching up history does not read as a run of healthy daily syncs. Absent on older servers.' anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" lastEnteredHereAt: description: "v1.43.7 — present only on a type the app writes back to Apple Health (weight, blood pressure, blood glucose while their entry is `bidirectional` or `writeOnly` and enabled): when the newest reading of the type was entered here rather than arriving from Apple Health, or null when none was. Such readings go to the phone and never come back as an arrival, so `stale` already counts them: the type is quiet only when both are. Since v1.43.9 `lastSeenAt` on such a type is the newer of this and the newest reading from Apple Health, because a reading entered here is in Apple Health too; show both as the server sends them and do not recompute `stale`." anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" required: - type - lastSeenAt - stale additionalProperties: false description: "Per-type Apple Health freshness: the newest sample's timestamp and staleness flag, plus the per-type arrival facts recorded by `POST /api/measurements/batch` (v1.42). Since v1.42.4 the list can also hold a `MEDICATION_DOSE` entry: `lastSeenAt` is the newest live dose imported from Apple Health (its `takenAt`, or its slot for a skipped dose), and the arrival facts come from `POST /api/medications/intake/bulk` entries sent with `source: \"APPLE_HEALTH\"`. A dose recorded in the app does not move them. Doses are never flagged `stale`." AppleHealthSyncProgress: type: object properties: recordsAccepted: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Measurement + workout rows carrying the `APPLE_HEALTH` source (live batch ingest and the one-shot export import both write it). Soft-deleted measurement rows are excluded. oldestMeasuredAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" description: Earliest instant reached across those rows (`measuredAt` for measurements, `startedAt` for workouts); null when nothing has arrived. During a first-run backfill this walks backwards as history lands. required: - recordsAccepted - oldestMeasuredAt additionalProperties: false description: "Server-side Apple Health backfill progress: how many rows have been accepted and how far back in time they reach. Only what the server actually knows — no ETA, no device-side queue state." ConnectionHealth: type: object properties: state: type: string enum: - ok - limited - degraded - action_required description: "`ok` data is arriving (or the first sync is still due). `limited` it works, but the types in `unavailable` cannot be delivered (provider plan or missing permission). `degraded` temporary provider trouble or unexpected silence; the server keeps retrying, `since` dates it. `action_required` the user has to do the one thing in `action`." reason: type: string enum: - data_arriving - first_sync_pending - plan_limited - permission_limited - provider_errors - sync_stalled - no_new_data - unexpected_silence - reauth_required - paused_after_errors description: "Why the state is what it is. ok: `data_arriving`, `first_sync_pending`. limited: `plan_limited`, `permission_limited`. degraded: `provider_errors`, `sync_stalled`, `no_new_data`, `unexpected_silence` (the integration watchdog saw a source that normally delivers daily go quiet). action_required: `reauth_required`, `paused_after_errors`. Pick the sentence from this code alone." since: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" description: When the current degraded / action_required condition started. Null for ok and limited. action: anyOf: - type: string enum: - reconnect - resume - type: "null" description: "The one action that resolves the state: `reconnect` (run the provider's connect flow again) or `resume` (POST the provider's resume route). Also `reconnect` on a `limited` connection whose gap is a missing permission. Null when nothing the user can do helps." unavailable: type: array items: type: object properties: item: type: string description: Provider data-type key, e.g. `spo2`, `vo2Max`, `activity`. cause: type: string enum: - plan - permission required: - item - cause additionalProperties: false description: Data types this connection cannot deliver. Present in every state, empty when nothing is withheld. lastSuccessAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" description: Last successful sync. For Apple Health, the last accepted batch. lastDataAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" description: "Newest data this source delivered: for polled providers when the newest reading or workout was taken, for Apple Health when a sync last brought a new sample. Null when nothing has arrived." diagnostics: type: object properties: ledgerState: description: The stored sync-ledger state. Null for Apple Health. type: - string - "null" failingLegs: type: array items: type: string failingSince: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" lastAttemptAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" verdict: type: string description: The `SyncHealth` verdict the state was derived from. recentCalls: maxItems: 10 type: array items: type: object properties: endpoint: type: string description: Sync leg name (`sleep`, `measures`, `sync`, `token_refresh`), or `batch:` for Apple Health. Never a URL. status: type: string enum: - ok - transient - persistent - reauth_required at: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ errorCode: description: Short error code (`401`, `invalid_grant`, `scope_missing`); anything not shaped like one is dropped. type: - string - "null" required: - endpoint - status - at - errorCode additionalProperties: false description: The last ten sync attempts, newest first. required: - ledgerState - failingLegs - failingSince - lastAttemptAt - verdict - recentCalls additionalProperties: false description: Redacted diagnostics for a bug report. Carries no token, no error message, no health value and no account identifier, so a client may offer it for copying as is. required: - state - reason - since - action - unavailable - lastSuccessAt - lastDataAt - diagnostics additionalProperties: false description: The one server-resolved health state of a connection (v1.42.4). It folds the ledger state, the liveness verdict, plan and permission gaps, provider reauth flags and the integration watchdog's silence flag into four states. Clients render it and never recompute it. The older health-shaped fields beside it stay for one release after the clients read `health`, then go. HealthKitConfigPatchEnvelope: type: object properties: data: $ref: "#/components/schemas/HealthKitConfigResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false OuraSyncEnvelope: type: object properties: data: $ref: "#/components/schemas/ProviderSyncOutcome" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false ProviderSyncOutcome: type: object properties: imported: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Rows that reached the database this run. failed: type: boolean description: True when any part of the run did not land — a row, a collection, a leg. outcome: type: string enum: - empty - failed - partial - success description: "`empty` — nothing new, nothing refused. `failed` — nothing written, something refused. `partial` — some written, some refused. `success` — everything written." required: - imported - failed - outcome additionalProperties: false description: The result of an on-demand provider sync trigger. No request body; the sync window is derived server-side from the newest row already held, never a client-supplied lookback. PolarSyncEnvelope: type: object properties: data: $ref: "#/components/schemas/ProviderSyncOutcome" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false StravaSyncEnvelope: type: object properties: data: $ref: "#/components/schemas/ProviderSyncOutcome" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false NightscoutSyncEnvelope: type: object properties: data: $ref: "#/components/schemas/ProviderSyncOutcome" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false GoogleHealthSyncStatusEnvelope: type: object properties: data: $ref: "#/components/schemas/GoogleHealthSyncStatusResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false GoogleHealthSyncStatusResponse: anyOf: - type: object properties: runId: type: string description: Truncated to 128 chars. state: type: string enum: - in_progress - complete - partial - zero - truncated - failed - interrupted description: Falls back to `failed` when the stored value is not one of this closed set. startedAt: type: string description: ISO instant, or empty string when unset. updatedAt: type: string description: ISO instant, or empty string when unset. terminalAt: description: ISO instant the run reached a terminal state. Present only once the run has finished; absent while `state` is `in_progress`. type: string imported: type: integer minimum: 0 maximum: 9007199254740991 failed: type: boolean resources: maxItems: 16 type: array items: $ref: "#/components/schemas/GoogleHealthResourceOutcome" description: Per-collection outcome for this run, capped at 16 entries. required: - runId - state - startedAt - updatedAt - imported - failed - resources additionalProperties: false - type: "null" description: The caller's current/most-recent Google Health sync run, or null when none has ever run. `data` carries this shape directly (not nested under a named key). GoogleHealthResourceOutcome: type: object properties: resource: type: string description: Resource collection name, lower-cased and slug-sanitised (`[^a-z0-9-]` stripped), capped at 48 chars. `unknown` when the stored value was not a string. pages: type: integer minimum: 0 maximum: 9007199254740991 fetched: type: integer minimum: 0 maximum: 9007199254740991 mapped: type: integer minimum: 0 maximum: 9007199254740991 written: type: integer minimum: 0 maximum: 9007199254740991 status: type: string enum: - pending - complete - partial - empty - truncated - failed description: Falls back to `failed` when the stored value is not one of this closed set. durationMs: type: integer minimum: 0 maximum: 9007199254740991 truncated: type: boolean reasonCode: anyOf: - type: string enum: - collection_failed - token_failed - upsert_failed - rollup_failed - existing_page_limit - type: "null" description: Null when the stored value is not one of this closed set, or when the resource did not fail. required: - resource - pages - fetched - mapped - written - status - durationMs - truncated - reasonCode additionalProperties: false NightscoutConnectEnvelope: type: object properties: data: type: object properties: connected: type: boolean const: true required: - connected additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false NightscoutStatusEnvelope: type: object properties: data: $ref: "#/components/schemas/NightscoutStatusResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false NightscoutStatusResponse: type: object properties: connected: type: boolean description: A connection exists. Not a liveness claim — a pipe dead for a fortnight still answers `true`; read `syncHealth.verdict` for that. configured: type: boolean description: An instance URL is stored. The connect marker. hasToken: description: Whether an API token is stored alongside the URL. A fully-public Nightscout instance needs none, so `false` is not a misconfiguration. The token itself is never returned. type: boolean allowPrivateHost: description: The operator has approved this user's private-origin (RFC 1918 / loopback / link-local) Nightscout instance. False for every public instance. type: boolean state: deprecated: true description: "Deprecated, still served: read `health`." type: string enum: - connected - error_transient - error_reauth - disconnected - parked - unknown lastSuccessAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" lastAttemptAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" lastError: deprecated: true description: "Deprecated, still served: read `health`. Last recorded failure message, decrypted from the ledger. Null when the last attempt succeeded or none has run." type: - string - "null" syncHealth: deprecated: true description: "Deprecated, still served: read `health`." $ref: "#/components/schemas/SyncHealth" health: description: The unified connection state. Present whenever the connection exists; null in the rare case it resolved to none. Absent on the not-connected body. anyOf: - $ref: "#/components/schemas/ConnectionHealth" - type: "null" required: - connected - configured additionalProperties: false description: "Nightscout connection status. When nothing is configured the body is exactly `{ connected: false, configured: false }` — every other field is absent, not null. The stored instance URL is the user's own and is NOT echoed here; the API token never is." NightscoutTestEnvelope: type: object properties: data: $ref: "#/components/schemas/NightscoutTestResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false NightscoutTestResponse: type: object properties: ok: type: boolean const: true latencyMs: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Round-trip time of the single-entry probe, in milliseconds. required: - ok - latencyMs additionalProperties: false description: "A live probe that reached the configured instance and read one SGV entry. Only ever returned on success — a failed probe is an error status, never `ok: false`." NightscoutDisconnectEnvelope: type: object properties: data: type: object properties: disconnected: type: boolean const: true required: - disconnected additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false WhoopCredentialsStatusEnvelope: type: object properties: data: $ref: "#/components/schemas/WhoopCredentialsStatusResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false WhoopCredentialsStatusResponse: type: object properties: hasCredentials: type: boolean description: Both the client id and the client secret are stored. Neither value is ever returned. required: - hasCredentials additionalProperties: false description: "Presence check for the per-user WHOOP BYO-key credentials. Presence only: this endpoint has no read path for the stored id or secret." WhoopCredentialsUpdateEnvelope: type: object properties: data: type: object properties: updated: type: boolean const: true required: - updated additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false WhoopCredentialsDeleteEnvelope: type: object properties: data: type: object properties: deleted: type: boolean const: true required: - deleted additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false WhoopStatusEnvelope: type: object properties: data: $ref: "#/components/schemas/WhoopStatusResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false WhoopStatusResponse: type: object properties: connected: type: boolean description: A WHOOP connection row exists and carries a WHOOP user id. configured: type: boolean description: Per-user BYO-key client id AND secret are both stored. lastSyncedAt: description: Null when no sync has completed yet. anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" connectedAt: description: When the connection row was created. type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ tokenExpired: deprecated: true description: "Deprecated, still served: read `health`. The stored access token's expiry is in the past. Read straight off the row — unlike the Withings status read, this endpoint does not refresh; the sync path refreshes lazily instead." type: boolean tokenExpiresAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ backfillCompleted: description: The self-converging history backfill has walked every collection to completion. type: boolean scope: description: Space-separated OAuth scope string granted by WHOOP. Null on a legacy connection that predates scope tracking. type: - string - "null" health: description: The unified connection state. Present whenever the connection exists; null in the rare case it resolved to none. Absent on the not-connected body. anyOf: - $ref: "#/components/schemas/ConnectionHealth" - type: "null" required: - connected - configured additionalProperties: false description: "WHOOP connection status. When no connection row carries a WHOOP user id the body is exactly `{ connected: false, configured }` — every other field is absent, not null." WhoopDisconnectEnvelope: type: object properties: data: type: object properties: disconnected: type: boolean const: true required: - disconnected additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false WithingsStatusEnvelope: type: object properties: data: $ref: "#/components/schemas/WithingsStatusResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false WithingsStatusResponse: type: object properties: connected: type: boolean description: A Withings connection row exists. configured: type: boolean description: Per-user client id AND secret are both stored. lastSyncedAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" connectedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ tokenExpired: deprecated: true description: "Deprecated, still served: read `health`. True after a refresh attempt that did not produce a live token." type: boolean tokenRefreshFailed: description: The read tried to refresh an expired token and could not — either the per-user credentials are gone or Withings refused. A permanently revoked grant additionally parks the ledger at `error_reauth` as a side effect of this read. type: boolean tokenExpiresAt: description: Post-refresh expiry when the read refreshed, the stored expiry otherwise. type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ scope: description: Comma-separated OAuth scope string from the authorisation flow. Null on a legacy connection that has never re-authed. type: - string - "null" hasActivityScope: deprecated: true description: "Deprecated, still served: read `health`. Pre-computed convenience flag the reconnect banner reads. A null `scope` counts as missing activity." type: boolean health: description: The unified connection state. Present whenever the connection exists; null in the rare case it resolved to none. Absent on the not-connected body. anyOf: - $ref: "#/components/schemas/ConnectionHealth" - type: "null" required: - connected - configured additionalProperties: false description: "Withings connection status. When no connection row exists the body is exactly `{ connected: false, configured }` — every other field is absent, not null. Note that this read is not side-effect free: a token within 60 s of expiry is refreshed and re-persisted before the response is built, so a GET here can rotate the stored token." WithingsDisconnectEnvelope: type: object properties: data: type: object properties: disconnected: type: boolean const: true required: - disconnected additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false WithingsCredentialsStatusEnvelope: type: object properties: data: $ref: "#/components/schemas/CredentialsPresenceResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false CredentialsPresenceResponse: type: object properties: hasCredentials: type: boolean description: Both the client id and the client secret are stored. Neither value is ever returned by any endpoint. required: - hasCredentials additionalProperties: false description: Presence check for a provider's per-user BYO-key credentials. Presence only — the stored id and secret are write-only. WithingsCredentialsUpdateEnvelope: type: object properties: data: type: object properties: updated: type: boolean const: true required: - updated additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false WithingsCredentialsDeleteEnvelope: type: object properties: data: type: object properties: deleted: type: boolean const: true required: - deleted additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false WithingsSyncTriggerEnvelope: type: object properties: data: $ref: "#/components/schemas/SyncTriggerOutcome" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false SyncTriggerOutcome: type: object properties: imported: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Rows that reached the database this run. failed: type: boolean description: True when any part of the run did not land. outcome: type: string enum: - empty - failed - partial - success description: 'Server-resolved tone, never derived from the HTTP status: a run where everything was refused still answers 200 with `outcome: "failed"`.' fullSync: type: boolean description: Echo of the flag this run used. required: - imported - failed - outcome - fullSync additionalProperties: false description: The result of a manual sync trigger, with the flag echoed back so a client can tell an incremental run from a full one in the response alone. WhoopSyncTriggerEnvelope: type: object properties: data: $ref: "#/components/schemas/SyncTriggerOutcome" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false WhoopConnectTicketEnvelope: type: object properties: data: type: object properties: ticket: type: string description: "Opaque one-time connect ticket, returned exactly once. Treat as a secret: pass it only as the `ticket` query parameter of the connect URL, and never log it." required: - ticket additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false FitbitCredentialsStatusEnvelope: type: object properties: data: $ref: "#/components/schemas/CredentialsPresenceResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false FitbitCredentialsUpdateEnvelope: type: object properties: data: type: object properties: updated: type: boolean const: true required: - updated additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false FitbitCredentialsDeleteEnvelope: type: object properties: data: type: object properties: deleted: type: boolean const: true required: - deleted additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false FitbitStatusEnvelope: type: object properties: data: $ref: "#/components/schemas/FitbitStatusResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false FitbitStatusResponse: type: object properties: connected: type: boolean description: A connection row exists. configured: type: boolean description: Per-user BYO-key client id AND secret are both stored. lastSyncedAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" connectedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ tokenExpired: deprecated: true description: "Deprecated, still served: read `health`. Read straight off the row — this endpoint does not refresh; the sync path refreshes lazily." type: boolean tokenExpiresAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ backfillCompleted: description: The history backfill has walked every collection. type: boolean scope: description: OAuth scope string granted at authorisation. Null on a legacy connection that predates scope tracking. type: - string - "null" health: description: The unified connection state. Present whenever the connection exists; null in the rare case it resolved to none. Absent on the not-connected body. anyOf: - $ref: "#/components/schemas/ConnectionHealth" - type: "null" required: - connected - configured additionalProperties: false description: "Fitbit connection status. When no connection row exists the body is exactly `{ connected: false, configured }` — every other field is absent, not null. Pure read." FitbitDisconnectEnvelope: type: object properties: data: type: object properties: disconnected: type: boolean const: true required: - disconnected additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false FitbitSyncTriggerEnvelope: type: object properties: data: $ref: "#/components/schemas/SyncTriggerOutcome" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false GoogleHealthCredentialsStatusEnvelope: type: object properties: data: $ref: "#/components/schemas/CredentialsPresenceResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false GoogleHealthCredentialsUpdateEnvelope: type: object properties: data: type: object properties: updated: type: boolean const: true required: - updated additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false GoogleHealthCredentialsDeleteEnvelope: type: object properties: data: type: object properties: deleted: type: boolean const: true required: - deleted additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false GoogleHealthStatusEnvelope: type: object properties: data: $ref: "#/components/schemas/GoogleHealthStatusResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false GoogleHealthStatusResponse: type: object properties: connected: type: boolean description: A connection row exists. configured: type: boolean description: Per-user BYO-key client id AND secret are both stored. lastSyncedAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" connectedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ tokenExpired: deprecated: true description: "Deprecated, still served: read `health`. Read straight off the row — this endpoint does not refresh; the sync path refreshes lazily." type: boolean tokenExpiresAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ backfillCompleted: description: The history backfill has walked every collection. type: boolean scope: description: OAuth scope string granted at authorisation. Null on a legacy connection that predates scope tracking. type: - string - "null" health: description: The unified connection state. Present whenever the connection exists; null in the rare case it resolved to none. Absent on the not-connected body. anyOf: - $ref: "#/components/schemas/ConnectionHealth" - type: "null" backfillEnded: type: array items: type: object properties: type: type: string description: The measurement type whose imported history ends here (e.g. `PULSE` for heart rate). endsAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ description: The oldest instant the imported history of this type reaches back to. reason: type: string enum: - horizon - no_progress description: "`horizon`: the backfill reads back a set number of years (`GOOGLE_HEALTH_BACKFILL_HORIZON_YEARS`, twenty by default) and the history goes further. `no_progress`: Google kept returning pages that reached no further back, so the import stopped there." required: - type - endsAt - reason additionalProperties: false description: Data types whose history backfill ended before the beginning of their history, with where and why. Empty when every type was read to its start. Kept after the backfill completes; cleared on reconnect. needsReauth: deprecated: true description: "Deprecated, still served: read `health`. The stored refresh token was rejected with `invalid_grant` — Google expires it after 7 days while the OAuth client is in Testing publishing mode, and a user-revoked grant lands here too. Distinct from `parked` and from `disconnected`: the connection still exists, it just cannot mint a token until the user reconnects. Only Google Health has this field." type: boolean required: - connected - configured additionalProperties: false description: "Google Health connection status. When no connection row exists the body is exactly `{ connected: false, configured }` — every other field is absent, not null. Pure read." GoogleHealthDisconnectEnvelope: type: object properties: data: type: object properties: disconnected: type: boolean const: true required: - disconnected additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false GoogleHealthSyncTriggerEnvelope: type: object properties: data: $ref: "#/components/schemas/GoogleHealthSyncOutcome" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false GoogleHealthSyncOutcome: type: object properties: imported: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Rows that reached the database this run. failed: type: boolean description: True when any part of the run did not land. outcome: type: string enum: - empty - failed - partial - success description: 'Server-resolved tone, never derived from the HTTP status: a run where everything was refused still answers 200 with `outcome: "failed"`.' fullSync: type: boolean description: Echo of the flag this run used. runId: description: Truncated to 128 chars. Absent when the run produced none. type: string state: type: string enum: - in_progress - complete - partial - zero - truncated - failed - interrupted resources: description: Per-collection outcome, capped at 16 entries and re-sanitised on the way out exactly as the status endpoint does. maxItems: 16 type: array items: $ref: "#/components/schemas/GoogleHealthResourceOutcome" required: - imported - failed - outcome - fullSync additionalProperties: false description: "The Google Health manual-sync result: the shared outcome plus the run identity and the per-collection breakdown." OuraCredentialsStatusEnvelope: type: object properties: data: $ref: "#/components/schemas/CredentialsPresenceResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false OuraCredentialsUpdateEnvelope: type: object properties: data: type: object properties: updated: type: boolean const: true required: - updated additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false OuraCredentialsDeleteEnvelope: type: object properties: data: type: object properties: deleted: type: boolean const: true required: - deleted additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false OuraStatusEnvelope: type: object properties: data: $ref: "#/components/schemas/OAuthProviderStatusResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false OAuthProviderStatusResponse: type: object properties: connected: type: boolean description: An access token is stored. configured: type: boolean description: Mirrors `connected` exactly on these three providers. It is NOT a credentials marker — read `hasOwnCredentials` for that. available: type: boolean description: "Usable OAuth client credentials resolve for this user: the per-user BYO pair first, then the operator's shared env app. False means the connect button cannot work at all." hasOwnCredentials: type: boolean description: The user has stored their own BYO client id and secret. state: deprecated: true description: "Deprecated, still served: read `health`." type: string enum: - connected - error_transient - error_reauth - disconnected - parked - unknown lastSuccessAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" lastAttemptAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" lastError: deprecated: true description: "Deprecated, still served: read `health`." type: - string - "null" syncHealth: deprecated: true description: "Deprecated, still served: read `health`." $ref: "#/components/schemas/SyncHealth" health: description: The unified connection state. Present whenever the connection exists; null in the rare case it resolved to none. Absent on the not-connected body. anyOf: - $ref: "#/components/schemas/ConnectionHealth" - type: "null" required: - connected - configured - available - hasOwnCredentials additionalProperties: false description: "Connection status for an OAuth provider whose grant lives on the user row (Oura / Polar / Strava). When no token is stored the body is exactly `{ connected: false, configured: false, available, hasOwnCredentials }` — the ledger fields are absent, not null. Tokens and client secrets are never returned. Pure read." OuraDisconnectEnvelope: type: object properties: data: type: object properties: disconnected: type: boolean const: true required: - disconnected additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false OuraTestEnvelope: type: object properties: data: $ref: "#/components/schemas/ProviderProbeResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false ProviderProbeResponse: type: object properties: ok: type: boolean const: true latencyMs: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Round-trip time of the probe request, in milliseconds. required: - ok - latencyMs additionalProperties: false description: "A probe that reached the provider with the stored grant. Only ever returned on success — a failed probe is an error status, never `ok: false`." PolarCredentialsStatusEnvelope: type: object properties: data: $ref: "#/components/schemas/CredentialsPresenceResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false PolarCredentialsUpdateEnvelope: type: object properties: data: type: object properties: updated: type: boolean const: true required: - updated additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false PolarCredentialsDeleteEnvelope: type: object properties: data: type: object properties: deleted: type: boolean const: true required: - deleted additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false PolarStatusEnvelope: type: object properties: data: $ref: "#/components/schemas/OAuthProviderStatusResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false PolarDisconnectEnvelope: type: object properties: data: type: object properties: disconnected: type: boolean const: true required: - disconnected additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false PolarTestEnvelope: type: object properties: data: $ref: "#/components/schemas/ProviderProbeResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false StravaCredentialsStatusEnvelope: type: object properties: data: $ref: "#/components/schemas/CredentialsPresenceResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false StravaCredentialsUpdateEnvelope: type: object properties: data: type: object properties: updated: type: boolean const: true required: - updated additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false StravaCredentialsDeleteEnvelope: type: object properties: data: type: object properties: deleted: type: boolean const: true required: - deleted additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false StravaStatusEnvelope: type: object properties: data: $ref: "#/components/schemas/OAuthProviderStatusResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false StravaDisconnectEnvelope: type: object properties: data: type: object properties: disconnected: type: boolean const: true required: - disconnected additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false StravaTestEnvelope: type: object properties: data: $ref: "#/components/schemas/ProviderProbeResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false IntegrationStatusEnvelopeResponse: type: object properties: data: $ref: "#/components/schemas/IntegrationStatusEnvelope" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false IntegrationStatusEnvelope: type: object properties: threshold: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Consecutive persistent failures at which the operator is alerted. Operator-level, configurable via env. metricFreshnessDegraded: type: boolean description: "The per-metric freshness query failed, so EVERY entry's `metricFreshness` is an empty array regardless of what the record actually holds. One flag rather than one per entry, because it is a single query for every provider: it fails for all of them or for none. When true, render the freshness section as unavailable rather than as 'no metric has gone quiet' — the two used to be indistinguishable on the wire, which turned the honesty signal into the thing it was built to prevent. The rest of the envelope is unaffected and still complete." integrations: type: array items: $ref: "#/components/schemas/IntegrationStatusEntry" description: "All nine providers, always, in the fixed order withings, whoop, fitbit, google-health, polar, oura, strava, nightscout, dawarich. A provider the user has never touched still appears, with `connected: false` and the ledger state `unknown` — absence from this list never means anything, because nothing is ever absent from it." required: - threshold - metricFreshnessDegraded - integrations additionalProperties: false description: The one read behind the integrations settings surface. It carries every field the per-provider status routes carry, so a client has the choice of nine round-trips or this one; the per-provider routes remain for callers that want a single provider. IntegrationStatusEntry: type: object properties: integration: type: string enum: - withings - whoop - fitbit - google-health - polar - oura - strava - nightscout - dawarich description: Which provider this entry describes. state: type: string enum: - connected - error_transient - error_reauth - disconnected - parked - unknown description: "Deprecated, still served: read `health`." deprecated: true lastSuccessAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" lastAttemptAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" lastError: deprecated: true description: "Deprecated, still served: read `health`." type: - string - "null" consecutiveFailuresByKind: anyOf: - type: object properties: transient: type: integer minimum: -9007199254740991 maximum: 9007199254740991 reauth_required: type: integer minimum: -9007199254740991 maximum: 9007199254740991 persistent: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - transient - reauth_required - persistent additionalProperties: false - type: "null" deprecated: true description: "Deprecated, still served: read `health`. Per-kind consecutive-failure counters, or null when the row has never written a bucket payload. Compare against the top-level `threshold` to know how close the provider is to the operator alert." syncHealth: description: "Deprecated, still served: read `health`." deprecated: true $ref: "#/components/schemas/SyncHealth" metricFreshness: type: array items: $ref: "#/components/schemas/MetricFreshnessEntry" description: Per-metric-type freshness for this provider's data. Empty when the provider has delivered nothing — and also empty when the freshness read failed, because the endpoint degrades to no data rather than failing the whole envelope. Read the top-level `metricFreshnessDegraded` to tell those two apart before rendering an empty list as 'nothing has gone quiet'. connected: type: boolean configured: type: boolean connectedAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" legacyLastSyncedAt: description: The connection row's own last-sync instant, for legacy connections established before the ledger existed. Not present on the three user-row OAuth providers or on Nightscout. anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" tokenExpiresAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" tokenExpired: deprecated: true description: "Deprecated, still served: read `health`. Null — not false — when there is no connection to judge." type: - boolean - "null" backfillCompleted: type: - boolean - "null" backfillEnded: description: Google Health only. Data types whose history backfill ended before the beginning of their history, with where and why. type: array items: type: object properties: type: type: string description: The measurement type whose imported history ends here (e.g. `PULSE` for heart rate). endsAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ description: The oldest instant the imported history of this type reaches back to. reason: type: string enum: - horizon - no_progress description: "`horizon`: the backfill reads back a set number of years (`GOOGLE_HEALTH_BACKFILL_HORIZON_YEARS`, twenty by default) and the history goes further. `no_progress`: Google kept returning pages that reached no further back, so the import stopped there." required: - type - endsAt - reason additionalProperties: false scope: description: Withings only. type: - string - "null" hasActivityScope: deprecated: true description: "Deprecated, still served: read `health`. Withings only." type: boolean planUnavailable: deprecated: true description: "Deprecated, still served: read `health`. Withings only. The readings this connection's Withings API plan withholds (status 277 since the 2026-10-12 plan split), deduplicated and in display order. Empty when the plan refuses nothing. The sync skips these and keeps importing everything else; the list refreshes when the weekly re-probe asks again." type: array items: type: string enum: - spo2 - vascularAge - pulseWaveVelocity - vo2Max - visceralFat - skinTemperature - ecg - sleepScore - sleepHrv - sleepHeartRate - sleepRespiratoryRate - sleepSpo2 - bodyTemperature - bodyFat - fatMass - fatFreeMass - muscleMass - bodyWater - boneMass - heartRate - bloodPressure - weight needsReauth: deprecated: true description: "Deprecated, still served: read `health`. Google Health only." type: boolean available: description: Oura / Polar / Strava only. type: boolean hasOwnCredentials: description: Oura / Polar / Strava only. type: boolean scopeReconnect: deprecated: true description: "Deprecated, still served: read `health`. Oura only. Collections the stored grant does not cover: Oura refused them for a missing scope (named in its 401, or inferred when sibling collections succeeded with the same token), or the scopes Oura reported at connect leave theirs out. Not a failure and not counted toward the reconnect threshold; one reconnect asks for the missing scopes. Empty when the grant covers everything HealthLog reads." type: array items: type: string enum: - spo2 - vo2max - cardio_age - resilience - workout - heartrate hasToken: description: Nightscout only. type: boolean allowPrivateHost: description: Nightscout only. type: boolean retirement: description: Fitbit only. The end of the legacy Fitbit Web API. type: object properties: endsOn: type: string pattern: ^\d{4}-\d{2}-\d{2}$ description: The day Google turns the legacy Fitbit Web API off. ended: type: boolean description: "The day has come: the connection no longer syncs, its imported data stays, and `health` is null." successor: type: string enum: - google-health description: The integration that reads the same Fitbit and Pixel devices. Moving needs a fresh consent; the Fitbit grant cannot be carried over. offered: type: boolean description: "Whether the Fitbit card is shown. False once `ended` for an account that never connected Fitbit, has no Fitbit app of its own and holds no Fitbit data: there is nothing to explain the move for. True before the turndown and for every account that had Fitbit." required: - endsOn - ended - successor - offered additionalProperties: false health: anyOf: - $ref: "#/components/schemas/ConnectionHealth" - type: "null" description: The unified connection state, null when the provider is not connected (and for Fitbit once `retirement.ended`). It supersedes `state`, `lastError`, `consecutiveFailuresByKind`, `syncHealth`, `tokenExpired`, `needsReauth`, `hasActivityScope`, `planUnavailable` and `scopeReconnect` on this entry; those nine are deprecated and still served for clients that predate `health`. required: - integration - state - lastSuccessAt - lastAttemptAt - lastError - consecutiveFailuresByKind - syncHealth - metricFreshness - connected - configured - health additionalProperties: false description: "One provider's entry in the consolidated envelope: the ledger core and the liveness verdict, which every entry carries, plus the extras that provider has. The optional fields are not optional per request — each is present exactly for the providers named in its description and absent for the rest, so branch on `integration`, not on presence." MetricFreshnessEntry: type: object properties: type: type: string description: The measurement type (e.g. `RESPIRATORY_RATE`), `WORKOUTS` for the workout leg, or (Apple Health only, since v1.42.4) `MEDICATION_DOSE` for medication doses imported from Apple Health. Clients should show or skip a type they do not know rather than fail. lastSeenAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ description: Newest recorded reading for this type from this source. stale: type: boolean description: This type has gone quiet while the source around it reads healthy — the dead-pipe signature (e.g. a single revoked HealthKit permission). Only ever true when the verdict is `fresh`. required: - type - lastSeenAt - stale additionalProperties: false description: Per-metric-type last-seen timestamp with the server-computed staleness flag. Only types that have actually delivered appear — absence is absence, never an invented row. FitbitResumeEnvelope: type: object properties: data: $ref: "#/components/schemas/IntegrationResumeResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false IntegrationResumeResponse: type: object properties: resumed: type: boolean const: true wasParked: type: boolean description: Whether the ledger was actually parked before this call. False means the call changed nothing — render it as a no-op rather than a 'resumed' confirmation. required: - resumed - wasParked additionalProperties: false description: "The result of clearing a park. Idempotent: a second call answers 200 with `wasParked: false` and writes no further audit row." FitbitIntegrationTestEnvelope: type: object properties: data: $ref: "#/components/schemas/IntegrationProbeResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false IntegrationProbeResponse: type: object properties: ok: type: boolean const: true lastSyncedAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" description: The connection row's last successful sync; null when it has never synced. Present here and absent from the `/api//test` family — the one shape difference between the two probe families. latencyMs: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - ok - lastSyncedAt - latencyMs additionalProperties: false description: A probe that reached the provider with a freshly-resolved access token, plus the connection's last sync instant. GoogleHealthResumeEnvelope: type: object properties: data: $ref: "#/components/schemas/IntegrationResumeResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false GoogleHealthIntegrationTestEnvelope: type: object properties: data: anyOf: - $ref: "#/components/schemas/IntegrationProbeResponse" - $ref: "#/components/schemas/GoogleHealthStructureProbeResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false GoogleHealthStructureProbeResponse: type: object properties: probe: type: string const: structure probedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ types: type: object propertyNames: type: string additionalProperties: {} description: "One entry per Google Health data type. A successful entry carries `{ ok: true, count, structure }` plus, where the client learned it, `requestShape` (which documented request form Google accepted) and `envelopeKeys` (raw first-page key names, present only when the parse yielded zero points — the one signal separating 'nothing returned' from 'returned under a key this reader does not know'). A failed entry carries `{ ok: false, httpStatus, classification, detail }`. `structure` is the first data point with every leaf replaced by its type name." required: - probe - probedAt - types additionalProperties: false WhoopResumeEnvelope: type: object properties: data: $ref: "#/components/schemas/IntegrationResumeResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false WhoopIntegrationTestEnvelope: type: object properties: data: $ref: "#/components/schemas/IntegrationProbeResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false WithingsResumeEnvelope: type: object properties: data: $ref: "#/components/schemas/IntegrationResumeResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false WithingsIntegrationTestEnvelope: type: object properties: data: $ref: "#/components/schemas/IntegrationProbeResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DailyDigestEnvelope: type: object properties: data: $ref: "#/components/schemas/DailyDigest" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DailyDigest: type: object properties: generatedAt: type: string description: ISO-8601 instant the digest was read. phase: type: string enum: - provisional - final description: Freshness lifecycle — 'final' once last night's sleep is in. sleepPending: type: boolean description: Sleep tracked but last night not yet recorded. score: anyOf: - type: object properties: value: type: number band: type: string delta: type: - number - "null" configured: description: True when the account's own recipe narrows the score's composition below what its defaults would resolve to today. Resolved on the server and carried straight from the dashboard snapshot; the configuration itself never rides this wire. Optional so older cached digests without the field stay valid. type: boolean deltaReason: description: Why a delta was suppressed; null when it was not. type: - string - "null" scoreVersion: description: Algorithm version that produced the value. type: integer exclusiveMinimum: 0 maximum: 9007199254740991 composition: description: Registry-ordered pillar ids the value was composed of. type: array items: type: string scoreBasis: description: What the value rests on. Since v1.38 a score is produced from one or two areas of health as well as three, so this is what distinguishes a narrow score from a broad one. Optional so older cached digests without the field stay valid. $ref: "#/components/schemas/HealthScoreBasis" steadyWeeks: description: Whole weeks the score has held where it is (same band, within two points, computed the same way), read off the stored daily scores. Null when it has not held for two weeks, when the stored record does not reach today, or when the score on screen differs from the newest stored day in value, band, algorithm version or composition. Shown in place of a delta, never beside one. anyOf: - type: integer exclusiveMinimum: 0 maximum: 9007199254740991 - type: "null" steadyAtLeast: description: "True when the run reaches back past everything the read covered (120 days): `steadyWeeks` is then a lower bound, said as \"at least\", and the run's start is unknown. Absent on older cached digests." type: boolean required: - value - band - delta additionalProperties: false - type: "null" description: Health score + band + week-over-week delta, plus whether the account authored the composition and how broad the set behind it is; null when none. topSignal: anyOf: - type: object properties: sourceMetric: type: string tone: type: string enum: - good - watch - info headline: type: string nudge: type: string delta: type: - string - "null" required: - sourceMetric - tone - headline - nudge - delta additionalProperties: false - type: "null" description: Clinical-priority top signal, lifted from the cached briefing. Null while the `briefing` capability is unavailable. briefingLead: description: "The first sentence of the cached briefing paragraph that says something: a greeting or a sentence that only repeats the score is skipped. Null while the `briefing` capability is unavailable or when no sentence qualifies; `line` then falls to its deterministic floor." type: - string - "null" lead: anyOf: - type: object properties: text: type: string source: type: string enum: - reaction - briefing - signal description: "`reaction` and `briefing` are cached model text; `signal` is the deterministic sentence about the day's strongest signal." required: - text - source additionalProperties: false - type: "null" description: "The Today hero's lead line, resolved: the reaction line or a briefing sentence while their capabilities are available, otherwise a deterministic sentence about the day's strongest signal (a vital outside its personal range, an unusual night, vitals in range, last night). Null when there is nothing to say. When the deterministic sentence is built from a fact, that fact is left out of `today`. Additive since v1.40." signalLine: anyOf: - type: object properties: headline: description: The top signal's headline, or null when the lead already talks about its metric. type: - string - "null" delta: description: The top signal's delta, when it has one. type: - string - "null" required: - headline - delta additionalProperties: false - type: "null" description: "The muted line under an AI lead, decided on the server so every client shows the same one: the top signal minus what the lead already says. Null under a deterministic lead, without a top signal, or when the lead already says everything the signal would. Additive since v1.40.3." today: maxItems: 5 type: array items: $ref: "#/components/schemas/DailyTodayFact" description: "The Today overview: up to 5 statements about the day in priority order (Rest Mode, medications taken today, an appointment today or tomorrow that is not already on the rail, last night's sleep, vitals against their personal range, cycle day), never padded. A fact about a switched-off module is absent. A narrow surface shows the first 4. Additive since v1.40." restMode: anyOf: - type: object properties: day: type: integer exclusiveMinimum: 0 maximum: 9007199254740991 description: 1-based day of the episode on the reader's calendar. required: - day additionalProperties: false - type: "null" description: "Rest Mode: present while an illness episode that is not chronic ongoing (acute, recurring or flare) is active and the illness module is on, else null. A chronic ongoing condition alone never sets it; `day` counts from the earliest onset among the episodes that do. Additive since v1.40." line: type: string description: "Push / lock-screen line: cached-AI lead with a deterministic floor. Carries no number (no reading, dose or score), since it is shown on a locked phone; the in-app `lead` keeps its figures." worthALook: type: array items: $ref: "#/components/schemas/DailyPriorityItem" description: Bounded 0–3 rail items, never padded. justIn: anyOf: - type: object properties: kind: type: string enum: - sleep_night - workout - weight - blood_pressure - labs_panel description: Closed arrival kind that landed. at: type: string description: ISO-8601 instant of the newest sample. NEVER pre-formatted server-side — the client formats it in its own locale and timezone. required: - kind - at additionalProperties: false - type: "null" description: The day's newest data arrival while it is still news (under three hours old), else null. Additive since v1.31.0. reactionLine: description: One-sentence generated reaction to that arrival, standing for the rest of the local day. Null whenever no line was generated (no provider, no consent, budget exhausted, or generation failed), and whenever the `reactionLines` capability is unavailable, however recent the stored line — consumers fall back to `briefingLead` / `line`. Additive since v1.31.0. type: - string - "null" ai: type: object properties: briefing: $ref: "#/components/schemas/AiCapabilityState" coach: $ref: "#/components/schemas/AiCapabilityState" reactionLines: $ref: "#/components/schemas/AiCapabilityState" required: - briefing - coach - reactionLines additionalProperties: false description: "The three AI capabilities the digest carries text or a card for: `briefing` (the lead and top signal), `coach` (the Coach check-in card, which opens the Coach) and `reactionLines` (the reaction line). Everything else in the digest is data." required: - generatedAt - phase - sleepPending - score - topSignal - briefingLead - lead - signalLine - today - restMode - line - worthALook - justIn - reactionLine - ai additionalProperties: false description: The one data spine of the daily-value system — composed from already-cached data, never a fresh AI call. Consumed by the Today surface, the daily push, and a future iOS widget. DailyTodayFact: type: object properties: kind: type: string enum: - rest_mode - medications - appointment - sleep - vitals - cycle description: Closed fact kind. label: type: string description: Localised label (resolved server-side). value: type: string description: Localised value (resolved server-side). href: type: string description: The page the fact belongs to. moduleKey: description: The module that admitted the fact; absent for core data. type: string required: - kind - label - value - href additionalProperties: false description: One statement about the day on the Today overview. A fact never asks the reader to do anything; the rail is the only place that does. DailyPriorityItem: type: object properties: kind: type: string enum: - coach_checkin - dose_window - preventive_care - sync_issue - milestone - ecg_new_recording - tension_window - same_time_baseline - upcoming_visit description: Closed rail-item kind. itemKey: description: Stable dismiss identity, namespaced `:...`. Present only on the observational kinds (milestone / ecg_new_recording / tension_window) — absent on the actionable kinds, which are never dismissible. type: string title: type: string description: Localised headline (resolved server-side). body: description: Grounded one-liner, rendered as plain text. type: string status: description: Semantic status wash — meaning, not decoration. type: string enum: - success - warning - info - destructive actions: maxItems: 3 type: array items: type: object properties: labelKey: type: string description: i18n key, resolved client-side. intent: type: string description: Stable action token. href: description: Deep-link when navigational. type: string required: - labelKey - intent additionalProperties: false description: 1–3 one-tap actions. moduleKey: description: Provenance of the gate that admitted the item. type: string required: - kind - title - actions additionalProperties: false description: One 'worth a look' rail item. The single model every daily-value consumer renders through PriorityCard. DismissPriorityItemEnvelope: type: object properties: data: type: object properties: dismissed: type: boolean const: true required: - dismissed additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false ChartOverlayPrefsSavedEnvelope: type: object properties: data: type: object properties: saved: type: boolean const: true required: - saved additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DashboardSummaryEnvelope: type: object properties: data: $ref: "#/components/schemas/DashboardSummaryResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DashboardSummaryResponse: type: object properties: greeting: type: object properties: salutation: type: string description: Already-localised greeting including the display name (or username). One of the few strings this payload still translates server-side, in the account's own locale. date: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ description: Build instant. required: - salutation - date additionalProperties: false description: Header block. streak: type: object properties: currentDays: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Consecutive logging days up to today in the account's display timezone. Yesterday still counts while today is unlogged, so the streak does not visibly break before the day is over. longest: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Longest run inside the trailing 365 days — NOT all time. label: type: string description: Already-localised caption. required: - currentDays - longest - label additionalProperties: false description: Logging streak over measurements plus resolved medication intakes (taken or skipped). compliance: type: object properties: scheduledToday: type: integer minimum: -9007199254740991 maximum: 9007199254740991 takenToday: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - scheduledToday - takenToday additionalProperties: false description: Today's dose tally from the shared projector — every today-window slot, and the ones with a `takenAt` that were not skipped. highlightInsight: type: "null" description: Reserved. The handler pins this to null unconditionally; no code path has ever populated it. Treat it as absent rather than as 'no insight today'. metrics: type: array items: $ref: "#/components/schemas/DashboardMetricCard" description: The metric tiles. Filtered against the account's module map AFTER the cache read, so a card whose measurement type belongs to a disabled module is OMITTED rather than refused — the same choice the snapshot sibling and the sync feed make, because refusing the whole aggregate would blank the dashboard over one disabled module. sleepRhythm: description: The same server-authoritative sleep-debt + chronotype + average-per-night DTO `GET /api/sleep/rhythm` returns, off the same canonical night reconstruction. Always present, with its own calm `partial` / `learning` states — it is NOT gated by the sleep module here, so it can carry figures while the sleep metric CARD is filtered out. $ref: "#/components/schemas/SleepRhythmResource" lastUpdated: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ description: Build instant of the cached body. required: - greeting - streak - compliance - highlightInsight - metrics - sleepRhythm - lastUpdated additionalProperties: false description: "The native client's dashboard aggregator: greeting, logging streak, today's dose tally, the per-metric tiles, and the sleep-rhythm DTO. Served through a 60 s fresh / 1 h stale read-through cache, so `greeting.date` and `lastUpdated` can be up to an hour old on a stale-window serve; writes mark the bucket so the account's own action lands on the very next read. No LLM is reachable from this path." DashboardMetricCard: type: object properties: id: type: string description: Stable card id. Equal to `kind` for every card except blood pressure, whose id is `bp`. kind: $ref: "#/components/schemas/DashboardMetricKind" titleKey: type: string description: i18n key for the metric's title (e.g. `dashboard.metric.title.weight`). The wire is language-neutral — resolve the key against your own bundle. latestValue: description: Most recent reading regardless of age, systolic for `bloodPressure`, last night's time asleep in HOURS for `sleep`, the latest daily mean for `mood`, and the derived figure for `bmi`. Null when the card is alive on history alone. type: - number - "null" secondaryValue: description: Diastolic value on the `bloodPressure` card; null on every other kind. type: - number - "null" unitKey: type: string description: i18n key for the display unit, resolved the same way. unit: description: Explicit unit token, set only on the `sleep` card (`h`, because its value is a per-night total in hours rather than the canonical SLEEP_DURATION minutes). Null on every other kind. type: - string - "null" sleepStages: anyOf: - type: object propertyNames: type: string additionalProperties: type: number - type: "null" description: Per-stage HOURS for the headline night, sleep card only. Null for every other kind and for a legacy bare-duration night with no stage rows. sleepSourceDiscrepancy: anyOf: - type: object properties: deltaMinutes: type: integer minimum: 0 maximum: 9007199254740991 sources: type: array items: type: object properties: source: type: string deviceType: type: - string - "null" asleepMinutes: type: integer minimum: 0 maximum: 9007199254740991 required: - source - deviceType - asleepMinutes additionalProperties: false required: - deltaMinutes - sources additionalProperties: false - type: "null" description: Non-null on the sleep card when two writer buckets reported clearly different asleep totals for the headline night's main session. Observational only — `latestValue` stays the winning writer's total; render a discreet 'sources disagree' hint. Null on every other kind and when the writers agree. trend: type: string enum: - up - down - flat - unknown description: First-to-last direction across `sparkline`, with a 1 % (min 1 unit) dead band. `unknown` under two points. sparkline: type: array items: type: number description: "Up to seven points: the last seven DAY rollup buckets for a measured metric regardless of age (daily sum for cumulative metrics such as steps, daily mean otherwise), the trailing nights' asleep hours for `sleep`, the last seven daily means for `mood`, and the trailing derived series for `bmi`. May be empty." updatedAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" description: Instant behind `latestValue`, falling back to the metric's newest reading. For `mood` this is the first instant of the latest daily bucket's day in the account's time zone, not a reading time (the series pins a local day, not an instant); formatted in that zone it always lands on the day the entry was logged. For `bmi` it is the WEIGHT metadata, since BMI moves exactly when weight does. allTimeCount: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Total readings ever logged for this metric — the gate that keeps a tile visible through a logging gap. Summed across both sides for `bloodPressure`; the mood entry count for `mood`; the WEIGHT count for `bmi`. lastSeenAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" description: The metric's newest reading, for the 'last reading N days ago' caption when it is older than the sparkline window. direction: description: v1.43 — which way a change of this metric counts. anyOf: - $ref: "#/components/schemas/TrendVerdict" - type: "null" trendSentiment: description: v1.43 — the verdict applied to this card's `trend`. $ref: "#/components/schemas/TrendSentiment" required: - id - kind - titleKey - latestValue - secondaryValue - unitKey - unit - sleepStages - sleepSourceDiscrepancy - trend - sparkline - updatedAt - allTimeCount - lastSeenAt additionalProperties: false description: One dashboard metric tile, normalised to the iOS-friendly `kind` rather than the canonical measurement-type enum. A card appears only when the metric has a reading or any history at all, so the array's LENGTH and MEMBERSHIP both vary per account — do not index it positionally. DashboardMetricKind: type: string enum: - weight - bloodPressure - pulse - bodyFat - glucose - sleep - steps - totalBodyWater - boneMass - oxygenSaturation - mood - bmi DashboardLayout: type: object properties: data: $ref: "#/components/schemas/DashboardLayoutReadResult" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DashboardLayoutReadResult: type: object properties: version: type: number const: 1 widgets: minItems: 1 maxItems: 50 type: array items: type: object properties: id: type: string enum: - weight - bp - pulse - bodyFat - mood - medications - sleep - steps - glucose - totalBodyWater - boneMass - muscleMass - bpInTarget - oxygenSaturation - hrv - respiratoryRate - achievements - vo2Max - recentWorkouts - cardioRecovery - sixMinuteWalk - stairAscentSpeed - stairDescentSpeed - breathingDisturbances - wristTemperature - falls - walkingSteadiness - vorsorge - waterIntake - restingHeartRate - walkingSpeed - walkingAsymmetry - walkingStepLength - bmi - bodyTemperature - walkingDoubleSupport - audioExposureEnvironment - audioExposureHeadphone - gripStrength - painNRS - waistCircumference - waistToHeight visible: type: boolean tileVisible: type: boolean order: type: integer minimum: 0 maximum: 99 required: - id - visible - order additionalProperties: false comparisonBaseline: type: string enum: - none - lastMonth - lastYear chartOverlayPrefs: type: object propertyNames: type: string enum: - bp - weight - bmi - pulse - bodyFat - mood - medications - sleep - steps - vo2Max - hrv - restingHr - oxygenSaturation - bodyTemperature - activeEnergy - bloodGlucose - totalBodyWater - boneMass - flightsClimbed - walkingRunningDistance - fatFreeMass - fatMass - muscleMass - skinTemperature - pulseWaveVelocity - vascularAge - visceralFat - audioExposureEnv - audioExposureHeadphone - timeInDaylight - walkingSteadiness - audioExposureEvent - respiratoryRate - leanBodyMass - walkingHeartRateAverage - walkingAsymmetry - walkingDoubleSupport - walkingStepLength - walkingSpeed - cardioRecovery - wristTemperature - fallCount - sixMinuteWalkDistance - stairAscentSpeed - stairDescentSpeed - breathingDisturbances - ansCharge - dayStrain - workoutStrain - cardioLoad - averageHeartRate - maxHeartRate - energyExpenditureKj - gripStrength - painNrs - waistCircumference - waistToHeight - scoreHealth - scoreReadiness - scoreRecovery - scoreSleep - scoreStress - scoreStrain additionalProperties: type: object properties: showTrendIndicator: type: boolean showTrendArrow: type: boolean showTargetRange: type: boolean comparisonBaseline: type: string enum: - none - lastMonth - lastYear rangePoints: anyOf: - type: number const: 0 - type: number const: 7 - type: number const: 30 - type: number const: 90 required: - showTrendIndicator - showTrendArrow - showTargetRange additionalProperties: false selectedScoreRings: maxItems: 3 type: array items: type: string enum: - READINESS - RECOVERY_SCORE - SLEEP_SCORE - MED_COMPLIANCE heroRingOrder: maxItems: 4 type: array items: type: string enum: - HEALTH_SCORE - READINESS - RECOVERY_SCORE - SLEEP_SCORE - MED_COMPLIANCE enabledHeroItemKinds: description: Today-highlight item kinds in catalogue order. Missing means every current kind is enabled; an empty array disables all kinds. maxItems: 9 type: array items: type: string enum: - coach_checkin - dose_window - preventive_care - sync_issue - milestone - ecg_new_recording - tension_window - same_time_baseline - upcoming_visit hero: description: 'Primary hero-card content on the dashboard: "score" (the health-score read, default) or "reminders" (the Today-highlight rail promoted into the hero slot). Missing resolves to "score"; the stored default is omitted from responses.' type: string enum: - score - reminders todayCardVisible: description: v1.42 — whether the dashboard shows its top card (the Today card above the tiles). Missing resolves to `true`; only an explicit `false` hides it. The stored default is omitted. A layout reset (DELETE) shows it again. Additive. type: boolean updatedAt: description: "Optimistic-concurrency token: the stored row's `updatedAt` at read/write time. Echo it back as `baseUpdatedAt` on the next write. Opaque." type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ unavailableWidgetIds: description: "Widget ids whose toggle cannot do anything for this account, and which a settings UI should therefore not offer — a switch over a tile that can never paint. Resolved per request from the caller's enabled modules and the data they actually have: `hrv` appears here when the Recovery module is off and the account has no SDNN series, because its only remaining HRV source (nightly RMSSD) is recovery-owned and withheld. Absent when nothing is unavailable. Additive: a client that ignores it behaves exactly as before." type: array items: type: string required: - version additionalProperties: false description: Resolved dashboard layout, its `updatedAt` token, and any widget toggles that are unavailable for this account. DashboardLayoutSaved: type: object properties: data: $ref: "#/components/schemas/DashboardLayoutResult" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DashboardLayoutResult: type: object properties: version: type: number const: 1 widgets: minItems: 1 maxItems: 50 type: array items: type: object properties: id: type: string enum: - weight - bp - pulse - bodyFat - mood - medications - sleep - steps - glucose - totalBodyWater - boneMass - muscleMass - bpInTarget - oxygenSaturation - hrv - respiratoryRate - achievements - vo2Max - recentWorkouts - cardioRecovery - sixMinuteWalk - stairAscentSpeed - stairDescentSpeed - breathingDisturbances - wristTemperature - falls - walkingSteadiness - vorsorge - waterIntake - restingHeartRate - walkingSpeed - walkingAsymmetry - walkingStepLength - bmi - bodyTemperature - walkingDoubleSupport - audioExposureEnvironment - audioExposureHeadphone - gripStrength - painNRS - waistCircumference - waistToHeight visible: type: boolean tileVisible: type: boolean order: type: integer minimum: 0 maximum: 99 required: - id - visible - order additionalProperties: false comparisonBaseline: type: string enum: - none - lastMonth - lastYear chartOverlayPrefs: type: object propertyNames: type: string enum: - bp - weight - bmi - pulse - bodyFat - mood - medications - sleep - steps - vo2Max - hrv - restingHr - oxygenSaturation - bodyTemperature - activeEnergy - bloodGlucose - totalBodyWater - boneMass - flightsClimbed - walkingRunningDistance - fatFreeMass - fatMass - muscleMass - skinTemperature - pulseWaveVelocity - vascularAge - visceralFat - audioExposureEnv - audioExposureHeadphone - timeInDaylight - walkingSteadiness - audioExposureEvent - respiratoryRate - leanBodyMass - walkingHeartRateAverage - walkingAsymmetry - walkingDoubleSupport - walkingStepLength - walkingSpeed - cardioRecovery - wristTemperature - fallCount - sixMinuteWalkDistance - stairAscentSpeed - stairDescentSpeed - breathingDisturbances - ansCharge - dayStrain - workoutStrain - cardioLoad - averageHeartRate - maxHeartRate - energyExpenditureKj - gripStrength - painNrs - waistCircumference - waistToHeight - scoreHealth - scoreReadiness - scoreRecovery - scoreSleep - scoreStress - scoreStrain additionalProperties: type: object properties: showTrendIndicator: type: boolean showTrendArrow: type: boolean showTargetRange: type: boolean comparisonBaseline: type: string enum: - none - lastMonth - lastYear rangePoints: anyOf: - type: number const: 0 - type: number const: 7 - type: number const: 30 - type: number const: 90 required: - showTrendIndicator - showTrendArrow - showTargetRange additionalProperties: false selectedScoreRings: maxItems: 3 type: array items: type: string enum: - READINESS - RECOVERY_SCORE - SLEEP_SCORE - MED_COMPLIANCE heroRingOrder: maxItems: 4 type: array items: type: string enum: - HEALTH_SCORE - READINESS - RECOVERY_SCORE - SLEEP_SCORE - MED_COMPLIANCE enabledHeroItemKinds: description: Today-highlight item kinds in catalogue order. Missing means every current kind is enabled; an empty array disables all kinds. maxItems: 9 type: array items: type: string enum: - coach_checkin - dose_window - preventive_care - sync_issue - milestone - ecg_new_recording - tension_window - same_time_baseline - upcoming_visit hero: description: 'Primary hero-card content on the dashboard: "score" (the health-score read, default) or "reminders" (the Today-highlight rail promoted into the hero slot). Missing resolves to "score"; the stored default is omitted from responses.' type: string enum: - score - reminders todayCardVisible: description: v1.42 — whether the dashboard shows its top card (the Today card above the tiles). Missing resolves to `true`; only an explicit `false` hides it. The stored default is omitted. A layout reset (DELETE) shows it again. Additive. type: boolean updatedAt: description: "Optimistic-concurrency token: the stored row's `updatedAt` at read/write time. Echo it back as `baseUpdatedAt` on the next write. Opaque." type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - version additionalProperties: false description: Resolved dashboard layout plus the optimistic-concurrency `updatedAt` token. DashboardLayoutReset: type: object properties: data: $ref: "#/components/schemas/DashboardLayoutResult" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false ManagedProfileHandoverStatusEnvelope: type: object properties: data: $ref: "#/components/schemas/ManagedProfileHandoverStatus" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false ManagedProfileHandoverStatus: type: object properties: available: type: boolean description: Whether a handover can be created on this instance. Up to v1.42 false on an instance that allows single sign-on only; from v1.43 true whenever `methods` is not empty. methods: description: "v1.43 — how a link can be redeemed here: `password` (not on a single-sign-on-only instance) and `sso` (whenever an identity provider is configured). Absent on older servers." type: array items: type: string enum: - password - sso open: anyOf: - type: object properties: id: type: string createdAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ expiresAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ createdByYou: type: boolean required: - id - createdAt - expiresAt - createdByYou additionalProperties: false - type: "null" description: "The profile's open link, or null. Never the link itself: only its hash is stored." required: - available - open additionalProperties: false ManagedProfileHandoverEnvelope: type: object properties: data: $ref: "#/components/schemas/ManagedProfileHandover" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false ManagedProfileHandover: type: object properties: id: type: string token: type: string description: The one-time `hlp_` token. Returned only in this response and never again; only its hash is stored. url: type: string description: "The link to hand over: `/claim/`." expiresAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - id - token - url - expiresAt additionalProperties: false RevokeManagedProfileHandoverEnvelope: type: object properties: data: type: object properties: revoked: type: boolean required: - revoked additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false PendingHandoverDecisionEnvelope: type: object properties: data: $ref: "#/components/schemas/PendingHandoverDecision" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false PendingHandoverDecision: type: object properties: pending: anyOf: - type: object properties: claimedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ guardians: type: array items: type: object properties: grantId: type: string description: The guardian's grant at the claim; what a decision names. displayName: type: string proposal: $ref: "#/components/schemas/ManagedProfileHandoverAccess" current: $ref: "#/components/schemas/ManagedProfileHandoverAccess" decidable: type: boolean description: False when the access moved since the claim (the guardian stepped away, or it was changed under shared access); such a row is shown and left alone. required: - grantId - displayName - proposal - current - decidable additionalProperties: false required: - claimedAt - guardians additionalProperties: false - type: "null" required: - pending additionalProperties: false ManagedProfileHandoverAccess: type: string enum: - end - read - manage description: "What a guardian's access becomes after the handover: ended (`end`), view only (`read`), or still managing (`manage`)." HandoverDecisionEnvelope: type: object properties: data: type: object properties: decided: type: boolean const: true changed: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: How many guardians' access this changed. required: - decided - changed additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false AccountPayloadEnvelope: type: object properties: data: $ref: "#/components/schemas/AccountPayload" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false AccountPayload: type: object properties: id: type: string username: type: string timezone: type: string description: "The IANA zone the server cuts this account's days in: the stored zone, or the instance default when the stored one is unusable. Resolved, never the raw column, so bucket day keys by this value." glucoseUnit: anyOf: - type: string enum: - mg/dL - mmol/L - type: "null" description: The account's chosen glucose display unit, as stored. `null` means the person never chose one (or the stored value is neither unit), and the server then shows glucose in mg/dL. features: type: object properties: trackIntake: type: boolean const: true description: "Medications take `trackIntake`: a medication can be kept as a record whose doses are never due, and a dose written against one is refused with `medication.intake.notTracked`." cumulativeHourBuckets: description: "v1.43 — `POST /api/measurements/batch` takes hourly `stats::T` rows for steps, active energy, walking and running distance and flights climbed. Send them only when this is `true`: an older server stores every hour as its own measurement." type: boolean syncTriggerBackfill: description: 'v1.43 — `syncTrigger: "backfill"` is accepted on the measurement batch and the intake bulk. Send it only when this is `true`: an older server refuses the whole batch.' type: boolean biomarkerMerge: description: v1.43 — `POST /api/biomarkers/{id}/merge` is available. type: boolean lifeCircumstances: description: v1.43 — the life-circumstances routes under `/api/anamnesis/life-circumstances` and `/api/anamnesis/audit-c` are available. type: boolean required: - trackIntake additionalProperties: false description: What this server understands, for a client released apart from it. Constant across accounts. A flag that is absent or `false` means this server does not have the feature; a client checks for `true` before it calls a route or sends a request form the flag names. Absent on servers older than v1.42.0. A client that meets a flag it does not know ignores it. accountAccess: $ref: "#/components/schemas/AccountAccess" moduleAccess: $ref: "#/components/schemas/ModuleAccessMap" ai: $ref: "#/components/schemas/AiAccountBlock" coachReasoning: $ref: "#/components/schemas/CoachReasoningState" recordSession: anyOf: - $ref: "#/components/schemas/RecordSessionState" - type: "null" notificationPrefs: type: object properties: medication: type: object properties: clientManaged: type: boolean required: - clientManaged additionalProperties: {} required: - medication additionalProperties: {} onboarding: $ref: "#/components/schemas/OnboardingState" required: - id - username - timezone - glucoseUnit - features - accountAccess - moduleAccess - ai - coachReasoning - recordSession - notificationPrefs - onboarding additionalProperties: {} description: "The signed-in account: its identity, its preferences, and (since v1.36.0) what account sharing lets it do. Additional properties are the preference fields this spec does not yet enumerate. Under an active switch the identity and preference fields still describe the CALLER, because display preferences belong to the person at the keyboard rather than to the record they are reading. Two fields are the exception (v1.38.14): `modules` and `cycleTrackingEnabled` describe the ACTIVE RECORD, since every surface they gate shows the record's data — and they are masked to the sections the active grant opens, so a scoped grant reads `false` for a module outside it rather than the record's true state. With no switch — which is every native request, since the Bearer transport carries none — the two are the same account and nothing is masked. `moduleAccess` says WHY each module is or is not there — the record's own switch, the edge of the grant, or the operator's instance-wide one — beside the `modules` booleans it never contradicts. `ai` (v1.39) is the resolved AI capability map for the same record, with the same masking; it replaces reading `GET /api/feature-flags`. `coachReasoning` (v1.41) is the Coach's resolved thinking depth: the caller's own preference against the operator's reasoning controls and the record's provider." AccountAccess: type: object properties: accounts: type: array items: type: object properties: accountId: type: string minLength: 1 username: type: string minLength: 1 displayName: type: - string - "null" fullName: type: - string - "null" access: type: string enum: - read - write level: type: string enum: - read - write - manage recordKind: type: string enum: - shared - managed sections: anyOf: - type: array items: type: string enum: - measurements - medications - labs - profile - illness - mind - cycle - documents - type: "null" canWrite: type: boolean writableDomains: type: array items: type: string enum: - measurements - medications - labs - profile - illness - mind - cycle - documents manageableDomains: type: array items: type: string enum: - measurements - medications - labs - profile - illness - mind - cycle - documents required: - accountId - username - displayName - fullName - access - level - recordKind - sections - canWrite - writableDomains - manageableDomains additionalProperties: false active: anyOf: - type: object properties: accountId: type: string minLength: 1 username: type: string minLength: 1 displayName: type: - string - "null" fullName: type: - string - "null" access: type: string enum: - read - write level: type: string enum: - read - write - manage recordKind: type: string enum: - shared - managed sections: anyOf: - type: array items: type: string enum: - measurements - medications - labs - profile - illness - mind - cycle - documents - type: "null" canWrite: type: boolean writableDomains: type: array items: type: string enum: - measurements - medications - labs - profile - illness - mind - cycle - documents manageableDomains: type: array items: type: string enum: - measurements - medications - labs - profile - illness - mind - cycle - documents required: - accountId - username - displayName - fullName - access - level - recordKind - sections - canWrite - writableDomains - manageableDomains additionalProperties: false - type: "null" recordKind: type: string enum: - self - shared - managed canSwitch: type: boolean required: - accounts - active - recordKind - canSwitch additionalProperties: false description: "Account sharing, resolved. This block is the ONLY source of switchability and writability: the server publishes what the caller may do and the client renders it. A client that computed either from grant data would be a second program deciding one person's access to a health record, and the two answer differently the first time an expiry or a revocation appears. Always present — an account nobody has shared with gets an empty list, not a missing field." ModuleAccessMap: type: object properties: cycle: type: string enum: - enabled - disabled - not_granted - unavailable description: Why the "cycle" module is or is not available here. mood: type: string enum: - enabled - disabled - not_granted - unavailable description: Why the "mood" module is or is not available here. sleep: type: string enum: - enabled - disabled - not_granted - unavailable description: Why the "sleep" module is or is not available here. glucose: type: string enum: - enabled - disabled - not_granted - unavailable description: Why the "glucose" module is or is not available here. workouts: type: string enum: - enabled - disabled - not_granted - unavailable description: Why the "workouts" module is or is not available here. recovery: type: string enum: - enabled - disabled - not_granted - unavailable description: Why the "recovery" module is or is not available here. labs: type: string enum: - enabled - disabled - not_granted - unavailable description: Why the "labs" module is or is not available here. illness: type: string enum: - enabled - disabled - not_granted - unavailable description: Why the "illness" module is or is not available here. achievements: type: string enum: - enabled - disabled - not_granted - unavailable description: Why the "achievements" module is or is not available here. coach: type: string enum: - enabled - disabled - not_granted - unavailable description: Why the "coach" module is or is not available here. insights: type: string enum: - enabled - disabled - not_granted - unavailable description: Why the "insights" module is or is not available here. medications: type: string enum: - enabled - disabled - not_granted - unavailable description: Why the "medications" module is or is not available here. doctorReport: type: string enum: - enabled - disabled - not_granted - unavailable description: Why the "doctorReport" module is or is not available here. environment: type: string enum: - enabled - disabled - not_granted - unavailable description: Why the "environment" module is or is not available here. mcp: type: string enum: - enabled - disabled - not_granted - unavailable description: Why the "mcp" module is or is not available here. inboundDocuments: type: string enum: - enabled - disabled - not_granted - unavailable description: Why the "inboundDocuments" module is or is not available here. mentalHealth: type: string enum: - enabled - disabled - not_granted - unavailable description: Why the "mentalHealth" module is or is not available here. nutrients: type: string enum: - enabled - disabled - not_granted - unavailable description: Why the "nutrients" module is or is not available here. vaccinations: type: string enum: - enabled - disabled - not_granted - unavailable description: Why the "vaccinations" module is or is not available here. timeline: type: string enum: - enabled - disabled - not_granted - unavailable description: Why the "timeline" module is or is not available here. required: - cycle - mood - sleep - glucose - workouts - recovery - labs - illness - achievements - coach - insights - medications - doctorReport - environment - mcp - inboundDocuments - mentalHealth - nutrients - vaccinations - timeline additionalProperties: false description: "Why each toggleable module is or is not available for the record this session is inside. `enabled` — the module is on and the client may paint it. `disabled` — the record has it switched off. `not_granted` — the active grant's sections do not open the module's section, which also covers every module that reads across the whole record (achievements, the assistant surfaces, the export, environment, the account's own MCP endpoint) since no scoped grant opens one. `unavailable` — the operator switched the module off for the whole instance. Precedence, highest first: `unavailable` > `not_granted` > `disabled` > `enabled`, so the reason published is the one nothing further in can change. Own-record sessions and unscoped grants never produce `not_granted`. Every key is present, and `modules[key] === (moduleAccess[key] === \"enabled\")` holds for every key — this field adds a reason, never a different gate." AiAccountBlock: type: object properties: capabilities: $ref: "#/components/schemas/AiCapabilities" provider: $ref: "#/components/schemas/AiProviderState" consent: $ref: "#/components/schemas/AiConsentState" usage: description: v1.43 — the account's AI usage level. Absent on older servers; hide the setting then. $ref: "#/components/schemas/AiUsageState" required: - capabilities - provider - consent additionalProperties: false description: Which AI capabilities the record this session is inside has, and why not when it has none. Resolved on the server for the ACTIVE RECORD and masked to the sections the active grant opens, exactly like `modules` and `moduleAccess`; with no switch (every native request) it describes the caller's own record. AiCapabilities: type: object properties: coach: description: The "coach" capability. Covered by the operator's "coach" switch. $ref: "#/components/schemas/AiCapabilityState" briefing: description: The "briefing" capability. Covered by the operator's "briefing" switch. $ref: "#/components/schemas/AiCapabilityState" periodNarrative: description: The "periodNarrative" capability. Covered by the operator's "briefing" switch. $ref: "#/components/schemas/AiCapabilityState" statusText: description: The "statusText" capability. Covered by the operator's "insightStatus" switch. $ref: "#/components/schemas/AiCapabilityState" workoutInsights: description: The "workoutInsights" capability. Covered by the operator's "insightStatus" switch. $ref: "#/components/schemas/AiCapabilityState" reactionLines: description: The "reactionLines" capability. Covered by the operator's "insightStatus" switch. $ref: "#/components/schemas/AiCapabilityState" aboutMeQuestions: description: The "aboutMeQuestions" capability. Covered by the operator's "coach" switch. $ref: "#/components/schemas/AiCapabilityState" documentAi: description: The "documentAi" capability. Covered by the operator's "documentAi" switch. $ref: "#/components/schemas/AiCapabilityState" labsOcr: description: The "labsOcr" capability. Covered by the operator's "documentAi" switch. $ref: "#/components/schemas/AiCapabilityState" medicationExtract: description: The "medicationExtract" capability. Covered by the operator's "documentAi" switch. $ref: "#/components/schemas/AiCapabilityState" supplementLabel: description: The "supplementLabel" capability. Covered by the operator's "documentAi" switch. $ref: "#/components/schemas/AiCapabilityState" required: - coach - briefing - periodNarrative - statusText - workoutInsights - reactionLines - aboutMeQuestions - documentAi - labsOcr - medicationExtract - supplementLabel additionalProperties: false description: Every AI capability, always all present. `coach` — chat, fenced document chat, attachments, Coach memory upkeep, AI nudges, every Coach launcher. `briefing` — the daily briefing and every place its text is lifted into. `periodNarrative` — the model-written half of a period narrative (the deterministic narrative is data). `statusText` — per-metric status notes and the AI override of a derived assessment. `workoutInsights` — the paragraph on a workout. `reactionLines` — the line written after a new reading. `aboutMeQuestions` — model-written follow-up questions on the about-me profile (a deterministic set stays). `documentAi` — suggest, summary, extract, index and chat over a stored document. `labsOcr` — reading a lab report image. `medicationExtract` — turning a typed medication description into a schedule. `supplementLabel` (v1.43) — reading a supplement's label photo into its ingredients; unavailable on the `frugal` usage level. AiProviderState: type: object properties: configured: type: boolean description: "At least one configured provider can serve text for this record. Presence only: no key is tried, so a revoked key still reads as configured until a generation fails." managedBy: anyOf: - type: string enum: - user - local - server - type: "null" description: "Where the serving credential comes from: the person's own (`user`), a self-hosted model they pointed at (`local`), or one the operator holds (`server`). `null` when nothing is configured." canConfigure: type: boolean description: "Whether the person in front of the screen may set up a provider for this record: false inside somebody else's record and while the operator's master switch is off. A setup hint is shown only when this is true." responseTimeoutMs: type: integer exclusiveMinimum: 0 maximum: 9007199254740991 description: "How long one model call on this record may take before the server gives up, in milliseconds: the record's response-timeout setting (Settings → AI), else 60000. A client that waits on a model call (a document read, lab OCR) sizes its own request timeout from this plus a margin, so it never gives up before the server does." required: - configured - managedBy - canConfigure - responseTimeoutMs additionalProperties: false description: The account-level companion to the capability map. AiConsentState: type: object properties: status: type: string enum: - given - needed - not_needed description: "`given` — an `ai_full` consent receipt is active (`since` says when); show it with a withdraw control. `needed` — the provider chain runs through a credential the operator holds and at least one capability is closed by the missing receipt alone (its reason is `consent_required`); ask, naming `recipients`, and grant only from the person's tap. `not_needed` — nothing to ask and nothing to withdraw." since: description: When the active `ai_full` consent was given, ISO-8601. `null` unless `status` is `given`. type: - string - "null" recipients: type: array items: type: object properties: via: type: string enum: - operator_key - operator_chatgpt description: "`operator_key` — the operator's API key. `operator_chatgpt` — the operator's shared ChatGPT account." name: type: string description: "The service, named for a person: `OpenAI`, `Anthropic`, `ChatGPT (OpenAI)`, or the host name of the endpoint the operator set." required: - via - name additionalProperties: false description: The operator-held services in the record's provider chain, in chain order. Empty when the chain holds none. required: - status - since - recipients additionalProperties: false description: "v1.43.2 — the standing AI consent for the record. Consent is asked only when data would go through a credential the operator holds; a person's own key, ChatGPT account or local model needs none. Render from it; a client never mints a receipt on its own (launch, sync or mount): POST /api/consent/ai and /api/consent/ai/web require `confirm: true`." AiUsageState: type: object properties: level: description: The level in effect for this record. $ref: "#/components/schemas/AiUsageLevel" preference: anyOf: - $ref: "#/components/schemas/AiUsageLevel" - type: "null" description: The account's stored choice; `null` follows the instance default. defaultLevel: description: The level an account without its own choice runs at. $ref: "#/components/schemas/AiUsageLevel" maxLevel: description: The highest level the operator allows on this server. $ref: "#/components/schemas/AiUsageLevel" source: type: string enum: - user - default - admin_cap description: "Where `level` comes from: the stored choice, the instance default, or the operator's ceiling capping a stored choice above it (the choice is kept)." options: minItems: 3 maxItems: 3 type: array items: type: object properties: level: $ref: "#/components/schemas/AiUsageLevel" allowed: type: boolean description: "`level` is at or below `maxLevel`." estimatedTokensPerDay: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" description: An estimate, from this account's measured use scaled to the level. `null` without three days of history. Show it as an estimate; never compute one on the client. required: - level - allowed - estimatedTokensPerDay additionalProperties: false description: Always three entries, in the order frugal, standard, full. measuredTokensPerDay: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" description: Mean tokens per day over the last seven full days (Coach and background work together); `null` without history. measuredDays: type: integer minimum: 0 maximum: 7 description: How many days stand behind `measuredTokensPerDay`. required: - level - preference - defaultLevel - maxLevel - source - options - measuredTokensPerDay - measuredDays additionalProperties: false description: The account's AI usage level, resolved on the server. Clients render the picker from `options` and never derive allowance or estimates themselves. Absent on servers older than v1.43. AiUsageLevel: type: string enum: - frugal - standard - full description: "How much background AI work an account asks for, in ceiling order. `frugal`: status cards regenerate at most every 24 h after a sync (or the operator's sync gap when that is longer, so never more often than `standard`) and every 6 h after a hand entry, a smaller snapshot, no background reasoning, a short daily briefing (`recommendations` holds at most one entry, every field stays present), no automatic document reading, no supplement label reading. `standard`: the operator's sync gap (default 6 h), 1 h after a hand entry, background reasoning at the job default. `full`: 1 h after any ingest (the behaviour before v1.43). The interactive Coach is the same at every level." CoachReasoningState: type: object properties: level: type: string enum: - off - low - medium - high description: "The level a Coach turn runs at right now, every cap applied: the person's choice, lowered to the operator's highest level, to `medium` when the operator pays for the provider, and `off` when the operator switched reasoning off or the provider cannot reason." preference: type: string enum: - off - low - medium - high description: What the person picked (`reasoning` on `PUT /api/auth/me/coach-prefs`), `medium` when they never chose. Bind the settings field to this, not to `level`. maxLevel: type: string enum: - off - low - medium - high description: The highest level the settings may offer. Options above it are shown disabled as limited by the operator. `off` when the operator switched reasoning off. available: type: boolean description: False when the operator switched reasoning off or the configured provider cannot reason; the settings field is then locked. offIsReal: type: boolean description: False when the provider cannot switch reasoning off (it thinks at its lowest level instead); label the lowest option "Minimal" rather than "Off". source: type: string enum: - user - job - admin_off - admin_cap - cost_cap - unsupported description: "Which layer decided `level`: the person's own choice (`user`), the operator's switch (`admin_off`) or cap (`admin_cap`), the operator-funded ceiling (`cost_cap`), or a provider that cannot reason (`unsupported`). `job` never appears here." required: - level - preference - maxLevel - available - offIsReal - source additionalProperties: false description: The Coach's thinking depth for the signed-in person, resolved on the server from their preference, the operator's reasoning switch and cap, who pays for the provider, and what the provider can do. RecordSessionState: type: object properties: epoch: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: How many times this browser session's record selector has moved. Monotonic, server-issued, and scoped to this session. Send it back as the `X-HealthLog-Record-Epoch` header on every subsequent same-origin request, and as `expectedEpoch` when switching. scope: description: The account this session is pointed at, or null for its own record. Send it back as the `X-HealthLog-Record-Scope` header, with `self` standing for null. type: - string - "null" required: - epoch - scope additionalProperties: false description: Where the record-session fence says this browser session is. Cookie transport only — null on Bearer, which carries its selector per request and accumulates no session state to be stale about. A client adopts this and then asserts it on every same-origin request, so a request formed under a context that has since moved is refused rather than served against the wrong record. RecordActivityEnvelope: type: object properties: data: $ref: "#/components/schemas/RecordActivity" error: type: "null" meta: $ref: "#/components/schemas/ListCapMeta" required: - data - error - meta additionalProperties: false RecordActivity: type: object properties: entries: type: array items: $ref: "#/components/schemas/RecordActivityEntry" retentionDays: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: How far back this feed can see, in days. Resolved from the instance's audit retention setting (365 by default, operator-configurable), NOT a constant — an instance running a 90-day window sends 90. The query is bounded to this window, so it is the real limit of the list and not only a caption. Show it beside the rows. truncated: type: boolean deprecated: true description: "Deprecated: read `meta.truncated`, which carries the same value. Whether the list stopped at its row ceiling; kept in `data` for clients built against v1.43.6 and earlier." required: - entries - retentionDays - truncated additionalProperties: false description: The owner's record of what somebody else did in their record, bounded by the instance's audit retention. RecordActivityEntry: type: object properties: id: type: string actor: anyOf: - $ref: "#/components/schemas/AccountGrantParty" - type: "null" description: Who did it. Null when that account has since been deleted — the audit column is deliberately not a foreign key, so history is not rewritten when somebody leaves. Render it as a deleted account; a blank would read as though the owner had done it themselves. action: type: string accesses: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" description: How many times that delegate opened the record on that day, for a coalesced read row. Null for anything else — a single act has no count, and a 1 there would invite adding up numbers that mean different things. createdAt: type: string required: - id - actor - action - accesses - createdAt additionalProperties: false AccountGrantParty: type: object properties: id: type: string username: type: string displayName: type: - string - "null" required: - id - username - displayName additionalProperties: false description: "The account on the other end of a grant. E-mail addresses are deliberately not published: a grant is not a way to collect the other party's contact details." AccountGrantListEnvelope: type: object properties: data: $ref: "#/components/schemas/AccountGrantList" error: type: "null" meta: $ref: "#/components/schemas/ListCapMeta" required: - data - error - meta additionalProperties: false AccountGrantList: type: object properties: given: type: array items: $ref: "#/components/schemas/AccountGrant" received: type: array items: $ref: "#/components/schemas/AccountGrant" retentionDays: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: How long the record's activity feed can still attribute an entry to the person who made it, in days. Resolved from the instance's audit retention setting (365 by default, operator-configurable), NOT a constant. It rides this payload so a client ending an access can state the window in the same breath, without a second request whose timing would decide whether the owner was told. required: - given - received - retentionDays additionalProperties: false description: "Both directions at once: `given` are the grants this account has offered on its own record, `received` are the ones offered to it. Capped at 100 rows per direction, newest first." AccountGrant: type: object properties: id: type: string account: $ref: "#/components/schemas/AccountGrantParty" access: type: string enum: - READ - WRITE - MANAGE scope: anyOf: - type: array items: $ref: "#/components/schemas/ShareSection" - type: "null" description: The sections this grant opens, or null for the entire record. Null is a first-class value and the one every grant written before v1.37.0 carries; render it as the whole record, never as a legacy or unknown state. A MANAGE grant always carries null. An EMPTY array means the grant opens nothing — the fail-closed reading of a stored scope the server cannot parse — and the server will refuse every section for it, so render it as nothing rather than as everything. Ordered by the consent screen's own reading order. state: $ref: "#/components/schemas/AccountGrantState" invitedAt: type: string acceptedAt: type: - string - "null" expiresAt: type: - string - "null" lastUsedAt: type: - string - "null" revokedAt: type: - string - "null" revokedBy: anyOf: - type: string enum: - GRANTOR - GRANTEE - type: "null" required: - id - account - access - scope - state - invitedAt - acceptedAt - expiresAt - lastUsedAt - revokedAt - revokedBy additionalProperties: false description: "One grant, from either side. The row is also the consent record — who offered the access, when it was accepted, when and by whom it ended — so ended grants stay in the list with `state: REVOKED` rather than disappearing. `revokedBy` distinguishes the owner withdrawing access from the delegate handing it back. `lastUsedAt` mirrors API-token semantics: the last time the grant was actually exercised." ShareSection: type: string enum: - measurements - medications - labs - profile - illness - mind - cycle - documents description: "One section of a health record, as a grant may name it. A closed vocabulary of eight, derived by clustering the delegable read surfaces rather than from the module list — the two answer different questions and never share a spelling. The whole-record case is `null` on the field that carries these, never a member of this enum: a scope that named the whole record would be a narrowing that means its own absence." AccountGrantState: type: string enum: - PENDING - ACTIVE - EXPIRED - REVOKED description: "What the grant is right now, resolved server-side against the request clock. Only ACTIVE confers anything. Ordering is stated rather than implied: a revoked grant reads REVOKED even if it also sat past its expiry, and an unaccepted invitation past its expiry reads EXPIRED rather than PENDING because it can no longer be accepted. Clients render this value and never re-derive it from the timestamps." AccountGrantEnvelope: type: object properties: data: $ref: "#/components/schemas/AccountGrant" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false AcceptedAccountGrantEnvelope: type: object properties: data: $ref: "#/components/schemas/AccountGrant" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false RevokedAccountGrantEnvelope: type: object properties: data: $ref: "#/components/schemas/EndedAccountGrant" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false EndedAccountGrant: type: object properties: id: type: string account: $ref: "#/components/schemas/AccountGrantParty" access: type: string enum: - READ - WRITE - MANAGE scope: anyOf: - type: array items: $ref: "#/components/schemas/ShareSection" - type: "null" description: The sections this grant opens, or null for the entire record. Null is a first-class value and the one every grant written before v1.37.0 carries; render it as the whole record, never as a legacy or unknown state. A MANAGE grant always carries null. An EMPTY array means the grant opens nothing — the fail-closed reading of a stored scope the server cannot parse — and the server will refuse every section for it, so render it as nothing rather than as everything. Ordered by the consent screen's own reading order. state: $ref: "#/components/schemas/AccountGrantState" invitedAt: type: string acceptedAt: type: - string - "null" expiresAt: type: - string - "null" lastUsedAt: type: - string - "null" revokedAt: type: - string - "null" revokedBy: anyOf: - type: string enum: - GRANTOR - GRANTEE - type: "null" sessionsCleared: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: How many of the delegate's browser sessions were sitting inside the record and were put back into their own account, in the same transaction that ended the grant. required: - id - account - access - scope - state - invitedAt - acceptedAt - expiresAt - lastUsedAt - revokedAt - revokedBy - sessionsCleared additionalProperties: false description: "A grant that has just been ended, plus what the cleanup did. Nothing is deleted: the row survives as the consent record and DELETE names the act, not the storage." RenouncedAccountGrantEnvelope: type: object properties: data: $ref: "#/components/schemas/EndedAccountGrant" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false ManagedProfileEnvelope: type: object properties: data: $ref: "#/components/schemas/ManagedProfile" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false ManagedProfile: type: object properties: id: type: string description: The profile's account id — the value `POST /api/account/switch` and the `X-HealthLog-Account` selector take, and the `{id}` in this family's paths. displayName: type: - string - "null" dateOfBirth: description: "`YYYY-MM-DD`, or null. The same date-only shape the request takes — this is a calendar date, not an instant, and publishing it with a time would invite a client to apply a zone to it." type: - string - "null" gender: anyOf: - type: string enum: - MALE - FEMALE - OTHER - type: "null" description: "`User.gender` for this record. Null means not recorded, which is a real answer and not a missing one." locale: description: The record's own language. Every profile this family creates carries one, because creation requires it; the column is nullable, so null means a row written before that and a client renders its own language rather than the word. type: - string - "null" timezone: type: string recordKind: type: string const: managed description: Always `managed` here. Published so a client can file the new record without re-deriving what it just created; the same value arrives on the account payload's entry for it. required: - id - displayName - dateOfBirth - gender - locale - timezone - recordKind additionalProperties: false description: "A record somebody looks after, as its creation, its read and its edit all answer with it. There is deliberately no list endpoint: the profiles a Guardian looks after are the `managed` entries of `accountAccess.accounts` on GET /api/auth/me, because a Guardian's relationship to a profile IS a MANAGE grant. Re-read the account payload after any change here; a client that refreshed only its own panel would leave the new record out of the switcher and the banner until the next boot." DeletedManagedProfileEnvelope: type: object properties: data: type: object properties: deleted: type: boolean const: true scheduledPurgeAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" required: - deleted - scheduledPurgeAt additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false ManagedProfileGuardianListEnvelope: type: object properties: data: type: array items: $ref: "#/components/schemas/ManagedProfileGuardian" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false ManagedProfileGuardian: type: object properties: grantId: type: string description: The grant row. This is the value `DELETE /api/managed-profiles/{id}/guardians/{grantId}` takes. account: $ref: "#/components/schemas/AccountGrantParty" state: type: string enum: - PENDING - ACTIVE description: "PENDING means the invitation has been sent and not yet accepted. A PENDING Guardian does NOT count toward the last-Guardian floor: the server counts accepted, unexpired MANAGE grants, so inviting somebody and then trying to leave is still refused until they accept. Say so before the act rather than letting it arrive as a 409." invitedAt: type: string acceptedAt: type: - string - "null" required: - grantId - account - state - invitedAt - acceptedAt additionalProperties: false description: "One person who looks after a managed profile. Ended grants — revoked or expired — are absent rather than listed with a state: the question is who looks after this record now. The invitation route answers with this same shape, so a Guardian is described one way everywhere; it carries no `expiresAt`, because an expiry bounds one invitation rather than describing who is looking after the record." ManagedProfileGuardianEnvelope: type: object properties: data: $ref: "#/components/schemas/ManagedProfileGuardian" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false EndedManagedProfileGuardianEnvelope: type: object properties: data: $ref: "#/components/schemas/EndedManagedProfileGuardian" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false EndedManagedProfileGuardian: type: object properties: grantId: type: string revokedAt: type: string required: - grantId - revokedAt additionalProperties: false description: "What ended, and when. Two fields on purpose: who held it is on the roster the caller re-reads, and the grant row's remaining columns are storage rather than contract. Nothing is deleted — the row survives as the consent record with `revokedBy: GRANTOR`, the managed profile being the grantor of every Guardian grant." AccountSwitchEnvelope: type: object properties: data: $ref: "#/components/schemas/AccountSwitchState" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false AccountSwitchState: type: object properties: recordSession: anyOf: - $ref: "#/components/schemas/RecordSessionState" - type: "null" description: "The context this session is now in, when the request supplied `expectedEpoch`. Null when it did not: an unconditional switch does not read the new epoch back, and a number computed client-side would be wrong the moment the target equalled the current selector. Adopt this value; do not derive it." actingAs: anyOf: - type: object properties: accountId: type: string username: type: string displayName: type: - string - "null" access: type: string enum: - READ - WRITE - MANAGE required: - accountId - username - displayName - access additionalProperties: false - type: "null" required: - recordSession - actingAs additionalProperties: false description: The account this session is now acting on, or null when it is back in its own. The stamp is a selector and not a permission — the grant is re-checked on every subsequent request, so a revocation lands on the next one rather than at the next login. VapidEnvelope: type: object properties: data: $ref: "#/components/schemas/VapidPublicKeyResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false VapidPublicKeyResponse: type: object properties: publicKey: type: string description: The instance's VAPID public key, base64url. Public by definition — it is what a browser passes to `pushManager.subscribe`. The private half never leaves the server. required: - publicKey additionalProperties: false WebPushSubscribeEnvelope: type: object properties: data: type: object properties: subscribed: type: boolean const: true required: - subscribed additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false WebPushUnsubscribeEnvelope: type: object properties: data: type: object properties: unsubscribed: type: boolean const: true required: - unsubscribed additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false WebPushTestEnvelope: type: object properties: data: $ref: "#/components/schemas/WebPushTestResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false WebPushTestResponse: type: object properties: ok: type: boolean sent: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: 1 on success, 0 on failure. Never more than one. latencyMs: description: Round-trip to the push service. Present on success only. type: integer minimum: -9007199254740991 maximum: 9007199254740991 perEndpoint: type: array items: type: object properties: host: type: string description: Host of the push service the test went to — the endpoint path is a routing secret and is never returned. status: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" description: The push service's HTTP status, or null when the attempt threw before one existed. required: - host - status additionalProperties: false description: "One entry: only the newest subscription is tested." required: - ok - sent - perEndpoint additionalProperties: false ApnsTestEnvelope: type: object properties: data: $ref: "#/components/schemas/ApnsTestResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false ApnsTestResponse: type: object properties: ok: type: boolean description: Whether the push was accepted by APNs. reason: description: Why it was not, when `ok` is false. Absent on success. Comes from the dispatcher's own classification — an unregistered token, a configuration gap, no iOS device on the account. type: string required: - ok additionalProperties: false NotificationActionResponse: type: object properties: data: $ref: "#/components/schemas/NotificationActionResult" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false NotificationActionResult: type: object properties: logged: type: boolean const: true action: type: string enum: - mood-good - mood-okay - mood-bad description: "The answer that was logged: mood-good = 4 (GUT), mood-okay = 3 (OKAY), mood-bad = 2 (SCHLECHT) on the five-point mood scale." required: - logged - action additionalProperties: false description: A quick answer from a notification, logged as a mood entry. GetNotificationPreferences: type: object properties: data: $ref: "#/components/schemas/NotificationPreferencesResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false NotificationPreferencesResponse: type: object properties: channels: type: array items: $ref: "#/components/schemas/NotificationChannelSummary" preferences: type: array items: $ref: "#/components/schemas/NotificationPreference" eventTypes: type: array items: type: string description: Every event category the server knows, in declaration order — the list a client renders rows for. eventDefaults: type: object propertyNames: type: string enum: - MEDICATION_REMINDER - MEASUREMENT_ANOMALY - COMPLIANCE_LOW - WITHINGS_SYNC_FAILED - SYSTEM_ALERT - PERSONAL_RECORD - MOOD_REMINDER - CYCLE_PERIOD_SOON - CYCLE_PERIOD_CONFIRM - CYCLE_FERTILE_SOON - COACH_NUDGE - MEDICATION_LOW_STOCK - MEASUREMENT_REMINDER - MEDICATION_INTAKE_SYNC - SECURITY_ALERT - DAILY_BRIEFING - SHARED_RECORD_INTAKE - BACKUP_HEALTH description: Notification category a per-channel preference can toggle. additionalProperties: type: boolean required: - MEDICATION_REMINDER - MEASUREMENT_ANOMALY - COMPLIANCE_LOW - WITHINGS_SYNC_FAILED - SYSTEM_ALERT - PERSONAL_RECORD - MOOD_REMINDER - CYCLE_PERIOD_SOON - CYCLE_PERIOD_CONFIRM - CYCLE_FERTILE_SOON - COACH_NUDGE - MEDICATION_LOW_STOCK - MEASUREMENT_REMINDER - MEDICATION_INTAKE_SYNC - SECURITY_ALERT - DAILY_BRIEFING - SHARED_RECORD_INTAKE - BACKUP_HEALTH description: What each event does on a channel with no stored row, resolved for this account by the function delivery uses. Most are constant per category; the mood reminder follows the account's mood-reminder setting (`moodReminderEnabled` on `/api/user/profile`). Render a switch without a row from this map rather than from a copy of the defaults. Since v1.43.11. required: - channels - preferences - eventTypes - eventDefaults additionalProperties: false description: The caller's notification channels, their stored per-event overrides, and the full event vocabulary. NotificationChannelSummary: type: object properties: id: type: string type: type: string enum: - TELEGRAM - NTFY - WEB_PUSH - APNS - WEBHOOK - EMAIL description: Delivery transport for a notification channel. label: type: string description: Human-readable channel name. Falls back to the raw `type` when no label is registered. enabled: type: boolean description: The user's own per-channel toggle. globallyEnabled: type: boolean description: The operator's instance-wide toggle for this transport. Only TELEGRAM, NTFY and WEB_PUSH have one; every other transport reports `true`. A channel the user has enabled still sends nothing while this is false. required: - id - type - label - enabled - globallyEnabled additionalProperties: false description: One of the caller's notification channels, with both the per-user and the instance-wide toggle so a client can explain why an enabled channel is silent. NotificationPreference: type: object properties: channelId: type: string eventType: type: string enum: - MEDICATION_REMINDER - MEASUREMENT_ANOMALY - COMPLIANCE_LOW - WITHINGS_SYNC_FAILED - SYSTEM_ALERT - PERSONAL_RECORD - MOOD_REMINDER - CYCLE_PERIOD_SOON - CYCLE_PERIOD_CONFIRM - CYCLE_FERTILE_SOON - COACH_NUDGE - MEDICATION_LOW_STOCK - MEASUREMENT_REMINDER - MEDICATION_INTAKE_SYNC - SECURITY_ALERT - DAILY_BRIEFING - SHARED_RECORD_INTAKE - BACKUP_HEALTH description: Notification category a per-channel preference can toggle. enabled: type: boolean required: - channelId - eventType - enabled additionalProperties: false description: An explicit per-(channel, event) override. Only STORED rows appear — an event with no row here follows `eventDefaults` on the same response. Absence is therefore not 'disabled'. PutNotificationPreferenceResponse: type: object properties: data: $ref: "#/components/schemas/NotificationPreference" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false GetNotificationStatus: type: object properties: data: $ref: "#/components/schemas/NotificationStatusResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false NotificationStatusResponse: type: object properties: channels: type: array items: $ref: "#/components/schemas/NotificationChannelDeliveryStatus" events: type: object propertyNames: type: string additionalProperties: $ref: "#/components/schemas/NotificationEventDeliveryStatus" description: "Keyed by event category. EVERY known category is present, with `lastDeliveredAt: null` when no data exists, so a client can render an empty row without conditional plumbing. Only categories with their own dispatch ledger can ever report a non-null instant — today that is `MOOD_REMINDER` alone; every other category reports null regardless of what was actually delivered." required: - channels - events additionalProperties: false description: Per-channel delivery health plus a per-category last-delivered map. NotificationChannelDeliveryStatus: type: object properties: id: type: string type: type: string enum: - TELEGRAM - NTFY - WEB_PUSH - APNS - WEBHOOK - EMAIL description: Delivery transport for a notification channel. label: type: string enabled: type: boolean state: type: string enum: - active - auto_disabled - manually_disabled - sending_paused description: Server-derived. `auto_disabled` — the dispatcher turned it off after repeated hard rejects (`disabledReason` says why). `manually_disabled` — the user turned it off. `sending_paused` — still enabled but inside a retry cooldown until `nextRetryAt`. `active` — enabled and sending. disabledReason: description: Why the dispatcher auto-disabled the channel; null when it did not. This is the field that separates `auto_disabled` from `manually_disabled`. type: - string - "null" consecutiveFailures: type: integer minimum: -9007199254740991 maximum: 9007199254740991 lastSuccessAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" lastFailureAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" lastFailureReason: type: - string - "null" nextRetryAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" description: End of the current cooldown; null when not paused. required: - id - type - label - enabled - state - disabledReason - consecutiveFailures - lastSuccessAt - lastFailureAt - lastFailureReason - nextRetryAt additionalProperties: false description: "Delivery health for one channel: the derived state plus the raw counters and timestamps behind it." NotificationEventDeliveryStatus: type: object properties: lastDeliveredAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" description: Null when this category has never been delivered. required: - lastDeliveredAt additionalProperties: false ReEnableNotificationChannelResponse: type: object properties: data: type: object properties: ok: type: boolean const: true required: - ok additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false McpConnectionListEnvelope: type: object properties: data: type: array items: $ref: "#/components/schemas/McpConnection" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false McpConnection: type: object properties: id: type: string clientName: type: string description: Name the OAuth client registered itself under. Client-supplied at registration time — display it as such, do not treat it as verified. scope: type: string description: Space-separated scope string granted to this connection. createdAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ lastUsedAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" description: Null when the connection has never been exercised. required: - id - clientName - scope - createdAt - lastUsedAt additionalProperties: false description: "A live remote-connector authorization created through the MCP OAuth bridge. Carries no token material: the access tokens it issued are not addressable here, they are ended by revoking the connection." McpConnectionRevokeEnvelope: type: object properties: data: type: object properties: revoked: type: boolean const: true required: - revoked additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false McpConnectorTokenListEnvelope: type: object properties: data: type: array items: $ref: "#/components/schemas/McpConnectorToken" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false McpConnectorToken: type: object properties: id: type: string name: type: string permissions: type: array items: type: string description: 'The granted scopes — `["health:read"]` or `["health:read", "health:write"]`. A `health:write` token is audience-bound to `/mcp`: the resource-server guard refuses it on every REST write, so it only admits the confirmed in-process write tools.' lastUsedAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" expiresAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" createdAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ revoked: type: boolean description: Revoked tokens stay in this list rather than disappearing from it, so a client must filter if it wants live credentials only. required: - id - name - permissions - lastUsedAt - expiresAt - createdAt - revoked additionalProperties: false description: A manually-minted MCP connector token. The token VALUE is not part of this shape — it exists in one response only, the 201 that mints it. CreateMcpTokenEnvelope: type: object properties: data: $ref: "#/components/schemas/CreateMcpTokenResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false CreateMcpTokenResponse: type: object properties: token: type: string description: The raw `hlk_` Bearer, RETURNED EXACTLY ONCE. It is stored only as an HMAC-SHA256 hash, so there is no way to retrieve it later; a client that does not capture it here has to mint a replacement. Treat it as a secret in logs, crash reports and analytics. name: type: string expiresAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - token - name - expiresAt additionalProperties: false description: The one response in the API that carries a usable Bearer token value for an MCP connector. McpConnectorTokenRevokeEnvelope: type: object properties: data: type: object properties: revoked: type: boolean const: true required: - revoked additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false RecordSettingsEnvelope: type: object properties: data: $ref: "#/components/schemas/RecordSettingsResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false RecordSettingsResponse: type: object properties: record: type: object properties: id: type: string displayName: type: string description: Empty string when the record has no display name set. locale: type: - string - "null" timezone: type: string kind: type: string const: managed description: Always `managed`. The endpoint exists only for managed records; there is no owner-record arm. required: - id - displayName - locale - timezone - kind additionalProperties: false required: - record additionalProperties: false description: The active managed record's configuration identity. Deliberately a distinct DTO rather than another actor field on `/api/auth/me`. RecordSettingsIntegrationsEnvelope: type: object properties: data: $ref: "#/components/schemas/RecordSettingsIntegrationsResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false RecordSettingsIntegrationsResponse: type: object properties: recordId: type: string integrations: type: array items: type: object properties: integration: type: string enum: - withings - whoop - fitbit - nightscout - polar - oura - google-health - strava state: type: string enum: - connected - error_transient - error_reauth - disconnected - parked lastSuccessAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" lastAttemptAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" health: anyOf: - $ref: "#/components/schemas/ConnectionHealth" - type: "null" description: The same server-resolved connection state the owner's own envelope carries, null while the provider is not connected. The web renders this; `state` stays for one transition release. required: - integration - state - lastSuccessAt - lastAttemptAt - health additionalProperties: false description: All eight providers, always, in the fixed order withings, whoop, fitbit, nightscout, polar, oura, google-health, strava. Note the order differs from the owner's own `/api/integrations/status` envelope. required: - recordId - integrations additionalProperties: false description: Whether the managed record's integrations are connected and how each is doing (`health`), and nothing else. No credential projection. A Guardian connects or disconnects an OAuth provider for the record through `POST /api/record-settings/integrations/{provider}/connect` and `/disconnect`. A provider with no real credential reads `disconnected` even when its ledger row says otherwise, because a synthesised healthy ledger is not proof of a connection. ManagedConnectStartEnvelope: type: object properties: data: $ref: "#/components/schemas/ManagedConnectStartResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false ManagedConnectStartResponse: type: object properties: recordId: type: string provider: type: string enum: - withings - whoop - oura - polar - strava - google-health authorizationUrl: type: string description: The provider's consent URL, with the state the record owns. required: - recordId - provider - authorizationUrl additionalProperties: false description: Where to send the browser to connect a provider for the managed record. ManagedDisconnectEnvelope: type: object properties: data: $ref: "#/components/schemas/ManagedDisconnectResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false ManagedDisconnectResponse: type: object properties: recordId: type: string provider: type: string enum: - withings - whoop - oura - polar - strava - google-health disconnected: type: boolean const: true required: - recordId - provider - disconnected additionalProperties: false description: The provider is no longer connected to the managed record. GetRecordSettingsFamilyEnvelope: type: object properties: data: $ref: "#/components/schemas/RecordSettingsFamilyResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false RecordSettingsFamilyResponse: type: object properties: recordId: type: string family: type: string enum: - profile - modules - notifications - thresholds - coach - insights description: The closed set of configuration families a Guardian may read and write on a managed record. Identity, credentials, delivery channels, Coach memories and provider connections are absent by construction — no family accepts them, so every future field fails closed until one deliberately does. settings: anyOf: - $ref: "#/components/schemas/RecordProfileSettings" - $ref: "#/components/schemas/RecordModuleSettings" - $ref: "#/components/schemas/RecordNotificationSettings" - $ref: "#/components/schemas/RecordThresholdSettings" - $ref: "#/components/schemas/RecordCoachSettings" - $ref: "#/components/schemas/RecordInsightsSettings" description: The DTO for the family in the path. Which variant arrives is determined entirely by `{family}`, so branch on `family` rather than probing the shape — OpenAPI cannot express the dependency between a path parameter and a response variant. required: - recordId - family - settings additionalProperties: false description: One family's settings for one managed record. Both the GET and the PATCH answer this same shape; the PATCH echo is the post-write state. RecordProfileSettings: type: object properties: displayName: type: - string - "null" heightCm: type: - number - "null" dateOfBirth: anyOf: - type: string format: date pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ - type: "null" description: Calendar date (`YYYY-MM-DD`), not an instant. gender: anyOf: - type: string enum: - MALE - FEMALE - OTHER - type: "null" locale: anyOf: - type: string enum: - de - en - fr - es - it - pl - ko - nl - pt-BR - type: "null" timezone: type: string unitPreference: anyOf: - type: string enum: - metric - imperial - type: "null" description: "Null when the record never chose one: metric applies." timeFormat: type: string enum: - AUTO - H12 - H24 dateFormat: type: string enum: - AUTO - DMY - MDY - YMD required: - displayName - heightCm - dateOfBirth - gender - locale - timezone - unitPreference - timeFormat - dateFormat additionalProperties: false RecordModuleSettings: type: object properties: modulePreferences: type: object propertyNames: type: string additionalProperties: type: boolean description: Every directly-owned module key, always present, with the registry default filled in for keys the record has never persisted — so a Guardian administers the whole supported surface rather than only what happens to be stored. required: - modulePreferences additionalProperties: false RecordNotificationSettings: type: object properties: moodReminderEnabled: type: boolean notificationPreferences: type: object properties: medication: type: object properties: lowStockRunwayDays: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" reorderLeadDays: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - lowStockRunwayDays - reorderLeadDays additionalProperties: false mood: type: object properties: reminderHour: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - reminderHour additionalProperties: false required: - medication - mood additionalProperties: false required: - moodReminderEnabled - notificationPreferences additionalProperties: false description: "Reminder timing and low-stock thresholds only. Delivery ownership is the actor's personal choice and is not here: a Guardian cannot route a managed record's notifications to their own phone or channel." RecordThresholdSettings: type: object properties: overrides: type: object propertyNames: type: string enum: - WEIGHT - BLOOD_PRESSURE_SYS - BLOOD_PRESSURE_DIA - PULSE - BODY_FAT - SLEEP_DURATION - ACTIVITY_STEPS - BLOOD_GLUCOSE_FASTING - BLOOD_GLUCOSE_POSTPRANDIAL - BLOOD_GLUCOSE_RANDOM - BLOOD_GLUCOSE_BEDTIME - TOTAL_BODY_WATER - BONE_MASS - OXYGEN_SATURATION description: A metric that carries a user-overridable traffic-light range. additionalProperties: type: object properties: min: type: number max: type: number required: - min - max additionalProperties: false required: - WEIGHT - BLOOD_PRESSURE_SYS - BLOOD_PRESSURE_DIA - PULSE - BODY_FAT - SLEEP_DURATION - ACTIVITY_STEPS - BLOOD_GLUCOSE_FASTING - BLOOD_GLUCOSE_POSTPRANDIAL - BLOOD_GLUCOSE_RANDOM - BLOOD_GLUCOSE_BEDTIME - TOTAL_BODY_WATER - BONE_MASS - OXYGEN_SATURATION description: Only the metrics the record has actually overridden. A metric absent here uses the computed default. effective: type: object propertyNames: type: string enum: - WEIGHT - BLOOD_PRESSURE_SYS - BLOOD_PRESSURE_DIA - PULSE - BODY_FAT - SLEEP_DURATION - ACTIVITY_STEPS - BLOOD_GLUCOSE_FASTING - BLOOD_GLUCOSE_POSTPRANDIAL - BLOOD_GLUCOSE_RANDOM - BLOOD_GLUCOSE_BEDTIME - TOTAL_BODY_WATER - BONE_MASS - OXYGEN_SATURATION description: A metric that carries a user-overridable traffic-light range. additionalProperties: type: object properties: range: anyOf: - type: object properties: greenMin: type: number greenMax: type: number orangeMin: type: number orangeMax: type: number required: - greenMin - greenMax - orangeMin - orangeMax additionalProperties: false - type: "null" description: "Resolved range: the override if set, else the default." isOverride: type: boolean default: anyOf: - type: object properties: greenMin: type: number greenMax: type: number orangeMin: type: number orangeMax: type: number required: - greenMin - greenMax - orangeMin - orangeMax additionalProperties: false - type: "null" description: The unmodified computed default, so a client can show the diff. Null when no default can be computed — a weight range needs a height, an age-banded range needs a date of birth. bounds: type: object properties: min: type: number max: type: number unit: type: string required: - min - max - unit additionalProperties: false required: - range - isOverride - default - bounds additionalProperties: false required: - WEIGHT - BLOOD_PRESSURE_SYS - BLOOD_PRESSURE_DIA - PULSE - BODY_FAT - SLEEP_DURATION - ACTIVITY_STEPS - BLOOD_GLUCOSE_FASTING - BLOOD_GLUCOSE_POSTPRANDIAL - BLOOD_GLUCOSE_RANDOM - BLOOD_GLUCOSE_BEDTIME - TOTAL_BODY_WATER - BONE_MASS - OXYGEN_SATURATION description: Every threshold metric, always, whether overridden or not — this is the resolved view a client renders without recomputing anything. required: - overrides - effective additionalProperties: false RecordCoachSettings: type: object properties: disableCoach: type: boolean preferences: type: object properties: tone: type: string verbosity: type: string excludeMetrics: type: array items: type: string defaultWindow: type: string dataClusters: description: Absent when the record has never set it. type: array items: type: string required: - tone - verbosity - excludeMetrics - defaultWindow additionalProperties: false required: - disableCoach - preferences additionalProperties: false description: The Coach kill switch and the prompt-tuning subset a Guardian may set. A write to `preferences` also mirrors `excludeMetrics` onto the record's insights exclusion list, so the two cannot drift apart. RecordInsightsSettings: type: object properties: layout: type: object properties: version: type: integer minimum: -9007199254740991 maximum: 9007199254740991 sections: type: array items: type: object properties: id: type: string visible: type: boolean order: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - id - visible - order additionalProperties: false tiles: type: array items: type: object properties: id: type: string visible: type: boolean order: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - id - visible - order additionalProperties: false required: - version - sections - tiles additionalProperties: false required: - layout additionalProperties: false description: The resolved insights layout. Unknown and legacy tile ids are normalised or dropped on read, and a newly-shipped tile is merged in default-visible, so the response is always the full current tile set rather than whatever was last persisted. PatchRecordSettingsFamilyEnvelope: type: object properties: data: $ref: "#/components/schemas/RecordSettingsFamilyResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false HealthAdminResponse: type: object properties: status: $ref: "#/components/schemas/HealthStatus" reason: $ref: "#/components/schemas/HealthReason" timestamp: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ description: When the probe ran. UTC (`Z`) — no user context is loaded. database: type: string enum: - connected - disconnected description: Result of a `SELECT 1` round-trip. worker: type: string enum: - running - stopped description: The in-process pg-boss worker loop. Reported for every process, including one that does not run the worker role — a web-only process reads `stopped` here and can still be `ok`. workerLastHeartbeat: description: Last worker heartbeat. Absent when the worker has never beaten in this process. type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - status - timestamp - database - worker additionalProperties: false description: "The component breakdown, returned ONLY to a caller whose cookie session resolves to an ADMIN. A Bearer token never reaches this arm: the route resolves the session directly through `getSession()`, which reads the cookie." HealthStatus: type: string enum: - ok - degraded description: "`ok` when the database answers AND every role this process runs holds up: the pg-boss producer is bound when the process serves web, and the worker loop is running when the process runs the worker. `degraded` on any of those failing." HealthReason: description: "Why a `degraded` process is degraded, when it is something other than a component failure. `encryption_key_mismatch`: the process refuses to serve for a reason a person has to fix; the configured encryption key cannot open the data in this database (checked at boot). Every other route then answers 503 with `meta.errorCode` `encryption.key_mismatch`, and background jobs are not started. Fix: restore the original key, point the server at the matching database, or start with an empty one; then restart. `starting`: the database answers but the background role (the pg-boss worker, or the producer of a web-only process) is still coming up after a start. Transient; nothing to fix, poll again. The probe answers at once while it lasts rather than waiting for the start to finish." type: string enum: - encryption_key_mismatch - starting HealthPublicResponse: type: object properties: status: $ref: "#/components/schemas/HealthStatus" reason: $ref: "#/components/schemas/HealthReason" required: - status additionalProperties: false description: "What an unauthenticated (or non-admin) caller sees: the aggregate verdict and nothing else. No component detail, so an uptime probe cannot be used to fingerprint which part of a self-hosted instance is unwell." ListPersonalRecordsEnvelope: type: object properties: data: type: array items: $ref: "#/components/schemas/PersonalRecord" error: type: "null" meta: $ref: "#/components/schemas/ListCapMeta" required: - data - error - meta additionalProperties: false PersonalRecord: type: object properties: id: type: string userId: type: string description: The RECORD's user id — under a delegated read this is the owner, not the caller. metricType: $ref: "#/components/schemas/MeasurementType" metricSlot: description: Per-sport bucket for workout-driven bests (e.g. "running_5km_time"). Null for a plain measurement-driven best, where the metric type alone is the dimension. type: - string - "null" direction: type: string enum: - MAX - MIN description: Which way is better. MAX for steps / VO2 max / HRV / distance; MIN for resting heart rate, body fat, audio exposure. value: type: number unit: type: string achievedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ sourceMeasurementId: description: The measurement that achieved the record. Nulled rather than cascaded when that measurement is later deleted, so the historical fact survives the reading behind it. type: - string - "null" source: $ref: "#/components/schemas/MeasurementSource" externalId: type: - string - "null" createdAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ benchmark: description: v1.43 — an age- and sex-aware comparison band where a cited norm exists, in the record's unit; null otherwise. anyOf: - $ref: "#/components/schemas/RecordBenchmark" - type: "null" required: - id - userId - metricType - metricSlot - direction - value - unit - achievedAt - sourceMeasurementId - source - externalId - createdAt additionalProperties: false description: One detected personal best. Rows are written by the detection worker, never by a client — there is no write endpoint on this resource. RecordBenchmark: type: object properties: low: type: number high: type: number unit: type: string source: type: string sourceLabel: type: string required: - low - high - unit - source - sourceLabel additionalProperties: false description: An age- and sex-aware comparison band from a cited norm, in the record's unit. AchievementsEnvelope: type: object properties: data: anyOf: - $ref: "#/components/schemas/AchievementsResponse" - type: array items: $ref: "#/components/schemas/AchievementIosEntry" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false AchievementsResponse: type: object properties: summary: type: object properties: unlockedCount: type: integer minimum: -9007199254740991 maximum: 9007199254740991 totalCount: type: integer minimum: -9007199254740991 maximum: 9007199254740991 earnedPoints: type: integer minimum: -9007199254740991 maximum: 9007199254740991 totalPoints: type: integer minimum: -9007199254740991 maximum: 9007199254740991 completionPercent: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: 100 when the visible set is empty, not 0. nextAchievement: anyOf: - $ref: "#/components/schemas/Achievement" - type: "null" description: The closest badge still locked, redacted if hidden. required: - unlockedCount - totalCount - earnedPoints - totalPoints - completionPercent - nextAchievement additionalProperties: false achievements: type: array items: $ref: "#/components/schemas/Achievement" metrics: type: object propertyNames: type: string additionalProperties: {} description: The raw counters behind the badges. Counters that ONLY back a still-locked hidden badge are dropped from this map — the same spoiler shield the badge rows get. Partial by construction, so read defensively. required: - summary - achievements - metrics additionalProperties: false description: "The badge grid as the web surface consumes it: i18n keys plus the summary tally and the underlying counters. Badges whose owning module the account has turned off are not evaluated and do not appear." Achievement: type: object properties: id: type: string metric: type: string description: The counter this badge is graded against. category: type: string enum: - medication - vitals - mood - security - engagement - hidden titleKey: type: string description: i18n key. The default payload ships keys, not prose — the web client translates. `?format=ios` resolves them server-side instead. descriptionKey: type: string icon: type: string description: Lucide icon name. format: type: string enum: - count - days - percent target: type: number current: type: number points: type: integer minimum: -9007199254740991 maximum: 9007199254740991 unlocked: type: boolean progressPercent: type: number completedAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" isHidden: type: boolean description: An Easter-egg badge. While locked, the row is REDACTED to an opaque placeholder — the real title, description, icon and target are not on the wire until it unlocks. required: - id - metric - category - titleKey - descriptionKey - icon - format - target - current - points - unlocked - progressPercent - completedAt - isHidden additionalProperties: false description: "One badge with its progress. A locked hidden badge carries placeholder copy and zeroed target/current: the spoiler shield is applied server-side, not left to the client." AchievementIosEntry: type: object properties: id: type: string key: type: string description: Same value as `id`; kept for the client's model. title: type: string description: Resolved in the caller's locale. description: type: string iconName: type: string unlocked: type: boolean unlockedAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" progress: type: number description: 0–1, clamped. Not the 0–100 percent. category: type: string points: type: integer minimum: -9007199254740991 maximum: 9007199254740991 target: type: number current: type: number isHidden: type: boolean required: - id - key - title - description - iconName - unlocked - unlockedAt - progress - category - points - target - current - isHidden additionalProperties: false description: "The flattened, pre-translated badge the native client renders. Note the differences from the web shape: translated strings instead of keys, `iconName` instead of `icon`, `unlockedAt` instead of `completedAt`, and `progress` as a 0–1 fraction." EnvironmentPreferencesEnvelope: type: object properties: data: $ref: "#/components/schemas/EnvironmentPreferences" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false EnvironmentPreferences: type: object properties: airQualityEnabled: type: boolean operatorDisabled: type: boolean description: True when the operator turned air quality off for the whole instance; the account switch then has no effect. required: - airQualityEnabled - operatorDisabled additionalProperties: false EnvironmentOverviewEnvelope: type: object properties: data: $ref: "#/components/schemas/EnvironmentOverview" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false EnvironmentOverview: type: object properties: home: anyOf: - $ref: "#/components/schemas/EnvironmentHome" - type: "null" description: Null until a home has been set. travel: type: array items: $ref: "#/components/schemas/EnvironmentTravelLocation" description: Every stored override, newest start date first. context: type: object properties: days: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Stored daily observations for this account. latestDate: description: Day key of the newest stored observation. type: - string - "null" latestFetchedAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" required: - days - latestDate - latestFetchedAt additionalProperties: false lastFetchFailure: anyOf: - type: object properties: lastFailedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ description: When the newest exhausted-retry failure gave up. failures: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Failures for this account on the queue inside the window. required: - lastFailedAt - failures additionalProperties: false - type: "null" description: "Non-null when the background fetch has been failing, so a `days: 0` count is never left to stand alone as if it meant 'nothing to see'. Deliberately carries no error text: the operator's message can name internals. Also null when there is no queue to ask at all — absence of a queue is not a failing queue." attribution: type: string description: Upstream attribution string. Display it wherever the observations are shown. airQuality: type: object properties: enabled: type: boolean description: The account's switch (`PATCH /api/environment/preferences`). operatorDisabled: type: boolean description: True when the operator turned air quality off for the instance; the account switch then has no effect. days: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Stored days the air-quality feed returned hourly values for. latestDate: description: Newest such day (YYYY-MM-DD). type: - string - "null" domain: description: "The model domain of that day: `cams_europe` (about 11 km, with pollen) or `cams_global` (about 45 km, no pollen)." type: - string - "null" history: anyOf: - type: object properties: total: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Past days with entries the source can serve at their place (from 2013 in Europe, from August 2022 elsewhere). Older days stay without values. done: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Of those, the days whose air quality is stored. complete: type: boolean description: True when nothing the source serves is left. checkedAt: type: string description: ISO instant of the run that counted them. required: - total - done - complete - checkedAt additionalProperties: false - type: "null" description: Progress of the background backfill that fills air quality for every past day with entries. Null before its first run and while air quality is off. required: - enabled - operatorDisabled - days - latestDate - domain - history additionalProperties: false description: v1.42 — the air-quality part of the module. latestDay: anyOf: - type: object properties: date: type: string description: YYYY-MM-DD of the newest stored day. tempMin: type: - number - "null" tempMax: type: - number - "null" apparentMax: description: Daily feels-like maximum, °C. type: - number - "null" airQuality: anyOf: - type: object properties: pm25Mean: description: PM2.5 daily mean, µg/m³. type: - number - "null" pm10Mean: description: PM10 daily mean, µg/m³. type: - number - "null" no2Mean: description: NO2 daily mean, µg/m³. type: - number - "null" o3Max8h: description: Ozone, highest 8-hour running mean, µg/m³. type: - number - "null" eaqiMax: description: European air-quality index, daily maximum. type: - number - "null" uvIndexMax: type: - number - "null" dustMax: description: Dust, µg/m³. type: - number - "null" pollen: type: object properties: alder: type: - number - "null" birch: type: - number - "null" grass: type: - number - "null" mugwort: type: - number - "null" olive: type: - number - "null" ragweed: type: - number - "null" required: - alder - birch - grass - mugwort - olive - ragweed additionalProperties: false description: Daily maxima, grains/m³; null where not covered (outside Europe, before mid 2022). required: - pm25Mean - pm10Mean - no2Mean - o3Max8h - eaqiMax - uvIndexMax - dustMax - pollen additionalProperties: false - type: "null" description: Null while air quality is off for the account or the instance, and for a day whose air-quality part was not fetched yet. Null values inside mean not covered, never zero. required: - date - tempMin - tempMax - apparentMax - airQuality additionalProperties: false - type: "null" description: v1.42 — the newest stored day, usually yesterday or the day before (the archive settles late). No forecast. Null when nothing is stored. attributions: type: array items: type: string description: v1.42 — every attribution line, weather first; the air-quality lines (Open-Meteo, Copernicus) while air quality is on. Display them wherever the values are shown. required: - home - travel - context - lastFetchFailure - attribution - airQuality - latestDay - attributions additionalProperties: false description: "The module overview: home, travel overrides, a count of stored observations, and whether the background fetch is healthy." EnvironmentHome: type: object properties: lat: type: number description: Coarse latitude, rounded to 1 decimal (about 11 km). lon: type: number description: Coarse longitude, rounded to 1 decimal. label: type: - string - "null" timezone: type: string description: The home's IANA zone. Falls back to the account's display timezone when the home was stored without one. since: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" description: "Effective-from instant. Every set/update re-stamps it: a home resolves days from the moment it was set, and days before it stay un-attributed unless a travel override covers them." required: - lat - lon - label - timezone - since additionalProperties: false description: The account's stored coarse home location. EnvironmentTravelLocation: type: object properties: id: type: string startDate: type: string description: Inclusive YYYY-MM-DD. endDate: type: string description: Inclusive YYYY-MM-DD. lat: type: number lon: type: number label: type: string required: - id - startDate - endDate - lat - lon - label additionalProperties: false description: One manual travel override. Overlapping ranges are accepted — nothing on this surface refuses them. EnvironmentHomeEnvelope: type: object properties: data: type: object properties: home: $ref: "#/components/schemas/EnvironmentHome" required: - home additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false EnvironmentBackfillEnvelope: type: object properties: data: type: object properties: enqueued: type: boolean startDate: type: string description: The resolved start, after defaulting. endDate: type: string description: The resolved end, after defaulting. spanDays: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - enqueued - startDate - endDate - spanDays additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false EnvironmentGeocodeEnvelope: type: object properties: data: type: object properties: results: type: array items: $ref: "#/components/schemas/EnvironmentGeocodeResult" required: - results additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false EnvironmentGeocodeResult: type: object properties: lat: type: number description: Coarse latitude, rounded to 1 decimal. lon: type: number description: Coarse longitude, rounded to 1 decimal. label: type: string description: Assembled from name, region and country, e.g. "City, Region, Country". timezone: type: string description: IANA zone for the place, or the literal `"auto"` when the upstream feed reported none. required: - lat - lon - label - timezone additionalProperties: false CreateEnvironmentTravelEnvelope: type: object properties: data: $ref: "#/components/schemas/EnvironmentTravelLocation" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DeleteEnvironmentTravelEnvelope: type: object properties: data: type: object properties: deleted: type: boolean required: - deleted additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false ExternalMedicationIntakeReplayEnvelope: type: object properties: data: $ref: "#/components/schemas/MedicationIntakeEvent" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false ExternalMedicationIntakeCreatedEnvelope: type: object properties: data: $ref: "#/components/schemas/MedicationIntakeEvent" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false AdminBackupUploadEnvelope: type: object properties: data: $ref: "#/components/schemas/AdminBackupUploadResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false AdminBackupUploadResponse: type: object properties: id: type: string description: "`DataBackup.id` of the stored copy; pass it to `POST /api/admin/backups/{id}/restore`." valid: type: boolean const: true summary: $ref: "#/components/schemas/AdminBackupUploadSummary" required: - id - valid - summary additionalProperties: false description: The file was read, checked and stored as a new copy. Nothing was restored. AdminBackupUploadSummary: type: object properties: schemaVersion: type: string userId: type: string exportedAt: type: string required: - schemaVersion - userId - exportedAt additionalProperties: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: "What the stored file holds: its `schemaVersion`, the account it was exported from (`userId`) and when (`exportedAt`), and one count per serialised class (`measurements`, `medications`, `intakeEvents`, `moodEntries`, …). The set of counts grows with the backup format; read the ones you know and show the rest generically." AdminBackupDocument: type: object propertyNames: type: string additionalProperties: {} description: "The backup payload as the writer serialised it: `schemaVersion`, `exportedAt`, `userId`, and one array or object per serialised class." AdminBackupRestoreQueuedResponse: type: object properties: data: $ref: "#/components/schemas/AdminBackupRestoreQueued" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false AdminBackupRestoreQueued: type: object properties: jobId: type: string status: type: string const: queued statusUrl: type: string required: - jobId - status - statusUrl additionalProperties: false description: The restore was accepted and queued. Poll `statusUrl` (`GET /api/admin/backups/restores/{jobId}`) for its progress and outcome. AdminBackupRestoreJobListResponse: type: object properties: data: type: object properties: jobs: type: array items: $ref: "#/components/schemas/AdminBackupRestoreJob" required: - jobs additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false AdminBackupRestoreJob: type: object properties: id: type: string userId: type: string username: type: - string - "null" backupId: type: string restoreInstanceSettings: type: boolean status: type: string enum: - queued - running - succeeded - failed phase: anyOf: - $ref: "#/components/schemas/AdminBackupRestorePhase" - type: "null" progress: anyOf: - $ref: "#/components/schemas/AdminBackupRestoreProgress" - type: "null" result: anyOf: - type: object properties: summary: type: object propertyNames: type: string additionalProperties: {} skipped: type: object properties: catalogueKeys: type: array items: $ref: "#/components/schemas/AdminBackupRestoreSkipEntry" links: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - catalogueKeys - links additionalProperties: false cleared: type: object propertyNames: type: string additionalProperties: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - summary - skipped - cleared additionalProperties: false - type: "null" failure: anyOf: - $ref: "#/components/schemas/AdminBackupRestoreFailure" - type: "null" waiting: anyOf: - type: string enum: - backup - busy - type: "null" description: "Why a `queued` job has not started. A restore never runs beside a backup or another large job: while one holds the server it goes back to the queue for a minute at a time. `backup` when a backup pass holds it, `busy` for anything else (an import, a nightly pass, the memory guard). Null while the job only waits for the worker, and on every job that is not queued." attempts: type: integer minimum: -9007199254740991 maximum: 9007199254740991 createdAt: type: string startedAt: type: - string - "null" completedAt: type: - string - "null" required: - id - userId - username - backupId - restoreInstanceSettings - status - phase - progress - result - failure - waiting - attempts - createdAt - startedAt - completedAt additionalProperties: false description: "One restore job. `phase` and `progress` describe a running job; `result` is set once it `succeeded` (the same report the synchronous restore used to answer with, less `restored`); `failure` once it `failed`. `attempts` counts the starts: a job whose worker stopped before the data was committed is started once more; one that stopped after the commit is closed as `failed_after_commit` and never run again." AdminBackupRestorePhase: type: string enum: - validating - clearing - measurements - sections - rebuilding description: "Where a running restore is: reading and checking the file, clearing the account's current data, writing the readings, writing the other sections, rebuilding the chart tiers after the commit." AdminBackupRestoreProgress: type: object properties: measurementsChecked: type: integer minimum: -9007199254740991 maximum: 9007199254740991 measurementsTotal: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" measurementsWritten: type: integer minimum: -9007199254740991 maximum: 9007199254740991 sectionsDone: type: integer minimum: -9007199254740991 maximum: 9007199254740991 sectionsTotal: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - measurementsChecked - measurementsTotal - measurementsWritten - sectionsDone - sectionsTotal additionalProperties: false description: Counts only. `measurementsTotal` is null until the first read of the file has counted the readings. AdminBackupRestoreSkipEntry: type: object properties: catalogue: type: string key: type: string links: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - catalogue - key - links additionalProperties: false description: One catalogue key the file referenced and this instance could not resolve, with the number of links it cost. AdminBackupRestoreFailure: type: object properties: code: type: string description: "Stable reason: `backup_not_found`, `backup_changed`, `backup.payload.undecryptable`, `schema_invalid`, `incompatible_schema_version`, `owner_mismatch`, `owner_not_found`, `backup.section.missing`, `document_ciphertext_missing`, `backup.key.missing`, `backup.foreign_reference`, `time_budget`, `transaction_failed`, `interrupted`, `not_started`, `enqueue_failed`, `failed_after_commit`, `unexpected`. Every code but `failed_after_commit` means the account was not changed." message: type: string sections: type: array items: type: string keyIds: type: array items: type: string cause: type: string enum: - timeout - lock - constraint - storage - connection - other required: - code - message additionalProperties: false description: "Why the restore did not happen, in a sentence an operator can act on. `sections` names the missing sections for `backup.section.missing`; `keyIds` names the encryption key ids the file needs and this server cannot open it with, for `backup.key.missing`; `cause` says what the database refused a `transaction_failed` restore over: it ran out of time, waited on a lock, rejected a record, ran out of disk space or memory, lost the connection, or something else." AdminBackupRestoreJobResponse: type: object properties: data: $ref: "#/components/schemas/AdminBackupRestoreJob" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DocumentSourcesStatusEnvelope: type: object properties: data: $ref: "#/components/schemas/DocumentSourcesStatus" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DocumentSourcesStatus: type: object properties: available: type: boolean allowedOrigins: type: array items: type: string connections: type: array items: $ref: "#/components/schemas/DocumentSourceConnection" required: - available - allowedOrigins - connections additionalProperties: false DocumentSourceConnection: type: object properties: system: type: string enum: - PAPERLESS - PAPRA baseUrl: type: string organizationId: type: - string - "null" hasToken: type: boolean const: true lastVerifiedAt: type: - string - "null" originAllowed: type: boolean required: - system - baseUrl - organizationId - hasToken - lastVerifiedAt - originAllowed additionalProperties: false DocumentSourceConnectionEnvelope: type: object properties: data: $ref: "#/components/schemas/DocumentSourceConnection" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DocumentSourceDisconnectEnvelope: type: object properties: data: $ref: "#/components/schemas/DocumentSourceDisconnect" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DocumentSourceDisconnect: type: object properties: disconnected: type: boolean required: - disconnected additionalProperties: false DocumentSourceTestEnvelope: type: object properties: data: $ref: "#/components/schemas/DocumentSourceTest" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DocumentSourceTest: type: object properties: ok: type: boolean const: true latencyMs: type: integer minimum: -9007199254740991 maximum: 9007199254740991 lastVerifiedAt: type: string required: - ok - latencyMs - lastVerifiedAt additionalProperties: false DocumentSourceTagsEnvelope: type: object properties: data: $ref: "#/components/schemas/DocumentSourceTags" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DocumentSourceTags: type: object properties: tags: type: array items: $ref: "#/components/schemas/DocumentSourceTag" required: - tags additionalProperties: false DocumentSourceTag: type: object properties: id: type: string name: type: string required: - id - name additionalProperties: false DocumentSourceSearchEnvelope: type: object properties: data: $ref: "#/components/schemas/DocumentSourceSearch" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DocumentSourceSearch: type: object properties: results: type: array items: $ref: "#/components/schemas/DocumentSourceResult" page: type: integer minimum: -9007199254740991 maximum: 9007199254740991 hasMore: type: boolean required: - results - page - hasMore additionalProperties: false DocumentSourceResult: type: object properties: sourceId: type: string title: type: string date: type: - string - "null" tags: type: array items: type: string sizeBytes: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" state: type: string enum: - new - imported - deleted documentId: description: The vault document for `imported`. For `deleted`, the document while it is still in Recently deleted (restorable), null once it has been removed for good. type: - string - "null" required: - sourceId - title - date - tags - sizeBytes - state - documentId additionalProperties: false DocumentSourceImportHeldEnvelope: type: object properties: data: $ref: "#/components/schemas/DocumentSourceImport" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DocumentSourceImport: type: object properties: outcome: type: string enum: - imported - duplicate - deleted - restored description: "`restored` only answers a request that sent `revive: true`: the deleted document was still in Recently deleted and is back in the vault." documentId: type: - string - "null" linked: type: boolean required: - outcome - documentId - linked additionalProperties: false DocumentSourceImportEnvelope: type: object properties: data: $ref: "#/components/schemas/DocumentSourceImport" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false ListSymptomDefinitionsEnvelope: type: object properties: data: $ref: "#/components/schemas/SymptomDefinitionList" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false SymptomDefinitionList: type: object properties: definitions: type: array items: $ref: "#/components/schemas/SymptomDefinition" limit: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - definitions - limit additionalProperties: false description: The record's symptom definitions in display order, and the active-definition cap. SymptomDefinition: type: object properties: id: type: string label: type: - string - "null" icon: type: - string - "null" sortOrder: type: integer minimum: -9007199254740991 maximum: 9007199254740991 isActive: type: boolean recent: type: object properties: count30d: type: integer minimum: -9007199254740991 maximum: 9007199254740991 maxIntensity30d: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" lastOccurredAt: type: - string - "null" required: - count30d - maxIntensity30d - lastOccurredAt additionalProperties: false required: - id - label - icon - sortOrder - isActive - recent additionalProperties: false description: "A symptom the person defined. `label` is decrypted (null only on an unreadable value). `recent` is computed server-side: occurrences and the highest intensity over the last 30 days, and the last occurrence ever. Each active definition is also the correlation channel `SYMPTOM:`." CreateSymptomDefinitionEnvelope: type: object properties: data: $ref: "#/components/schemas/SymptomDefinition" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false UpdateSymptomDefinitionEnvelope: type: object properties: data: $ref: "#/components/schemas/SymptomDefinition" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DeleteSymptomDefinitionEnvelope: type: object properties: data: $ref: "#/components/schemas/SymptomDefinitionDeleted" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false SymptomDefinitionDeleted: type: object properties: deleted: type: boolean const: true purged: type: boolean eventsDeleted: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - deleted - purged - eventsDeleted additionalProperties: false description: "`purged: false` means the definition was hidden and its history kept; `purged: true` means it and `eventsDeleted` occurrences were removed." ListSymptomEventsEnvelope: type: object properties: data: $ref: "#/components/schemas/SymptomEventList" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false SymptomEventList: type: object properties: events: type: array items: $ref: "#/components/schemas/SymptomEvent" limit: type: integer minimum: -9007199254740991 maximum: 9007199254740991 hasMore: type: boolean required: - events - limit - hasMore additionalProperties: false description: A page of occurrences, newest first. `hasMore` is true exactly when an occurrence past this page exists; page on with `to` set to the oldest `occurredAt` seen. SymptomEvent: type: object properties: id: type: string definitionId: type: string occurredAt: type: string intensity: type: integer minimum: -9007199254740991 maximum: 9007199254740991 note: type: - string - "null" episodeId: type: - string - "null" createdAt: type: string updatedAt: type: string required: - id - definitionId - occurredAt - intensity - note - episodeId - createdAt - updatedAt additionalProperties: false description: "One occurrence of a defined symptom: when, how strong (0-10), the decrypted note, and the illness episode it was filed against (null when none)." CreateSymptomEventEnvelope: type: object properties: data: $ref: "#/components/schemas/SymptomEvent" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false UpdateSymptomEventEnvelope: type: object properties: data: $ref: "#/components/schemas/SymptomEvent" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DeleteSymptomEventEnvelope: type: object properties: data: type: object properties: deleted: type: boolean const: true required: - deleted additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DayIndexEnvelope: type: object properties: data: $ref: "#/components/schemas/DayIndex" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DayIndex: type: object properties: from: type: string description: A local calendar date, `YYYY-MM-DD`. to: type: string description: A local calendar date, `YYYY-MM-DD`. days: type: object propertyNames: type: string additionalProperties: type: array items: type: string enum: - values - sleep - scores - mood - assessments - medications - illness - symptoms - allergies - labs - visits - vaccinations - checkups - documents - workouts - cycle - environment - lifeEvents description: Each day in the window that holds anything, keyed `YYYY-MM-DD`, with the sections it holds. Days without entries are absent. notable: type: array items: type: string description: Days in the window that carry a notable observation. required: - from - to - days - notable additionalProperties: false DayNotableWindowEnvelope: type: object properties: data: $ref: "#/components/schemas/DayNotableWindow" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DayNotableWindow: type: object properties: from: type: string description: A local calendar date, `YYYY-MM-DD`. to: type: string description: A local calendar date, `YYYY-MM-DD`. anchor: type: string enum: - nextVisit - lastVisit - requested - fallback description: "Where `from` came from. `requested`: the request named it. `nextVisit`: no `from` was sent and a visit is booked; `from` is that visit's `preparation.from` on `GET /api/encounters` (the soonest PLANNED visit ahead), so a client that omits `from` reads the window the web shows for it. `lastVisit`: nothing is booked; the day of the last completed visit. `fallback`: 90 days, when the record holds no visit the caller may see. Clients should treat an unknown value like `fallback`." observations: type: array items: $ref: "#/components/schemas/DatedDayNotable" changes: type: array items: $ref: "#/components/schemas/DayChange" description: "Context changes in the window, oldest first: dose changes, medication and course starts and ends, pauses, illness onsets and recoveries, vaccinations, procedures and lab days." required: - from - to - anchor - observations - changes additionalProperties: false DatedDayNotable: type: object properties: date: type: string description: A local calendar date, `YYYY-MM-DD`. kind: type: string enum: - extremeHigh - extremeLow - firstValue - gap type: type: - string - "null" params: type: object propertyNames: type: string additionalProperties: type: - string - number description: Parameters for the message the client words from its own bundle. Dates are `YYYY-MM-DD`, values are numbers in the unit the entry names. required: - date - kind - type - params additionalProperties: false DayChange: type: object properties: date: type: string description: A local calendar date, `YYYY-MM-DD`. kind: type: string enum: - intake - doseChange - medicationStart - medicationEnd - pauseStart - pauseEnd - courseStart - courseEnd - illnessOnset - illnessResolved - illnessDayLog - symptom - allergyOnset - labResult - visit - procedure - vaccination - checkup - document - mood - assessment - workout - cycleDayLog - lifeEvent section: type: string enum: - values - sleep - scores - mood - assessments - medications - illness - symptoms - allergies - labs - visits - vaccinations - checkups - documents - workouts - cycle - environment - lifeEvents id: type: string title: type: string description: The record's own name for it, decrypted for the caller. count: type: integer exclusiveMinimum: 0 maximum: 9007199254740991 description: Entries folded into this one (lab results of one day). href: type: - string - "null" required: - date - kind - section - id - title - count - href additionalProperties: false DayEnvelope: type: object properties: data: $ref: "#/components/schemas/Day" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false Day: type: object properties: date: type: string description: A local calendar date, `YYYY-MM-DD`. tz: type: string description: The IANA zone the day was cut in. counts: type: object properties: values: type: integer minimum: 0 maximum: 9007199254740991 entries: type: integer minimum: 0 maximum: 9007199254740991 required: - values - entries additionalProperties: false running: type: array items: $ref: "#/components/schemas/DayRunningItem" values: type: array items: $ref: "#/components/schemas/DayValue" events: type: array items: $ref: "#/components/schemas/DayEvent" notable: type: array items: $ref: "#/components/schemas/DayNotable" scores: type: array items: $ref: "#/components/schemas/DayScore" description: The scores the record holds for this day, in `DAY_SCORE_KEYS` order. A score with no value that day is absent; an empty list means none. sections: type: object propertyNames: type: string enum: - values - sleep - scores - mood - assessments - medications - illness - symptoms - allergies - labs - visits - vaccinations - checkups - documents - workouts - cycle - environment - lifeEvents additionalProperties: type: object properties: available: type: boolean const: false reason: type: string enum: - not_shared - module_disabled required: - available - reason additionalProperties: false description: Sections the caller cannot see. A section of a switched-off module is left out entirely rather than listed. movement: description: v1.43 — the day's movement context; absent for a delegate, with the `environment` module off, with no Dawarich connection, and on older servers. anyOf: - $ref: "#/components/schemas/DayMovement" - type: "null" required: - date - tz - counts - running - values - events - notable - scores - sections additionalProperties: false DayRunningItem: type: object properties: kind: type: string enum: - medication - medicationCourse - medicationPause - illness - restMode - cyclePhase - travel - lifeEvent section: type: string enum: - values - sleep - scores - mood - assessments - medications - illness - symptoms - allergies - labs - visits - vaccinations - checkups - documents - workouts - cycle - environment - lifeEvents id: type: string title: type: string description: Decrypted server-side for the caller's own session. sub: description: A second line, already formatted (a dose, a destination). type: - string - "null" since: anyOf: - type: string description: A local calendar date, `YYYY-MM-DD`. - type: "null" description: The record's own start date. Null when the record holds none (a medication filed without a start date); the entry's creation date is never put in its place. until: anyOf: - type: string description: A local calendar date, `YYYY-MM-DD`. - type: "null" description: Null while it is still open. dayIndex: anyOf: - type: integer exclusiveMinimum: 0 maximum: 9007199254740991 - type: "null" description: Day n of the period, counting `since` as day 1; null when `since` is null. dayCount: anyOf: - type: integer exclusiveMinimum: 0 maximum: 9007199254740991 - type: "null" description: Length of a closed period in days; null while open. href: description: In-app path to the record this came from. type: - string - "null" required: - kind - section - id - title - sub - since - until - dayIndex - dayCount - href additionalProperties: false DayValue: type: object properties: type: type: string description: A `MeasurementType`. value: type: number unit: type: string at: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ source: type: string description: The `MeasurementSource` of the canonical row. band: anyOf: - type: object properties: lo: type: number hi: type: number n: type: integer minimum: 0 maximum: 9007199254740991 required: - lo - hi - n additionalProperties: false - type: "null" description: The person's own usual range over the 30 days before this day, from their daily values. Null with too little history. required: - type - value - unit - at - source - band additionalProperties: false DayEvent: type: object properties: at: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" description: The instant, or null for something that has a date only. kind: type: string enum: - intake - doseChange - medicationStart - medicationEnd - pauseStart - pauseEnd - courseStart - courseEnd - illnessOnset - illnessResolved - illnessDayLog - symptom - allergyOnset - labResult - visit - procedure - vaccination - checkup - document - mood - assessment - workout - cycleDayLog - lifeEvent section: type: string enum: - values - sleep - scores - mood - assessments - medications - illness - symptoms - allergies - labs - visits - vaccinations - checkups - documents - workouts - cycle - environment - lifeEvents id: type: string title: type: string description: "The record's own name, or a code the client words: a mood, a screener's instrument, a workout's sport, `cycle` for a cycle day." meta: description: "The second line: text (a dose, a lab value, a duration) or a code or number the client words (a category, a kind, a severity, a screener's score and band, an intensity, a dose number)." type: - string - "null" note: description: The person's own note, decrypted for the caller's session. type: - string - "null" docs: type: array items: type: object properties: id: type: string name: type: string required: - id - name additionalProperties: false description: Documents filed against the entry. href: type: - string - "null" required: - at - kind - section - id - title - meta - note - docs - href additionalProperties: false DayNotable: type: object properties: kind: type: string enum: - extremeHigh - extremeLow - firstValue - gap type: description: The `MeasurementType` it is about. type: - string - "null" params: type: object propertyNames: type: string additionalProperties: type: - string - number description: Parameters for the message the client words from its own bundle. Dates are `YYYY-MM-DD`, values are numbers in the unit the entry names. required: - kind - type - params additionalProperties: false DayScore: type: object properties: key: type: string enum: - healthScore - readiness - recovery - sleepScore - strain value: type: number description: "The score on its own scale: whole points for a 0 to 100 score, one decimal for a device's day strain." max: type: integer exclusiveMinimum: 0 maximum: 9007199254740991 description: "The top of the scale: 100, or 21 for a device's day strain." source: type: string description: "`COMPUTED` for a score the server derived, else the `MeasurementSource` of the device row." band: anyOf: - type: object properties: lo: type: number hi: type: number n: type: integer minimum: 0 maximum: 9007199254740991 required: - lo - hi - n additionalProperties: false - type: "null" description: The person's own usual range of the score over the 30 days before this day, on the same scale. Null with too little history. required: - key - value - max - source - band additionalProperties: false DayMovement: type: object properties: source: type: string const: dawarich distance: type: object properties: unit: type: string enum: - km - mi description: Resolved from the account's unit preference. total: type: - number - "null" driving: type: - number - "null" train: type: - number - "null" required: - unit - total - driving - train additionalProperties: false placesVisited: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" awayFromHomeMinutes: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" description: "`null` means no coverage that day, not zero." travel: anyOf: - type: object properties: isTravelDay: type: boolean countryCode: type: - string - "null" required: - isTravelDay - countryCode additionalProperties: false - type: "null" fetchedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - source - distance - placesVisited - awayFromHomeMinutes - travel - fetchedAt additionalProperties: false description: Distances to one decimal in `distance.unit`. Hide a null field; never show it as zero. TimelineEnvelope: type: object properties: data: $ref: "#/components/schemas/Timeline" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false Timeline: type: object properties: zoom: type: string enum: - all - year - quarter - range range: type: object properties: from: type: string description: A local calendar date, `YYYY-MM-DD`. to: type: string description: A local calendar date, `YYYY-MM-DD`. dataFrom: anyOf: - type: string description: A local calendar date, `YYYY-MM-DD`. - type: "null" description: The earliest date anything in the record carries. required: - from - to - dataFrom additionalProperties: false lanes: type: array items: type: object properties: key: type: string enum: - life - illness - allergies - medications - vaccinations - visits - labs - documents - cycle items: type: array items: $ref: "#/components/schemas/TimelineItem" required: - key - items additionalProperties: false standing: type: array items: $ref: "#/components/schemas/TimelineStandingItem" description: "Things without a start: shown once as standing." bucket: type: string enum: - quarter - month - week - day description: "The span every series point averages: `quarter` for a multi-year `all`, `month` for `year` and an `all` under two years, `week` for `quarter`, and for a `range` by its length: `quarter` from two years, `month` from 120 days, `week` from 42 days, `day` below." series: type: array items: $ref: "#/components/schemas/TimelineSeries" availableSeries: type: array items: type: string description: "Every value series this record can show, whatever the window: the `MeasurementType` names with live readings that the caller may see (events excluded), and `MOOD` when the mood score is visible and has entries. Each may be named in `values`." notable: type: array items: type: object properties: date: type: string description: A local calendar date, `YYYY-MM-DD`. kind: type: string enum: - extremeHigh - extremeLow - firstValue - gap required: - date - kind additionalProperties: false required: - zoom - range - lanes - standing - bucket - series - availableSeries - notable additionalProperties: false TimelineItem: type: object properties: id: type: string kind: type: string enum: - lifeEvent - travel - episode - chronic - allergy - medication - course - doseChange - pause - vaccination - visit - procedure - labDay - document - cycle start: type: string description: A local calendar date, `YYYY-MM-DD`. end: anyOf: - type: string description: A local calendar date, `YYYY-MM-DD`. - type: "null" description: Null for a point or an open span. open: type: boolean description: A span that has not ended. precision: type: string enum: - DAY - MONTH - YEAR startKnown: type: boolean description: False when the start is a stand-in (a medication without a start date starts at its first intake). label: type: string description: The item's name. A trip is labelled `travel` and a cycle `cycle`; the client words both. sub: description: "The second line, by kind: a code for a life event (its category), a visit or procedure (its encounter kind) and a document (its kind); the analyte count for a lab day; the dose as text for a medication or a dose change. The client words every code." type: - string - "null" href: type: - string - "null" group: description: "Items that belong to one thing share a group and are drawn on one row: a medication's own span, its courses, its dose changes and its pauses all carry the medication's id. Null for an item that stands alone." type: - string - "null" required: - id - kind - start - end - open - precision - startKnown - label - sub - href - group additionalProperties: false TimelineStandingItem: type: object properties: lane: type: string enum: - life - illness - allergies - medications - vaccinations - visits - labs - documents - cycle id: type: string label: type: string since: anyOf: - type: string description: A local calendar date, `YYYY-MM-DD`. - type: "null" href: type: - string - "null" required: - lane - id - label - since - href additionalProperties: false TimelineSeries: type: object properties: key: type: string description: A `MeasurementType` or `MOOD`. unit: type: - string - "null" points: type: array items: type: object properties: t: type: string description: The bucket's first day. mean: type: number description: The mean of the bucket's day values, each day weighing one (a cumulative type's days are its day totals, a pulse-like type's its hourly-mean day value). count: type: integer minimum: 1 maximum: 9007199254740991 description: Readings behind the mean. Clients draw a point from fewer than three as thin. required: - t - mean - count additionalProperties: false description: Buckets with at least one reading, oldest first. A bucket without one is absent, never interpolated. required: - key - unit - points additionalProperties: false TimelineReadinessEnvelope: type: object properties: data: $ref: "#/components/schemas/TimelineReadiness" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false TimelineReadiness: type: object properties: verdict: type: string enum: - carries - thin since: anyOf: - type: string description: A local calendar date, `YYYY-MM-DD`. - type: "null" description: The earliest date the carrying lanes reach back to. lanes: type: array items: type: object properties: key: type: string enum: - values - life - illness - allergies - medications - vaccinations - visits - labs - documents - cycle - mood - environment status: type: string enum: - carries - thin - empty count: type: integer minimum: 0 maximum: 9007199254740991 detail: anyOf: - type: object properties: key: type: string params: type: object propertyNames: type: string additionalProperties: type: - string - number description: Parameters for the message the client words from its own bundle. Dates are `YYYY-MM-DD`, values are numbers in the unit the entry names. required: - key - params additionalProperties: false - type: "null" description: Worded by the client from `timeline.readiness.detail`. gaps: type: array items: type: object properties: key: type: string count: type: integer minimum: 0 maximum: 9007199254740991 href: type: string required: - key - count - href additionalProperties: false required: - key - status - count - detail - gaps additionalProperties: false required: - verdict - since - lanes additionalProperties: false LifeEventListEnvelope: type: object properties: data: $ref: "#/components/schemas/LifeEventList" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false LifeEventList: type: object properties: events: type: array items: $ref: "#/components/schemas/LifeEvent" required: - events additionalProperties: false LifeEvent: type: object properties: id: type: string category: type: string enum: - FAMILY - HOME - WORK - LOSS - OTHER startDate: type: string description: A local calendar date, `YYYY-MM-DD`. endDate: anyOf: - type: string description: A local calendar date, `YYYY-MM-DD`. - type: "null" precision: type: string enum: - DAY - MONTH - YEAR title: description: Decrypted; null only when the stored title cannot be read. type: - string - "null" note: type: - string - "null" createdAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ updatedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - id - category - startDate - endDate - precision - title - note - createdAt - updatedAt additionalProperties: false description: A dated anchor in the person's life. Shared under the `profile` domain; never sent to a model. CreateLifeEventEnvelope: type: object properties: data: $ref: "#/components/schemas/LifeEvent" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false UpdateLifeEventEnvelope: type: object properties: data: $ref: "#/components/schemas/LifeEvent" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DeleteLifeEventEnvelope: type: object properties: data: $ref: "#/components/schemas/LifeEvent" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false SearchEnvelope: type: object properties: data: $ref: "#/components/schemas/SearchResponse" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false SearchResponse: type: object properties: groups: type: array items: type: object properties: kind: type: string enum: - visit - condition - document - vaccination - lab - medication - symptom - workout - note hits: type: array items: $ref: "#/components/schemas/SearchHit" more: type: boolean required: - kind - hits - more additionalProperties: false truncated: type: boolean description: The search stopped before it had read every entry (a per-kind row cap or the time budget), so older entries may match and are not listed. required: - groups - truncated additionalProperties: false SearchHit: type: object properties: id: type: string kind: type: string enum: - visit - condition - document - vaccination - lab - medication - symptom - workout - note title: anyOf: - $ref: "#/components/schemas/SearchMarkedText" - type: "null" context: type: array items: $ref: "#/components/schemas/SearchMarkedText" snippet: anyOf: - $ref: "#/components/schemas/SearchMarkedText" - type: "null" day: type: - string - "null" path: type: string description: In-app path that opens the record, never an API path. source: anyOf: - type: string enum: - mood - measurement - illness - type: "null" required: - id - kind - title - context - snippet - day - path - source additionalProperties: false SearchMarkedText: type: object properties: text: type: string marks: type: array items: type: array prefixItems: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: integer minimum: -9007199254740991 maximum: 9007199254740991 items: false minItems: 2 maxItems: 2 description: Half-open [start, end) character ranges of `text` a query word matched. required: - text - marks additionalProperties: false PutAiUsageResponse: type: object properties: data: $ref: "#/components/schemas/AiUsageState" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false PutMedicationIngredientsResponse: type: object properties: data: type: object properties: ingredients: type: array items: $ref: "#/components/schemas/MedicationIngredient" recompute: type: object properties: queued: type: boolean required: - queued additionalProperties: false required: - ingredients - recompute additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false LifeCircumstancesEnvelope: type: object properties: data: $ref: "#/components/schemas/LifeCircumstances" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false LifeCircumstances: type: object properties: sections: type: array items: type: object properties: key: type: string enum: - tobacco - alcohol - activity - work - habits - goals aiSection: type: string enum: - SMOKING_STATUS - ALCOHOL_PATTERN - ACTIVITY - WORK - HABITS - GOALS aiIncluded: type: boolean lastConfirmedAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" reviewDue: type: boolean description: Older than 365 days; show the quiet yearly check. facts: type: array items: $ref: "#/components/schemas/LifeFactRevision" required: - key - aiSection - aiIncluded - lastConfirmedAt - reviewDue - facts additionalProperties: false description: Always all six, in this order. extraAiSections: type: object properties: SHIFT_SCHEDULE: type: boolean required: - SHIFT_SCHEDULE additionalProperties: false derived: type: object properties: packYears: type: object properties: value: type: - number - "null" status: type: string enum: - computed - not_applicable - missing_inputs required: - value - status additionalProperties: false activityMinutesPerWeek: type: object properties: value: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" meetsWho150: type: - boolean - "null" measuredStepsAvailable: type: boolean required: - value - meetsWho150 - measuredStepsAvailable additionalProperties: false freeWeekdays: type: object properties: days: type: array items: type: integer minimum: 1 maximum: 7 source: type: string enum: - fact - default_weekend required: - days - source additionalProperties: false screeningHints: type: array items: type: object properties: id: type: string enum: - lung_ldct - aaa_ultrasound status: type: string enum: - suggested - not_eligible - unknown reasonKeys: type: array items: type: string region: type: string enum: - DE - US sourceLabel: type: string sourceUrl: type: string required: - id - status - reasonKeys - region - sourceLabel - sourceUrl additionalProperties: false required: - packYears - activityMinutesPerWeek - freeWeekdays - screeningHints additionalProperties: false auditC: type: object properties: latest: anyOf: - $ref: "#/components/schemas/AuditCResult" - type: "null" description: The newest entry whose answers this host can open; an entry it cannot open is listed in `history` only. history: type: array items: type: object properties: id: type: string takenAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ totalScore: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - id - takenAt - totalScore additionalProperties: false description: Newest first, at most 20. offerDue: type: boolean description: No readable entry, or the newest one whose answers open (the one in `latest`) is 365 days old or older. instrument: type: object properties: items: type: array items: type: object properties: stem: type: string options: type: array items: type: object properties: value: type: integer minimum: -9007199254740991 maximum: 9007199254740991 label: type: string required: - value - label additionalProperties: false required: - stem - options additionalProperties: false description: The three items verbatim in the account language; render as delivered. locale: type: string validatedLocale: type: boolean standardDrink: type: string attribution: type: string required: - items - locale - validatedLocale - standardDrink - attribution additionalProperties: false required: - latest - history - offerDue - instrument additionalProperties: false takeOver: type: array items: type: object properties: field: type: string enum: - conditions - allergies freeTextPresent: type: boolean structuredCount: type: integer minimum: -9007199254740991 maximum: 9007199254740991 available: type: boolean required: - field - freeTextPresent - structuredCount - available additionalProperties: false required: - sections - extraAiSections - derived - auditC - takeOver additionalProperties: false description: The six life-circumstance sections with their answers, AI switches and review state, the derived values with their sources, AUDIT-C, and the take-over hints for the free-text conditions and allergies. Every number is resolved on the server. LifeFactRevision: type: object properties: id: type: string kind: type: string enum: - SMOKING_STATUS - SHIFT_SCHEDULE - TOBACCO_DETAIL - ACTIVITY_HABIT - WORK_CONTEXT - FREE_WEEKDAYS - HABITS - HEALTH_GOALS value: description: "`SMOKING_STATUS` and `SHIFT_SCHEDULE` carry their string value; every other kind an object with `schema: 1` whose shape is fixed per kind (see the life-circumstances contract). `null` when `unreadable`." unreadable: type: boolean description: The sealed value could not be opened on this host. validFrom: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ validUntil: type: "null" provenance: type: string enum: - USER_REPORTED - USER_CORRECTION createdAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - id - kind - value - unreadable - validFrom - validUntil - provenance - createdAt additionalProperties: false description: The current revision of one life-circumstance fact. AuditCResult: type: object properties: id: type: string takenAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ items: type: array prefixItems: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: integer minimum: -9007199254740991 maximum: 9007199254740991 items: false minItems: 3 maxItems: 3 totalScore: type: integer minimum: 0 maximum: 12 locale: type: string cutoff: anyOf: - type: object properties: value: type: integer minimum: -9007199254740991 maximum: 9007199254740991 sex: type: string enum: - male - female source: type: string enum: - S3_ALCOHOL_DE - KNHANES_KR - BRADLEY_2007 sourceLabel: type: string sourceUrl: type: string required: - value - sex - source - sourceLabel - sourceUrl additionalProperties: false - type: "null" description: The screening cut-off for the entry's language and the profile's sex (German S3 guideline, Korean KNHANES, else Bradley 2007). `null` when the profile has no sex; show the sum only. aboveCutoff: type: - boolean - "null" validatedLocale: type: boolean description: Whether the item wording shown was a validated translation for `locale`. required: - id - takenAt - items - totalScore - locale - cutoff - aboveCutoff - validatedLocale additionalProperties: false description: One AUDIT-C entry, scored on the server. A screening result, not a diagnosis. LifeAiSectionsEnvelope: type: object properties: data: type: object properties: aiSections: type: object propertyNames: type: string enum: - SMOKING_STATUS - ALCOHOL_PATTERN - SHIFT_SCHEDULE - ACTIVITY - WORK - HABITS - GOALS additionalProperties: type: boolean updatedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - aiSections - updatedAt additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false LifeConfirmEnvelope: type: object properties: data: type: object properties: section: type: string enum: - tobacco - alcohol - activity - work - habits - goals lastConfirmedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - section - lastConfirmedAt additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false AuditCReplayEnvelope: type: object properties: data: type: object properties: result: $ref: "#/components/schemas/AuditCResult" required: - result additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false AuditCCreatedEnvelope: type: object properties: data: type: object properties: result: $ref: "#/components/schemas/AuditCResult" required: - result additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false AuditCRemovedEnvelope: type: object properties: data: type: object properties: id: type: string removedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - id - removedAt additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DawarichConnectEnvelope: type: object properties: data: type: object properties: connected: type: boolean const: true required: - connected additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DawarichStatusEnvelope: type: object properties: data: $ref: "#/components/schemas/DawarichStatus" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DawarichStatus: type: object properties: connected: type: boolean configured: type: boolean homePlaceSet: type: boolean homeCountry: type: - string - "null" daysStored: type: integer minimum: -9007199254740991 maximum: 9007199254740991 oldestDay: type: - string - "null" latestDay: type: - string - "null" health: anyOf: - $ref: "#/components/schemas/ConnectionHealth" - type: "null" required: - connected - configured - homePlaceSet - homeCountry - daysStored - oldestDay - latestDay - health additionalProperties: false description: The Dawarich connection. `health` is the shape every connection publishes; there are no legacy ledger fields. DawarichPlacesEnvelope: type: object properties: data: type: object properties: places: type: array items: type: object properties: id: type: integer minimum: -9007199254740991 maximum: 9007199254740991 name: type: string required: - id - name additionalProperties: false required: - places additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DawarichSettingsEnvelope: type: object properties: data: type: object properties: homePlaceId: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" homeCountry: type: - string - "null" required: - homePlaceId - homeCountry additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DawarichProbeEnvelope: type: object properties: data: type: object properties: ok: type: boolean const: true lastSyncedAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" latencyMs: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - ok - lastSyncedAt - latencyMs additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DawarichSyncEnvelope: type: object properties: data: type: object properties: enqueued: type: boolean required: - enqueued additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false DawarichDisconnectEnvelope: type: object properties: data: type: object properties: disconnected: type: boolean const: true deletedDays: type: integer minimum: -9007199254740991 maximum: 9007199254740991 required: - disconnected - deletedDays additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false TravelSuggestionsEnvelope: type: object properties: data: type: object properties: suggestions: type: array items: $ref: "#/components/schemas/TravelSuggestion" required: - suggestions additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false TravelSuggestion: type: object properties: id: type: string startDate: type: string endDate: type: string countryCode: type: string required: - id - startDate - endDate - countryCode additionalProperties: false description: A run of consecutive travel days no stored trip covers. Accept it through `POST /api/environment/travel` (the person picks the place). TravelSuggestionDismissEnvelope: type: object properties: data: type: object properties: dismissed: type: boolean const: true required: - dismissed additionalProperties: false error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false MedicationLinkResponse: type: object properties: data: $ref: "#/components/schemas/RelationLinkResult" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false RelationLinkResult: type: object properties: changed: type: integer minimum: -9007199254740991 maximum: 9007199254740991 unknown: type: array items: type: string description: Ids that name nothing this account owns; nothing failed. required: - changed - unknown additionalProperties: false MedicationUnlinkResponse: type: object properties: data: $ref: "#/components/schemas/RelationLinkResult" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false LabResultLinkResponse: type: object properties: data: $ref: "#/components/schemas/RelationLinkResult" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false LabResultUnlinkResponse: type: object properties: data: $ref: "#/components/schemas/RelationLinkResult" error: type: "null" meta: type: object properties: requestId: type: string additionalProperties: false required: - data - error additionalProperties: false __schema0: anyOf: - type: string - type: number - type: boolean - type: "null" - type: array items: $ref: "#/components/schemas/__schema0" - type: object propertyNames: type: string additionalProperties: $ref: "#/components/schemas/__schema0" __schema1: anyOf: - type: string - type: number - type: boolean - type: "null" - type: array items: $ref: "#/components/schemas/__schema1" - type: object propertyNames: type: string additionalProperties: $ref: "#/components/schemas/__schema1" __schema2: anyOf: - type: string - type: number - type: boolean - type: "null" - type: array items: $ref: "#/components/schemas/__schema2" - type: object propertyNames: type: string additionalProperties: $ref: "#/components/schemas/__schema2" MoodContextOutput: type: object properties: workStatus: description: Shape of the working day. anyOf: - type: string enum: - off - partial - regular - overtime - type: "null" workMinutes: anyOf: - type: integer minimum: 0 maximum: 1440 - type: "null" overtimeMinutes: anyOf: - type: integer minimum: 0 maximum: 1440 - type: "null" workLoad: description: "0-10, INVERSE-oriented: a higher value is a heavier day, not a better one." anyOf: - type: integer minimum: 0 maximum: 10 - type: "null" workSatisfaction: anyOf: - type: integer minimum: 0 maximum: 10 - type: "null" contactCircles: description: "Who the day's contact was with. Multi-select. Never scored: the count of people is not a good or a bad number." anyOf: - type: array items: type: string enum: - partner - child - family - friends - colleagues - acquaintances - professional - other - type: "null" contactForm: anyOf: - type: string enum: - inPerson - phone - video - written - type: "null" contactExtent: anyOf: - type: string enum: - brief - medium - extended - type: "null" contactQuality: anyOf: - type: integer minimum: 0 maximum: 10 - type: "null" contactSupport: anyOf: - type: integer minimum: 0 maximum: 10 - type: "null" leisureCategories: anyOf: - type: array items: type: string enum: - film - reading - gaming - music - creative - outing - garden - cooking - timeWithChild - relaxation - other - type: "null" leisureMinutes: anyOf: - type: integer minimum: 0 maximum: 1440 - type: "null" leisureJoy: anyOf: - type: integer minimum: 0 maximum: 10 - type: "null" leisureRecovery: anyOf: - type: integer minimum: 0 maximum: 10 - type: "null" eventType: description: One notable event. One per entry, not a list. anyOf: - type: string enum: - goodNews - badNews - success - disappointment - conflict - appointment - familyEvent - change - unexpected - type: "null" eventValence: description: -5 (clearly bad) to +5 (clearly good). anyOf: - type: integer minimum: -5 maximum: 5 - type: "null" eventAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" note: description: One free-text note for the whole context. AES-256-GCM at rest; the ciphertext never rides the wire. anyOf: - type: string maxLength: 500 - type: "null" additionalProperties: false description: "The day around a mood entry: work, contacts, leisure and one notable event. Every field optional. Sleep, activity, vitals and body symptoms are deliberately ABSENT — they are owned by their own modules and are read from there, never captured a second time here." MedicationScheduleOutput: type: object properties: id: type: string medicationId: type: string windowStart: type: string description: Legacy single-time-of-intake (HH:mm, user local). Preserved for backwards compatibility; the new `timesOfDay` array supersedes it. windowEnd: type: string description: Legacy reminder-window upper bound (HH:mm). Used to derive the late-classification grace span when `reminderGraceMinutes` is null. label: type: - string - "null" dose: description: Per-schedule dose override. NULL means the schedule inherits `Medication.dose`. type: - string - "null" unitsPerDose: description: v1.37.10 (#219) — per-slot inventory-units override (may be a split-pill fraction). NULL means the schedule inherits the medication-level `unitsPerDose`. Kept raw so an edit surface can distinguish an explicit value from inheritance; consumers wanting the effective figure read `resolvedUnitsPerDose`. type: - number - "null" resolvedUnitsPerDose: type: number description: v1.37.19 — the EFFECTIVE units one dose of this slot consumes, resolved server-side (`schedule.unitsPerDose ?? medication.unitsPerDose`, matching the intake consumption resolver). Always present; clients never re-derive the inheritance rule. daysOfWeek: description: Legacy persisted recurrence encoding (`null` | `1,3,5` | `i2;1,3,5`). v1.5 readers consult `rrule` first; the field is kept for pre-v1.5 rows. v1.6.0 drops the column. type: - string - "null" timesOfDay: type: array items: type: string description: v1.5 first-class points-in-time the dose is taken (HH:mm, user local). Backfilled to `[windowStart]` for every pre-v1.5 row. reminderGraceMinutes: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" description: Reminder grace window in minutes. NULL falls back to the legacy `windowEnd - windowStart` span. rrule: description: RFC 5545 RRULE string (subset). Used for calendar-anchored cadences. **Mutually exclusive with `rollingIntervalDays`** — exactly one of the two is non-null on any v1.5+ schedule (or both are null on legacy rows that haven't been touched since the migration). type: - string - "null" rollingIntervalDays: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" description: Flexible-rolling interval in days, counted forward from the latest `MedicationIntakeEvent.takenAt`. **Mutually exclusive with `rrule`.** scheduleType: type: string enum: - SCHEDULED - PRN - CYCLIC description: v1.7.0 schedule-type discriminator. SCHEDULED = rrule / rolling / legacy cadence. PRN = as-needed (never projected, reminded, or counted in compliance expected; still loggable via the intake route). CYCLIC = N weeks on / M weeks off, gating whichever inner cadence the rrule / legacy fields describe. cyclicOnWeeks: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" description: v1.7.0 cyclic "on" weeks. Only meaningful when `scheduleType` is CYCLIC; null otherwise. cyclicOffWeeks: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" description: v1.7.0 cyclic "off" weeks. Only meaningful when `scheduleType` is CYCLIC; null otherwise. doseWindows: description: Per-dose on-time windows as stored. Null when every dose time keeps the default derivation. anyOf: - type: array items: $ref: "#/components/schemas/DoseWindowEntryOutput" - type: "null" required: - id - medicationId - windowStart - windowEnd - label - dose - unitsPerDose - resolvedUnitsPerDose - daysOfWeek - timesOfDay - reminderGraceMinutes - rrule - rollingIntervalDays - scheduleType - cyclicOnWeeks - cyclicOffWeeks additionalProperties: false description: Schedule entry attached to a medication. v1.5 promotes `timesOfDay` to first-class and introduces `rrule` (calendar-anchored cadences) and `rollingIntervalDays` (flexible-rolling cadences). The two recurrence primitives are mutually exclusive — enforced by the Zod refine on writes, the route layer, and a DB CHECK constraint (`medication_schedules_rrule_xor_rolling`). v1.7.0 adds `scheduleType` (SCHEDULED / PRN / CYCLIC) and the cyclic on/off-week fields. DoseWindowEntryOutput: type: object properties: timeOfDay: type: string pattern: ^([01]\d|2[0-3]):([0-5]\d)$ description: The dose time this window applies to (matches a `timesOfDay` entry). start: type: string pattern: ^([01]\d|2[0-3]):([0-5]\d)$ description: On-time band lower bound (HH:mm, user local). end: type: string pattern: ^([01]\d|2[0-3]):([0-5]\d)$ description: On-time band upper bound (HH:mm, user local). Must be >= `start`. required: - timeOfDay - start - end additionalProperties: false description: One explicit per-dose on-time intake window. `timeOfDay` matches a schedule dose time; `[start, end]` (HH:mm, user local, `start <= end`) is the on-time band. Outside it the cadence-derived late tail applies, then ad-hoc. DisableCoachFlagOutput: type: object properties: disableCoach: type: boolean required: - disableCoach additionalProperties: false description: Per-account Coach opt-out toggle (v1.4.47 W3). `true` hides the Coach FAB and short-circuits its API gates. DiabetesFlagOutput: type: object properties: hasDiabetes: type: boolean required: - hasDiabetes additionalProperties: false description: Per-account diabetes opt-in (v1.18.6). `true` selects the ADA glycemic GOAL bands (fasting 80–130, postprandial < 180) for glucose targets instead of the general non-diabetic bands. A user-declared preference only — never inferred from a value, never a diagnosis. TourProgressOutput: type: object properties: lastStopId: anyOf: - type: string minLength: 1 maxLength: 64 - type: "null" completedStopIds: default: [] maxItems: 64 type: array items: type: string minLength: 1 maxLength: 64 status: type: string enum: - in_progress - completed - skipped updatedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z))$ required: - lastStopId - completedStopIds - status - updatedAt additionalProperties: false description: Resumable module-tour progress point. `lastStopId` seeds the resume index; `status` is the running/terminal state. Medication: type: object properties: id: type: string userId: description: The record the row belongs to. type: string name: type: string dose: type: string treatmentClass: $ref: "#/components/schemas/MedicationTreatmentClass" deliveryForm: description: How the medication is taken. A one-time injection is `oneShot` with `INJECTION`. type: string enum: - ORAL - INJECTION - OTHER trackInjectionSites: description: Whether intakes record an injection site. Only offered for `INJECTION`. type: boolean allowedInjectionSites: description: The sites the rotation offers; empty means every site. type: array items: type: string enum: - ABDOMEN_LEFT - ABDOMEN_RIGHT - ABDOMEN_UPPER_LEFT - ABDOMEN_UPPER_RIGHT - THIGH_LEFT - THIGH_RIGHT - UPPER_ARM_LEFT - UPPER_ARM_RIGHT externalSource: description: v1.32.25 — provenance of the medication. `APPLE_HEALTH` marks the row a read-only mirror of the iOS 26+ HealthKit Medications sync; NULL is a native HealthLog medication. anyOf: - type: string const: APPLE_HEALTH - type: "null" externalId: description: The source's own identifier for a mirrored medication, paired with `externalSource`; null for a native one. type: - string - "null" lowStockNotifiedAt: description: When the low-stock alert last fired for this medication; null when it has not since the stock was last topped up. Server-written. anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" lowStockNotifiedThresholdDays: description: The runway threshold, in days, that alert fired at. Server-written. anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" dosesPerUnit: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" description: Doses per pen / vial for inventory tracking. NULL = inventory tracking off. unitsPerDose: type: number description: v1.16.10 — inventory units one dose consumes (e.g. 2 tablets of 2 mg for a 4 mg dose). v1.16.12 — may be a split-pill fraction (¼ / ⅓ / ½ / ⅔ / ¾); thirds carry as ≈0.3333 / 0.6667. v1.39.1 (#1034) — any value above 0 and at most 100 with at most 4 decimal places, so also a whole number plus a fraction (1.5, 2.25, 1.3333) or a measured amount (0.8). Default 1. The intake consumption hook decrements this many units per taken dose; dose-derived readouts divide unit counts by it. reorderLeadDays: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" description: v1.17.0 — optional per-medication reorder lead time in days (0–60). The low-stock alert widens its trigger by this lead plus one dose-interval so a refill arrives before the last dose. null = inherit the user-level notificationPrefs.medication.reorderLeadDays default (10). active: type: boolean notificationsEnabled: type: boolean liveActivityEnabled: type: boolean description: v1.7.0 iOS Live Activity opt-in for this medication's reminders. Default false. The iOS client owns the ActivityKit lifecycle; the server only stores + echoes the flag. criticalAlarmEnabled: type: boolean description: v1.7.0 iOS 26 AlarmKit critical-reminder opt-in. Default false. Critical alarms bypass the device mute switch / Focus; the server stores the preference only. atcCode: description: v1.9.0 optional WHO ATC classification code (active-substance class, e.g. `A10BX10`). User/clinician-asserted; never machine-guessed. Emitted on the FHIR `medicationCodeableConcept` under `http://www.whocc.no/atc`. NULL = no code captured. type: - string - "null" rxNormCode: description: v1.9.0 optional RxNorm RxCUI (numeric, US identifier, e.g. `2601723`). Secondary FHIR coding under `http://www.nlm.nih.gov/research/umls/rxnorm`, alongside any ATC code. NULL = no code captured. type: - string - "null" pausedAt: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" snoozedUntil: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" remindersActive: description: "Present on the READ paths only (list and single GET). v1.42.2 — whether the server sends dose reminders for this medication: true only when `notificationsEnabled` is on, the medication is `active`, its course has not ended (`endsOn` is today or later on the person's clock, or unset), its intake is tracked (`trackIntake`), it is not `asNeeded`, and `autoMarkTaken` is off. The same decision gates every server-side reminder send. A client that arms local reminders arms them from this, not from `notificationsEnabled`. Read-only — computed, not stored." type: boolean nextDueAt: description: "Present on the READ paths only — the list and single GET compute it; the create / update responses return the stored row and omit it. v1.7.0 server-computed next due instant across all the medication's schedules (earliest `nextOccurrenceAfter`). Read-only — computed, not stored. NULL when no schedule has an upcoming slot (paused, one-shot in the past, `endsOn` crossed, every schedule PRN). v1.16.4 — when an unresolved slot's anchor has passed but the catch-up band is still open (`anchor < now <= overdueEnd`, current schedule era), this carries THAT slot (a past instant) with `nextDueOverdue: true` instead of jumping to the next future slot. The list GET is cached 60 s, so a 60 s staleness is accepted." anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" nextDueOverdue: description: "Present on the READ paths only, alongside `nextDueAt`. v1.16.4 — true when `nextDueAt` is an OPEN overdue slot: its anchor has passed, `now` is still inside the slot's catch-up band, and no taken / skipped / auto-missed row resolves it. False for a regular future next-due (and when `nextDueAt` is null). Read-only — computed, not stored." type: boolean startsOn: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" description: v1.5 course start (ISO date). Anchors RRULE BYDAY / BYMONTHDAY patterns and the rolling-interval countdown's first window. NULL means active from creation. endsOn: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" description: v1.5 course end (ISO date). NULL means chronic. Equals `startsOn` when `oneShot` is true. endedAt: description: "#1280 — the instant the latest course was ended with `POST /api/medications/{id}/courses/end` (the course's own `endedAt`). From it on no dose is due or reminded and `courseStatus` is ENDED; NULL when the course runs to the end of `endsOn`. Read-only — written by the course writer only." anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" oneShot: type: boolean description: v1.5 single-administration flag. When true the medication has at most one schedule (no `rrule` / `rollingIntervalDays`), and `active` auto-flips to false once the dose is logged. asNeeded: type: boolean description: "v1.16.11 as-needed (PRN) flag. When true the medication carries ZERO schedules (the write routes 422 on any schedule entry alongside the flag): it is never due (`nextDueAt` stays null), never reminded, and excluded from every compliance rate/streak — but intakes still log as ad-hoc rows, inventory still consumes per `unitsPerDose`, and the history renders. Stays active indefinitely. Mutually exclusive with `oneShot`." trackIntake: type: boolean description: "v1.39.1 — intake tracking. True (the default) for every medication that existed before the field. When false the medication is kept as a record: nothing is ever due (`nextDueAt` stays null), nothing reminds on any channel, and it is excluded from every adherence figure. Its stored schedule then travels in `recordedSchedules` while `schedules` is served empty, so a client that arms reminders from `schedules` stays silent without an update." autoMarkTaken: type: boolean description: "v1.42.2 — doses are recorded automatically. False (the default) for every medication that existed before the field. When true the server records each scheduled dose as taken (an intake with `autoRecorded: true`, `takenAt` = the slot time) shortly after its time passes, from `autoMarkTakenSince` on. A slot the person took, skipped or removed is never touched, and they can still change or skip any dose. No medication reminder is sent for it on any channel while it is on; a client that arms local reminders should not arm them for it either. `notificationsEnabled` keeps the person's own setting and applies again once this is switched off." autoMarkTakenSince: anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" description: v1.42.2 — when `autoMarkTaken` was last switched on. No slot anchored before it is ever recorded automatically. NULL while the option is off. Read-only — set by the server on the switch. createdAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ updatedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ schedules: type: array items: $ref: "#/components/schemas/MedicationScheduleOutput" description: "The schedules in force. Empty for an as-needed medication and (v1.39.1) for a medication with `trackIntake: false`, whose stored schedule is in `recordedSchedules`." recordedSchedules: description: "v1.39.1 — present only when `trackIntake` is false: the stored schedule, kept as information. Nothing derives a due dose or a reminder from it. Absent on a tracked medication, whose schedule is `schedules`." type: array items: $ref: "#/components/schemas/MedicationScheduleOutput" required: - id - name - dose - treatmentClass - dosesPerUnit - unitsPerDose - reorderLeadDays - active - notificationsEnabled - liveActivityEnabled - criticalAlarmEnabled - atcCode - rxNormCode - pausedAt - snoozedUntil - startsOn - endsOn - oneShot - asNeeded - trackIntake - autoMarkTaken - autoMarkTakenSince - createdAt - updatedAt - schedules additionalProperties: false description: Server-shaped medication row returned by GET / POST / PUT endpoints. Carries the v1.5 course-window fields (`startsOn`, `endsOn`, `oneShot`) at the medication level and the per-schedule cadence fields on the nested `schedules` array. MedicationDoseHistoryImportFatalReason: type: string enum: - empty_file - missing_required_columns - unreadable_json - json_not_an_array - json_carries_no_intake_time description: Whole-file refusal reason on the 422 `errorCode`. empty_file — no data rows. missing_required_columns — the CSV header is missing a column the parser needs. unreadable_json — the body did not parse as JSON. json_not_an_array — parsed JSON is not an array (and no nested array was found under any key). json_carries_no_intake_time — well-formed JSON export input, refused by design (see above). CoachPrefs: type: object properties: tone: default: warm type: string enum: - warm - neutral - concise verbosity: default: default type: string enum: - brief - default - detailed excludeMetrics: default: [] maxItems: 11 type: array items: type: string enum: - bp - weight - pulse - mood - compliance - hrv - sleep - resting_hr - steps - medications - anthropometrics showEvidenceByDefault: default: false type: boolean defaultWindow: default: allTime type: string enum: - last7days - last30days - last90days - lastYear - allTime description: "How far back the Coach may read, in every answer: snapshot, retrieval tools and summaries alike. `allTime` (the default) is no limit. Formerly only the starting window; `lastYear` came with the limit." dataClusters: maxItems: 10 type: array items: type: string enum: - cardio - body - activity - workouts - sleep - mood - glucose - medication - mobility - environment reminderSuggestions: type: object properties: enabled: default: true type: boolean stopped: default: false type: boolean dismissedCadences: default: [] maxItems: 32 type: array items: type: string maxLength: 64 lastSuggestedAt: default: null anyOf: - type: string maxLength: 40 - type: "null" required: - enabled - stopped - dismissedCadences - lastSuggestedAt additionalProperties: false followUpChips: description: v1.39.4 — offer up to three follow-up chips under the Coach's latest answer. Absent means on; only `false` switches them off. type: boolean reasoning: description: "v1.41 — how hard the Coach thinks before it answers: `off`, `low`, `medium` or `high`. Absent means `medium`. The operator may switch reasoning off or cap the level; the resolved value is published on `GET /api/auth/me` as `coachReasoning`." type: string enum: - off - low - medium - high required: - tone - verbosity - excludeMetrics - showEvidenceByDefault - defaultWindow additionalProperties: false description: Per-user Coach prompt-tuning preferences (v1.4.23 H4). All fields default to the legacy v1.4.22 behaviour when omitted. MoodTagLayout: type: object properties: groupOrder: maxItems: 50 type: array items: type: string minLength: 1 maxLength: 80 placements: type: object propertyNames: type: string minLength: 1 maxLength: 80 additionalProperties: type: array items: type: string minLength: 1 maxLength: 80 additionalProperties: false description: "Per-user mood-tag presentation blob. `groupOrder`: category keys in display order (unknown dropped, missing appended in seeded order). `placements`: categoryKey → ordered tag keys; a placed tag renders in that group at that index, un-placed tags follow in their home category. Display-only — placements referencing hidden/archived/unknown keys are silently dropped at read time. Both fields optional: PUT merges preserve-when-absent." WellnessScoreValue: type: object properties: score: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: The latest persisted 0–100 score. band: type: string enum: - green - yellow - red description: "Server's verdict on the score. Direction-aware: recovery bands high-is-good, stress and strain invert, so a client must never re-band the number itself." trendDelta: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" description: Score minus the mean of the earlier days in the window; null when the window holds only one day. daysInWindow: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Days that actually carried a score inside the window. Compare against `provenance.windowDays` — a shorter record answers a wide request with fewer days, and this is where that shows. asOf: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ description: "`measuredAt` of the latest score." series: type: array items: type: number description: "Trailing scores, oldest → newest, capped at 30 points however wide the window is. For RECOVERY these are the canonical per-night values: a worn band and the server's own proxy write the same night on different clocks, and the server collapses them to one value per night before this array is built." anchor: description: STRAIN only — whether this score was judged against the user's own training history or the cold-start population reference. Null for recovery and stress. anyOf: - type: string enum: - personal - population - type: "null" components: description: RECOVERY only, and only when the canonical value is the server's computed proxy — a device-native recovery percentage is not our blend and carries no decomposition. anyOf: - type: array items: type: object properties: key: type: string description: Contributor id, e.g. rhr / hrv / sleep. value: description: 0–100 sub-score, null when the input was missing. type: - number - "null" weight: type: number description: Effective weight after redistributing missing inputs. required: - key - value - weight additionalProperties: false - type: "null" device: description: STRAIN only — present when no computed strain score exists in the window and the score is served from the device's own DAY_STRAIN instead. `score` is then `value` as a 0–100 share of `scaleMax`. Absent when the computed proxy is the source. anyOf: - type: object properties: value: type: number description: The device's latest day strain on its own scale. scaleMax: type: number description: Top of the device scale (21 for WHOOP day strain). required: - value - scaleMax additionalProperties: false - type: "null" required: - score - band - trendDelta - daysInWindow - asOf - series additionalProperties: false SleepScoreValue: type: object properties: score: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: The composite 0–100 sleep score. band: type: string enum: - green - yellow - red description: Server's verdict on the score; never re-band it client-side. night: type: string description: Wake day (YYYY-MM-DD, user's zone) of the night scored. asleepMinutes: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Whole minutes asleep that night. inBedMinutes: anyOf: - type: integer minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" description: Whole minutes in bed, null when no in-bed signal exists. subScores: type: array items: type: object properties: key: type: string enum: - sufficiency - efficiency - consistency - timing - composition description: Sub-score id. value: description: 0–100 sub-score; null when the data cannot support it (dropped from the blend, never 0). type: - number - "null" weight: type: number description: Effective weight after redistributing missing sub-scores. gap: description: "Only on a null `consistency` / `timing`: why it is withheld. `few_nights` — fewer than `rhythmMinNights` nights in the window have a main sleep on the clock (count in `rhythmNights`); `no_main_sleep` — the latest day had only a daytime nap. Absent otherwise." type: string enum: - few_nights - no_main_sleep required: - key - value - weight additionalProperties: false description: All five sub-scores, present and dropped. windowNights: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Scorable nights in the window. rhythmNights: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: Nights in the window with a main sleep placed on the clock — what consistency and timing are computed from. A daytime nap is not one; a short night is. rhythmMinNights: type: integer minimum: -9007199254740991 maximum: 9007199254740991 description: 'How many `rhythmNights` consistency and timing need before they are scored; below it they carry `gap: "few_nights"`.' series: type: array items: type: number description: Score of every scorable night in the window, oldest → newest, capped at 30 points; the last point is `score`. required: - score - band - night - asleepMinutes - inBedMinutes - subScores - windowNights - rhythmNights - rhythmMinNights - series additionalProperties: false parameters: AccountSelector: name: X-HealthLog-Account in: header required: false schema: type: string maxLength: 64 description: "Act on another account's record for THIS request. Bearer transport only — the browser session carries its switch on the session row, and sending this header alongside a cookie is refused rather than ignored. The value is an `accountId` from `accountAccess.accounts` on GET /api/auth/me. It is a SELECTOR and not a permission: the grant is re-checked on every request, so a revocation lands on the next one rather than at the next login, and a selector naming an account that granted nothing is refused with 403 `sharing.access.denied` — byte-identical to one naming an account that does not exist, so the refusal is not an enumeration oracle. NEVER attach it to the sync engine, device or notification registration, or any auth/refresh call: those are about the person holding the token, not the record being read, and the account payload refuses the header outright for exactly that reason." RecordEpoch: name: X-HealthLog-Record-Epoch in: header required: false schema: type: string maxLength: 15 description: "The record-session epoch this client believes it is on, taken from `recordSession.epoch` on GET /api/auth/me or POST /api/account/switch. Cookie transport only; a Bearer client neither sends nor receives it. Send the literal `bootstrap` before the account payload has resolved. A browser session that has never been pointed at another record is served without either header, which is what keeps a tab open across the deploy working. Once a session HAS entered a shared record it stays fenced permanently, in both directions: a mismatching epoch is refused with 409 `sharing.session.changed` (reconcile through GET /api/auth/me and retry), and an absent header is refused with 403 `sharing.access.denied` (a client predating the fence, which recovers by leaving the record and reloading)." RecordScope: name: X-HealthLog-Record-Scope in: header required: false schema: type: string maxLength: 64 description: The record this client believes it is inside, taken from `recordSession.scope`, with the literal `self` standing for one's own record. Sent alongside `X-HealthLog-Record-Epoch` on every same-origin request; one without the other is treated as a stale assertion, not as an absent one. Cookie transport only. Both headers are echoed back on any response that actually resolved a record scope, and a client discards a response whose echo contradicts the context it is now on — a response carrying no echo resolved no record scope and is served normally. securitySchemes: bearerAuth: type: http scheme: bearer bearerFormat: hlk_<64hex> description: Native-client API token. Use the `token` field returned by /api/auth/login on a native client. cookieAuth: type: apiKey in: cookie name: healthlog_session description: Browser session cookie — set by /api/auth/login. Named `__Host-healthlog_session` wherever the cookie is `Secure` (every HTTPS install); a plain-HTTP install, which cannot hold a `__Host-` cookie, keeps `healthlog_session`.