diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 9a7507378..3fb2e9127 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -46,28 +46,25 @@ tags: - name: Records - name: Admin - name: Meta +security: + - bearerAuth: [] + - cookieAuth: [] paths: - /api/cycle/day-logs: + /api/cycle/symptoms/custom: 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 + 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 day-log for that date, or null. + description: The active custom symptoms (possibly empty). content: application/json: schema: - $ref: "#/components/schemas/CycleDayLogReadEnvelope" + $ref: "#/components/schemas/CycleCustomSymptomListEnvelope" "401": &a1 description: Authentication required or invalid credentials. content: @@ -93,18 +90,201 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": &a3 + "422": &a4 description: Request validation failed. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": &a4 + "429": &a5 description: Rate limit exceeded. 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." + 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 + "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: "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 @@ -112,7 +292,7 @@ paths: 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: - - &a5 + - &a6 name: Idempotency-Key in: header required: false @@ -264,7 +444,7 @@ paths: $ref: "#/components/schemas/CycleDayLogCreatedEnvelope" "401": *a1 "403": *a2 - "409": &a6 + "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: @@ -283,8 +463,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/cycle/day-logs/{id}: patch: tags: @@ -440,8 +620,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 delete: tags: - Cycle @@ -465,8 +645,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/cycle/day-logs/bulk: post: tags: @@ -475,7 +655,7 @@ paths: description: "Up to 500 entries; `withIdempotency`; rate-limited `cycle:day-logs:bulk:` 60/min. Per-entry status: inserted | duplicate | updated | skipped. Always 200." parameters: - - *a5 + - *a6 requestBody: required: true content: @@ -615,16 +795,10 @@ paths: schema: $ref: "#/components/schemas/CycleBulkEnvelope" "401": *a1 - "403": &a7 - description: "Cycle tracking is not enabled for this account (errorCode `cycle.disabled`). Resolved against the RECORD - being read, not the caller: a delegate inside somebody else's record sees the owner's setting." - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorEnvelope" - "409": *a6 - "422": *a3 - "429": *a4 + "403": *a3 + "409": *a7 + "422": *a4 + "429": *a5 /api/cycle/period: post: tags: @@ -633,7 +807,7 @@ paths: description: One-tap started/ended period. `start` opens a new cycle (closing the prior); `end` stamps periodEndDate. Writes the boundary day-log. Gated. parameters: - - *a5 + - *a6 requestBody: required: true content: @@ -670,9 +844,9 @@ paths: $ref: "#/components/schemas/CyclePeriodEnvelope" "401": *a1 "403": *a2 - "409": *a6 - "422": *a3 - "429": *a4 + "409": *a7 + "422": *a4 + "429": *a5 /api/cycle/calendar: get: tags: @@ -698,8 +872,8 @@ paths: $ref: "#/components/schemas/CycleCalendarEnvelope" "401": *a1 "403": *a2 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/cycle/cycles: get: tags: @@ -723,8 +897,8 @@ paths: $ref: "#/components/schemas/CycleHistoryEnvelope" "401": *a1 "403": *a2 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/cycle/insights: get: tags: @@ -744,8 +918,8 @@ paths: $ref: "#/components/schemas/CycleInsightsEnvelope" "401": *a1 "403": *a2 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/cycle/cycles/{id}: delete: tags: @@ -769,8 +943,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/cycle/all: delete: tags: @@ -787,9 +961,9 @@ paths: schema: $ref: "#/components/schemas/CyclePurgeEnvelope" "401": *a1 - "403": *a7 - "422": *a3 - "429": *a4 + "403": *a3 + "422": *a4 + "429": *a5 /api/cycle/profile: get: tags: @@ -805,8 +979,8 @@ paths: $ref: "#/components/schemas/CycleProfileEnvelope" "401": *a1 "403": *a2 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/auth/me/cycle-prefs: get: tags: @@ -821,8 +995,8 @@ paths: schema: $ref: "#/components/schemas/CyclePrefsGetEnvelope" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 patch: tags: - Cycle @@ -885,8 +1059,8 @@ paths: schema: $ref: "#/components/schemas/CyclePrefsPatchEnvelope" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/measurement-categories: get: tags: @@ -904,8 +1078,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementCategoriesEnvelope" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/meta/capabilities: get: tags: @@ -924,8 +1098,103 @@ paths: schema: $ref: "#/components/schemas/CapabilitiesEnvelope" "401": *a1 - "422": *a3 - "429": *a4 + "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 feature matrix + description: >- + Which assistant surfaces this deployment offers. The master flag is applied server-side before the shape leaves + the handler, so every sub-flag is already false when the master is off and a client never composes the two. + + + 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. + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantFlagsEnvelope" + "401": *a1 + "403": + description: 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. + 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 + "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: @@ -1035,8 +1304,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/share-links: post: tags: @@ -1114,8 +1383,8 @@ paths: schema: $ref: "#/components/schemas/ShareLinkCreated" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 get: tags: - Export @@ -1130,8 +1399,8 @@ paths: schema: $ref: "#/components/schemas/ShareLinkList" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/share-links/{id}: delete: tags: @@ -1159,8 +1428,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/c/{token}/unlock: post: tags: @@ -1203,8 +1472,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /c/{token}/d/{id}: get: tags: @@ -1274,8 +1543,8 @@ paths: type: string format: binary "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/fhir/Patient: get: tags: @@ -1301,8 +1570,8 @@ paths: type: string format: binary "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/fhir/Observation: get: tags: @@ -1328,8 +1597,8 @@ paths: type: string format: binary "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/fhir/MedicationStatement: get: tags: @@ -1355,8 +1624,8 @@ paths: type: string format: binary "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/fhir/MedicationAdministration: get: tags: @@ -1382,8 +1651,8 @@ paths: type: string format: binary "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/fhir/Coverage: get: tags: @@ -1410,8 +1679,8 @@ paths: type: string format: binary "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/fhir/Patient/{id}: get: tags: @@ -1442,8 +1711,8 @@ paths: schema: type: string format: binary - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/fhir/Coverage/{id}: get: tags: @@ -1474,8 +1743,8 @@ paths: schema: type: string format: binary - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/fhir/Observation/{id}: get: tags: @@ -1506,8 +1775,8 @@ paths: schema: type: string format: binary - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/fhir/MedicationStatement/{id}: get: tags: @@ -1538,8 +1807,8 @@ paths: schema: type: string format: binary - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/fhir/MedicationAdministration/{id}: get: tags: @@ -1570,8 +1839,8 @@ paths: schema: type: string format: binary - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/fhir/Patient/$everything: get: tags: @@ -1600,8 +1869,285 @@ paths: type: string format: binary "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 + /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." + 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 + "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." + 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 + "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 + "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 + "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 + "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: @@ -1629,8 +2175,8 @@ paths: type: string format: binary "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/auth/check-user: post: tags: @@ -1654,10 +2200,11 @@ paths: schema: $ref: "#/components/schemas/CheckUserEnvelope" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/auth/login: post: + security: [] tags: - Auth summary: Email-or-username login (password) @@ -1703,8 +2250,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/auth/mfa/verify: post: tags: @@ -1728,8 +2275,8 @@ paths: schema: $ref: "#/components/schemas/MfaVerifyResponse" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/auth/oidc/native/token: post: tags: @@ -1760,8 +2307,8 @@ paths: schema: $ref: "#/components/schemas/OidcNativeTokenResponse" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/auth/native/login: get: tags: @@ -1833,8 +2380,8 @@ paths: schema: $ref: "#/components/schemas/NativeHandoffTokenResponse" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/auth/step-up/options: post: tags: @@ -1865,8 +2412,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/auth/step-up: post: tags: @@ -1915,8 +2462,8 @@ paths: schema: $ref: "#/components/schemas/StepUpMintEnvelope" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/auth/me/mfa/totp/setup: post: tags: @@ -1939,8 +2486,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/auth/me/mfa/totp/confirm: post: tags: @@ -1975,8 +2522,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/auth/me/mfa/disable: post: tags: @@ -1999,8 +2546,8 @@ paths: schema: $ref: "#/components/schemas/MfaDisableEnvelope" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/auth/me/mfa/recovery-codes/regenerate: post: tags: @@ -2017,8 +2564,8 @@ paths: schema: $ref: "#/components/schemas/MfaRecoveryRegenEnvelope" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/auth/me/mfa: get: tags: @@ -2036,8 +2583,8 @@ paths: schema: $ref: "#/components/schemas/MfaStatusEnvelope" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/auth/me/mfa/webauthn/register/options: post: tags: @@ -2054,8 +2601,8 @@ paths: schema: $ref: "#/components/schemas/MfaWebauthnRegisterOptionsEnvelope" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/auth/me/mfa/webauthn/register/verify: post: tags: @@ -2084,8 +2631,8 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/auth/me/mfa/webauthn/{id}: patch: tags: @@ -2120,8 +2667,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 delete: tags: - Auth @@ -2148,8 +2695,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/auth/mfa/webauthn/verify/options: post: tags: @@ -2177,8 +2724,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/auth/mfa/webauthn/verify: post: tags: @@ -2201,10 +2748,11 @@ paths: schema: $ref: "#/components/schemas/MfaWebauthnVerifyResponse" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/auth/passkey/login-verify: post: + security: [] tags: - Auth summary: Passkey assertion verification @@ -2234,8 +2782,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/auth/refresh: post: tags: @@ -2268,8 +2816,8 @@ paths: - $ref: "#/components/schemas/RefreshResponse" - $ref: "#/components/schemas/RefreshRevokeResponse" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/auth/passkeys: get: tags: @@ -2286,8 +2834,8 @@ paths: schema: $ref: "#/components/schemas/PasskeyListEnvelope" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/auth/me/sessions: get: tags: @@ -2304,8 +2852,8 @@ paths: schema: $ref: "#/components/schemas/SessionListEnvelope" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 delete: tags: - Auth @@ -2320,8 +2868,8 @@ paths: schema: $ref: "#/components/schemas/SignOutEverywhereEnvelope" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/auth/me/sessions/{id}: delete: tags: @@ -2349,8 +2897,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/auth/me/trusted-devices: get: tags: @@ -2366,8 +2914,8 @@ paths: schema: $ref: "#/components/schemas/TrustedDeviceListEnvelope" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 delete: tags: - Auth @@ -2381,8 +2929,8 @@ paths: schema: $ref: "#/components/schemas/TrustedDeviceRevokeAllEnvelope" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/auth/me/trusted-devices/{id}: delete: tags: @@ -2409,8 +2957,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/auth/me/security-activity: get: tags: @@ -2436,8 +2984,664 @@ paths: schema: $ref: "#/components/schemas/SecurityActivityEnvelope" "401": *a1 - "422": *a3 - "429": *a4 + "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, 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. + 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" + "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 + "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. + + + No step-up: this endpoint takes a plain cookie session or a wildcard Bearer token, where removing a + second-factor security key (DELETE /api/auth/me/mfa/webauthn/{id}) requires a fresh factor proof. The asymmetry + is in the code as it stands, not a description of intent. + 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": *a1 + "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 mint on this path any more — the generic POST that issued `["medication:ingest"]` was removed, because + that scope reached no ingest route while the pre-fail-closed default let it reach everything else; the working + credential comes from the per-medication API-endpoint toggle. 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 + "422": *a4 + "429": *a5 + /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: The operator has switched the API off instance-wide (`AppSettings.apiGlobal`). No `meta.errorCode`; 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 + /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" + "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 + "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": *a5 + /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. + + + 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." + responses: + "302": + description: Always a redirect. To the provider on success. On failure to `/auth/login?error=` (browser) or + `healthlog://oidc-callback?error=` (native), where reason is one of `oidc_disabled`, + `oidc_rate_limited`, `oidc_invalid_request`, `oidc_failed`. + /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. + + + 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. + 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" + "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": *a4 + "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" + "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 + "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 + "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 + "422": *a4 + "429": *a5 /api/sync/state: get: tags: @@ -2454,8 +3658,8 @@ paths: schema: $ref: "#/components/schemas/SyncStateEnvelope" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/sync/changes: get: tags: @@ -2491,8 +3695,8 @@ paths: schema: $ref: "#/components/schemas/SyncChangesEnvelope" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/measurements/series-batch: get: tags: @@ -2541,8 +3745,8 @@ paths: $ref: "#/components/schemas/GetMeasurementsSeriesBatchResponse" "401": *a1 "403": *a8 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/measurements/series: get: tags: @@ -2593,8 +3797,8 @@ paths: $ref: "#/components/schemas/GetMeasurementsSeriesResponse" "401": *a1 "403": *a8 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/measurements: get: tags: @@ -2790,8 +3994,8 @@ paths: $ref: "#/components/schemas/ListMeasurementsResponse" "401": *a1 "403": *a8 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 post: tags: - Measurements @@ -2800,7 +4004,7 @@ paths: 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. parameters: - - *a5 + - *a6 requestBody: required: true content: @@ -3057,8 +4261,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/measurements/{id}: get: tags: @@ -3086,8 +4290,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 put: tags: - Measurements @@ -3142,8 +4346,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 delete: tags: - Measurements @@ -3170,8 +4374,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/measurements/batch: post: tags: @@ -3182,7 +4386,7 @@ paths: `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. parameters: - - *a5 + - *a6 requestBody: required: true content: @@ -3197,9 +4401,9 @@ paths: schema: $ref: "#/components/schemas/BatchMeasurementsResponse" "401": *a1 - "409": *a6 - "422": *a3 - "429": *a4 + "409": *a7 + "422": *a4 + "429": *a5 /api/measurements/by-external-ids: delete: tags: @@ -3223,8 +4427,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementsDeleteByExternalIdsResponse" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/measurements/bulk-delete: post: tags: @@ -3235,7 +4439,7 @@ paths: `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: - - *a5 + - *a6 requestBody: required: true content: @@ -3251,9 +4455,9 @@ paths: $ref: "#/components/schemas/MeasurementsBulkDeleteResponse" "401": *a1 "403": *a8 - "409": *a6 - "422": *a3 - "429": *a4 + "409": *a7 + "422": *a4 + "429": *a5 /api/measurements/restore: post: tags: @@ -3264,7 +4468,7 @@ paths: `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: - - *a5 + - *a6 requestBody: required: true content: @@ -3280,9 +4484,9 @@ paths: $ref: "#/components/schemas/MeasurementsRestoreResponse" "401": *a1 "403": *a8 - "409": *a6 - "422": *a3 - "429": *a4 + "409": *a7 + "422": *a4 + "429": *a5 /api/sleep/night: get: tags: @@ -3330,8 +4534,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/sleep/rhythm: get: tags: @@ -3352,8 +4556,61 @@ paths: $ref: "#/components/schemas/SleepRhythmResponse" "401": *a1 "403": *a9 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 + /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": *a8 + "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/workouts: get: tags: @@ -3401,8 +4658,8 @@ paths: $ref: "#/components/schemas/ListWorkoutsResponse" "401": *a1 "403": *a9 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/workouts/{id}: get: tags: @@ -3450,8 +4707,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/workouts/batch: post: tags: @@ -3468,7 +4725,7 @@ paths: ceiling is 5 MB enforced at the HTTP layer — clients above the ceiling receive a 413 with `workout.batch.payload_too_large`.' parameters: - - *a5 + - *a6 requestBody: required: true content: @@ -3661,15 +4918,15 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "409": *a6 + "409": *a7 "413": description: Request body exceeds the 5 MB ceiling. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/devices: post: tags: @@ -3697,8 +4954,909 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "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 + "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": *a8 + "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: >- + 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. The latter two carry no issue list — branch on the code. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "429": *a5 + /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": *a8 + "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 + 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": *a8 + "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}/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: 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: 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": *a8 + "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 + "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 + "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": *a8 + "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 + 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": *a8 + "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 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": *a8 + "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": *a8 + "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": *a8 + "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": *a8 + "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": *a8 + "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": *a8 + "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: @@ -3712,7 +5870,7 @@ paths: 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: - - *a5 + - *a6 requestBody: required: true content: @@ -3727,9 +5885,9 @@ paths: schema: $ref: "#/components/schemas/BulkMedicationIntakeResponseEnvelope" "401": *a1 - "409": *a6 - "422": *a3 - "429": *a4 + "409": *a7 + "422": *a4 + "429": *a5 /api/medications: get: tags: @@ -3748,8 +5906,8 @@ paths: $ref: "#/components/schemas/ListMedicationsResponse" "401": *a1 "403": *a8 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 post: tags: - Medications @@ -3777,8 +5935,8 @@ paths: $ref: "#/components/schemas/CreateMedicationResponse" "401": *a1 "403": *a8 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/medications/layout: get: tags: @@ -3796,8 +5954,8 @@ paths: $ref: "#/components/schemas/MedicationListLayoutResponse" "401": *a1 "403": *a8 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 put: tags: - Medications @@ -3837,7 +5995,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": *a4 + "429": *a5 delete: tags: - Medications @@ -3851,8 +6009,8 @@ paths: schema: $ref: "#/components/schemas/MedicationListLayoutReset" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/medications/{id}: get: tags: @@ -3881,8 +6039,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 put: tags: - Medications @@ -3918,8 +6076,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 delete: tags: - Medications @@ -3946,8 +6104,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/medications/{id}/intake: post: tags: @@ -3963,7 +6121,7 @@ paths: schema: type: string required: true - - *a5 + - *a6 requestBody: required: true content: @@ -3991,9 +6149,9 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "409": *a6 - "422": *a3 - "429": *a4 + "409": *a7 + "422": *a4 + "429": *a5 get: tags: - Medications @@ -4065,8 +6223,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/medications/{id}/inventory: get: tags: @@ -4098,8 +6256,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 post: tags: - Medications @@ -4133,8 +6291,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/medications/{id}/inventory/{itemId}: patch: tags: @@ -4176,8 +6334,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 delete: tags: - Medications @@ -4211,8 +6369,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/medications/extract: post: tags: @@ -4237,8 +6395,8 @@ paths: schema: $ref: "#/components/schemas/MedicationExtractResponse" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 "502": description: Upstream provider returned an empty, unparseable, or off-schema reply. content: @@ -4289,8 +6447,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/medications/compliance: get: tags: @@ -4311,8 +6469,8 @@ paths: $ref: "#/components/schemas/ListMedicationComplianceResponse" "401": *a1 "403": *a8 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/medications/{id}/compliance: get: tags: @@ -4343,8 +6501,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/medications/{id}/efficacy: get: tags: @@ -4375,8 +6533,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/medications/{id}/efficacy/target: put: tags: @@ -4500,8 +6658,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/medications/{id}/dose-history: get: tags: @@ -4550,8 +6708,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/medications/{id}/schedule-revisions: get: tags: @@ -4581,8 +6739,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 post: tags: - Medications @@ -4619,8 +6777,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/medications/{id}/schedule-revisions/{revisionId}: patch: tags: @@ -4669,8 +6827,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 delete: tags: - Medications @@ -4710,8 +6868,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/medications/intake/dose-history-import: post: tags: @@ -4786,7 +6944,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": *a4 + "429": *a5 "503": description: Background worker is not available. content: @@ -4829,8 +6987,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/medications/intake/dose-history-import/{jobId}/status: get: tags: @@ -4863,8 +7021,98 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "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": *a8 + "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 + "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: @@ -4899,8 +7212,8 @@ paths: schema: $ref: "#/components/schemas/GetCoachPrefsResponse" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 put: tags: - Auth @@ -4931,7 +7244,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a10 - "429": *a4 + "429": *a5 /api/auth/me/disable-coach: get: tags: @@ -4947,8 +7260,8 @@ paths: schema: $ref: "#/components/schemas/GetDisableCoachResponse" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 patch: tags: - Auth @@ -4969,8 +7282,8 @@ paths: schema: $ref: "#/components/schemas/PatchDisableCoachResponse" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/auth/me/diabetes: get: tags: @@ -4988,8 +7301,8 @@ paths: schema: $ref: "#/components/schemas/GetDiabetesResponse" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 patch: tags: - Auth @@ -5011,8 +7324,8 @@ paths: schema: $ref: "#/components/schemas/PatchDiabetesResponse" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/auth/me/health-score-config: get: tags: @@ -5030,8 +7343,8 @@ paths: schema: $ref: "#/components/schemas/HealthScoreConfigEnvelope" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 patch: tags: - Auth @@ -5077,7 +7390,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": *a4 + "429": *a5 /api/auth/me/modules: get: tags: @@ -5095,8 +7408,8 @@ paths: schema: $ref: "#/components/schemas/GetModulesResponse" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 patch: tags: - Auth @@ -5129,7 +7442,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a10 - "429": *a4 + "429": *a5 /api/auth/me/source-priority: get: tags: @@ -5145,8 +7458,8 @@ paths: schema: $ref: "#/components/schemas/GetSourcePriorityResponse" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 put: tags: - Auth @@ -5960,8 +8273,8 @@ paths: schema: $ref: "#/components/schemas/PutSourcePriorityResponse" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/user/avatar: post: tags: @@ -6004,8 +8317,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 delete: tags: - Auth @@ -6016,8 +8329,8 @@ paths: "204": description: Avatar cleared (or already empty). "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/user/avatar/{id}: get: tags: @@ -6063,8 +8376,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/user/profile: get: tags: @@ -6080,8 +8393,8 @@ paths: schema: $ref: "#/components/schemas/GetProfileResponse" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 patch: tags: - Auth @@ -6103,8 +8416,8 @@ paths: schema: $ref: "#/components/schemas/PatchProfileResponse" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/auth/me/notification-prefs: get: tags: @@ -6121,8 +8434,8 @@ paths: schema: $ref: "#/components/schemas/GetNotificationPrefsResponse" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 patch: tags: - Notifications @@ -6155,7 +8468,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a10 - "429": *a4 + "429": *a5 /api/auth/me/report-selection: get: tags: @@ -6172,8 +8485,8 @@ paths: schema: $ref: "#/components/schemas/GetReportSelectionResponseEnvelope" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 put: tags: - Auth @@ -6196,8 +8509,547 @@ paths: schema: $ref: "#/components/schemas/PutReportSelectionResponseEnvelope" "401": *a1 - "422": *a3 - "429": *a4 + "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 + "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" + "401": *a1 + "413": + description: Request body exceeds 1024 bytes. Nothing was written. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "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 + "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_full` `ConsentReceipt` — the same receipt POST /api/consent/ai/web mints, and one DELETE + /api/consent/ai/latest revokes 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. 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" + "401": *a1 + "413": + description: Request body exceeds 1024 bytes. Nothing was written. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "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 + "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 + "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 + "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 + "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. + There is no confirmation step and no dry run, so a client that means to reset one metric must send the + parameter. + + + Audit-logged with the before and after maps. Unlike the PUT this path runs no rate limit of its own. + parameters: + - in: query + name: metric + schema: + description: The single metric to reset. Omit to reset every override on the account. + $ref: "#/components/schemas/ThresholdMetric" + description: The single metric to reset. Omit to reset every override on the account. + 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 + "422": *a4 + "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 + "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 + "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 + "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 + "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" + "401": *a1 + "413": + description: Request body exceeds 1024 bytes. Nothing was written. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "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 + "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" + "401": *a1 + "413": + description: Request body exceeds 1024 bytes. Nothing was written. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "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: 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 medication-compliance rollup keys its rows by the LOCAL day a + dose fell on, minted under whatever zone was in force at write time, so moving the zone re-buckets that history + and the stored keys go stale — and the coverage probe compares counts rather than keys, so it cannot notice. The + route therefore enqueues a re-fold of the trailing window 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 + "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 + "422": *a4 + "429": *a5 /api/insights/chat: get: tags: @@ -6242,8 +9094,8 @@ paths: schema: $ref: "#/components/schemas/CoachConversationsPageResponse" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 post: tags: - Insights @@ -6369,8 +9221,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/insights/chat/{id}: get: tags: @@ -6401,8 +9253,53 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "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. Coach-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 + "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 @@ -6430,8 +9327,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/insights/chat/fenced: post: tags: @@ -6471,8 +9368,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/insights/chat/{id}/attachments: post: tags: @@ -6510,8 +9407,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/insights/chat/{id}/attachments/{documentId}: delete: tags: @@ -6548,8 +9445,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/insights/layout: get: tags: @@ -6566,8 +9463,8 @@ paths: schema: $ref: "#/components/schemas/InsightsLayout" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 put: tags: - Insights @@ -6599,7 +9496,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a10 - "429": *a4 + "429": *a5 delete: tags: - Insights @@ -6613,8 +9510,8 @@ paths: schema: $ref: "#/components/schemas/InsightsLayoutReset" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/insights/coach/facts: get: tags: @@ -6632,8 +9529,8 @@ paths: schema: $ref: "#/components/schemas/CoachFactsList" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 delete: tags: - Insights @@ -6648,8 +9545,8 @@ paths: schema: $ref: "#/components/schemas/CoachFactsCleared" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/insights/coach/facts/{id}: delete: tags: @@ -6666,8 +9563,8 @@ paths: schema: $ref: "#/components/schemas/CoachFactDeleted" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/insights/coach/nudge-status: get: tags: @@ -6688,8 +9585,8 @@ paths: $ref: "#/components/schemas/CoachNudgeStatus" "401": *a1 "403": *a8 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/insights/coach/seen: post: tags: @@ -6708,8 +9605,8 @@ paths: schema: $ref: "#/components/schemas/CoachSeenResponse" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/coach/about-me: get: tags: @@ -6727,8 +9624,8 @@ paths: schema: $ref: "#/components/schemas/GetCoachAboutMeResponse" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 put: tags: - Insights @@ -6803,7 +9700,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a10 - "429": *a4 + "429": *a5 /api/anamnesis/facts: get: tags: @@ -6821,8 +9718,8 @@ paths: $ref: "#/components/schemas/GetAnamnesisFactsResponse" "401": *a1 "403": *a8 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 post: tags: - Insights @@ -6830,7 +9727,7 @@ paths: description: Creates the first current revision for one closed fact kind. The answer is encrypted before persistence. A second current value for the same kind returns 409; corrections use the revision endpoint. parameters: - - *a5 + - *a6 requestBody: required: true content: @@ -6895,8 +9792,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/anamnesis/facts/{id}: patch: tags: @@ -6944,8 +9841,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 delete: tags: - Insights @@ -6975,8 +9872,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/anamnesis/emergency: get: tags: @@ -6993,8 +9890,8 @@ paths: schema: $ref: "#/components/schemas/GetEmergencyProfileResponse" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 patch: tags: - Insights @@ -7062,8 +9959,8 @@ paths: schema: $ref: "#/components/schemas/UpdateEmergencyProfileResponse" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/coach/about-me/questions: get: tags: @@ -7080,8 +9977,8 @@ paths: schema: $ref: "#/components/schemas/GetCoachAboutMeQuestionsResponse" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 delete: tags: - Insights @@ -7106,8 +10003,83 @@ paths: schema: $ref: "#/components/schemas/DeleteCoachAboutMeQuestionsResponse" "401": *a1 - "422": *a3 - "429": *a4 + "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: '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: @@ -7161,8 +10133,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/coach/plans/{id}: patch: tags: @@ -7223,8 +10195,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 delete: tags: - Insights @@ -7253,8 +10225,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/coach/reminder-suggestions: post: tags: @@ -7306,8 +10278,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/coach/reminders: get: tags: @@ -7352,8 +10324,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 post: tags: - Insights @@ -7388,8 +10360,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/coach/reminders/{id}: patch: tags: @@ -7432,8 +10404,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 delete: tags: - Insights @@ -7462,8 +10434,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/coach/suggested-actions: post: tags: @@ -7501,8 +10473,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/admin/invites: get: tags: @@ -7520,8 +10492,8 @@ paths: schema: $ref: "#/components/schemas/AdminInviteList" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 post: tags: - Admin @@ -7555,8 +10527,8 @@ paths: schema: $ref: "#/components/schemas/AdminInviteCreated" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/admin/invites/{id}: delete: tags: @@ -7579,8 +10551,8 @@ paths: schema: $ref: "#/components/schemas/AdminInviteRevoked" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/insights/chat/messages/{id}/feedback: post: tags: @@ -7615,8 +10587,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/admin/notifications/diagnostic: get: tags: @@ -7639,8 +10611,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/dashboard/snapshot: get: tags: @@ -7658,8 +10630,8 @@ paths: $ref: "#/components/schemas/DashboardSnapshotResponseEnvelope" "401": *a1 "403": *a8 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/insights/comprehensive: get: tags: @@ -7695,8 +10667,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/insights/cards: get: tags: @@ -7716,8 +10688,8 @@ paths: $ref: "#/components/schemas/InsightsCardsEnvelope" "401": *a1 "403": *a9 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/insights/pregenerate: post: tags: @@ -7743,8 +10715,585 @@ paths: schema: $ref: "#/components/schemas/InsightsPregenerateResponseEnvelope" "401": *a1 - "422": *a3 - "429": *a4 + "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 + "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. + Gated on the Coach assistant surface — an operator who turns Coach off empties every advisor consumer. 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: The operator has switched the Coach assistant surface off (`meta.errorCode` = `assistant.disabled.coach`). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "429": *a5 + 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. Gated on the Coach assistant surface, consent-gated when the resolved chain egresses through the + operator's server-managed key. 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: The Coach assistant surface is off (`assistant.disabled.coach`), or the resolved chain egresses through the + operator's key and no consent receipt is on file (`consent.ai.required`). + 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" + "422": + description: No AI provider is configured anywhere for this account, 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" + "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. + 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 + "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 + "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 + "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. + Module-gated on `insights` and gated on the Coach assistant surface, because the strip is the ambient Coach + presence on that page. 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: + 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 + description: "The metric the strip is about. Closed enum: an unknown value 422s." + 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": *a9 + "422": *a4 + "429": *a5 + /api/insights/coach/seeded-question: + get: + tags: + - Insights + summary: Today's suggested Coach opener + description: "Resolves today's single most notable derived wellness signal into a tappable opener for the Coach's + blank-chat hero, using the same confidence-gated detector the daily briefing uses. The selection happens + SERVER-side and the client renders it rather than recomputing. `signal: null` is the ordinary answer and means + the hero keeps its neutral greeting — never a fabricated opener. Two different causes produce that null and are + indistinguishable on the wire: nothing crossed the notability gate, or the account has turned proactive + suggestions off, in which case the detector does not run at all. Module-gated on `insights` and on the Coach + assistant surface. Shared analytics-read budget. Cookie or Bearer auth; not delegable." + responses: + "200": + description: The opener, or the neutral null. + content: + application/json: + schema: + $ref: "#/components/schemas/CoachSeededQuestionEnvelope" + "401": *a1 + "403": *a12 + "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. + Module-gated on `insights` and on the `insightStatus` assistant surface. 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": *a9 + "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": *a8 + "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. Module-gated on `insights`. + 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": *a9 + "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 + "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 + "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": *a8 + "422": *a4 + "429": *a5 /api/analytics/range: get: tags: @@ -7862,8 +11411,8 @@ paths: schema: $ref: "#/components/schemas/AnalyticsRangeResponseEnvelope" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/insights/blood-pressure-status: get: tags: @@ -7891,8 +11440,8 @@ paths: $ref: "#/components/schemas/BloodPressureStatusResponseEnvelope" "401": *a1 "403": *a8 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/insights/pulse-status: get: tags: @@ -7920,8 +11469,8 @@ paths: $ref: "#/components/schemas/PulseStatusResponseEnvelope" "401": *a1 "403": *a8 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/insights/weight-status: get: tags: @@ -7949,8 +11498,8 @@ paths: $ref: "#/components/schemas/WeightStatusResponseEnvelope" "401": *a1 "403": *a8 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/insights/bmi-status: get: tags: @@ -7978,8 +11527,8 @@ paths: $ref: "#/components/schemas/BmiStatusResponseEnvelope" "401": *a1 "403": *a8 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/insights/mood-status: get: tags: @@ -8007,8 +11556,8 @@ paths: $ref: "#/components/schemas/MoodStatusResponseEnvelope" "401": *a1 "403": *a8 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/insights/medication-compliance-status: get: tags: @@ -8036,8 +11585,8 @@ paths: $ref: "#/components/schemas/MedicationComplianceStatusResponseEnvelope" "401": *a1 "403": *a8 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/insights/metric-status: get: tags: @@ -8128,8 +11677,8 @@ paths: $ref: "#/components/schemas/MetricStatusResponseEnvelope" "401": *a1 "403": *a8 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/insights/biomarker-assessment: get: tags: @@ -8170,8 +11719,8 @@ paths: $ref: "#/components/schemas/BiomarkerAssessmentResponseEnvelope" "401": *a1 "403": *a8 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/insights/derived: get: tags: @@ -8253,8 +11802,8 @@ paths: $ref: "#/components/schemas/DerivedMetricResponseEnvelope" "401": *a1 "403": *a8 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/insights/derived/batch: get: tags: @@ -8292,8 +11841,8 @@ paths: $ref: "#/components/schemas/DerivedBatchResponseEnvelope" "401": *a1 "403": *a8 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/insights/glp1-plateau: get: tags: @@ -8312,8 +11861,8 @@ paths: $ref: "#/components/schemas/InsightsGlp1PlateauResponseEnvelope" "401": *a1 "403": *a8 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/insights/ecg: get: tags: @@ -8335,8 +11884,8 @@ paths: $ref: "#/components/schemas/EcgListResponseEnvelope" "401": *a1 "403": *a9 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 post: tags: - Insights @@ -8373,8 +11922,8 @@ paths: $ref: "#/components/schemas/EcgIngestCreatedEnvelope" "401": *a1 "403": *a12 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/insights/ecg/{id}: get: tags: @@ -8420,8 +11969,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/insights/rhythm-events: get: tags: @@ -8445,8 +11994,8 @@ paths: $ref: "#/components/schemas/RhythmEventsResponseEnvelope" "401": *a1 "403": *a9 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/insights/correlations: get: tags: @@ -8466,8 +12015,8 @@ paths: $ref: "#/components/schemas/CorrelationDiscoveryResponseEnvelope" "401": *a1 "403": *a8 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/insights/patterns: get: tags: @@ -8484,8 +12033,8 @@ paths: $ref: "#/components/schemas/CorrelationPatternListResponseEnvelope" "401": *a1 "403": *a9 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/insights/patterns/{id}: patch: tags: @@ -8520,8 +12069,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/insights/health-status: get: tags: @@ -8539,8 +12088,8 @@ paths: $ref: "#/components/schemas/InsightsHealthStatusEnvelope" "401": *a1 "403": *a8 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/insights/breathing-screening: get: tags: @@ -8558,8 +12107,8 @@ paths: $ref: "#/components/schemas/InsightsBreathingScreeningEnvelope" "401": *a1 "403": *a8 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/insights/labs-changes: get: tags: @@ -8577,8 +12126,53 @@ paths: $ref: "#/components/schemas/InsightsLabsChangesEnvelope" "401": *a1 "403": *a8 - "422": *a3 - "429": *a4 + "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": *a12 + "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." + responses: + "200": + description: The aggregate bundle. + content: + application/json: + schema: + $ref: "#/components/schemas/MoodAggregatesEnvelope" + "401": *a1 + "403": *a12 + "422": *a4 + "429": *a5 /api/mood/prognosis: get: tags: @@ -8600,8 +12194,8 @@ paths: $ref: "#/components/schemas/MoodPrognosisEnvelope" "401": *a1 "403": *a9 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/mood-entries/bulk: post: tags: @@ -8613,7 +12207,7 @@ paths: 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: - - *a5 + - *a6 requestBody: required: true content: @@ -8628,9 +12222,9 @@ paths: schema: $ref: "#/components/schemas/BulkMoodResponseEnvelope" "401": *a1 - "409": *a6 - "422": *a3 - "429": *a4 + "409": *a7 + "422": *a4 + "429": *a5 /api/mood/tags: get: tags: @@ -8656,8 +12250,8 @@ paths: schema: $ref: "#/components/schemas/MoodTagTreeEnvelope" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/mood/tags/custom: post: tags: @@ -8695,8 +12289,8 @@ paths: schema: $ref: "#/components/schemas/MoodCustomTagCreatedEnvelope" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/mood/tags/custom/{key}: patch: tags: @@ -8745,8 +12339,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 delete: tags: - Mood @@ -8775,8 +12369,8 @@ paths: $ref: "#/components/schemas/MoodCustomTagDeleteEnvelope" "401": *a1 "404": *a13 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/mood/tags/{key}/hidden: put: tags: @@ -8816,8 +12410,8 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 "404": *a13 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/mood/tags/groups: post: tags: @@ -8850,8 +12444,8 @@ paths: schema: $ref: "#/components/schemas/MoodTagGroupCreatedEnvelope" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/mood/tags/groups/{key}: patch: tags: @@ -8891,8 +12485,8 @@ paths: $ref: "#/components/schemas/MoodTagGroupPatchEnvelope" "401": *a1 "404": *a13 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 delete: tags: - Mood @@ -8922,8 +12516,8 @@ paths: $ref: "#/components/schemas/MoodTagGroupDeleteEnvelope" "401": *a1 "404": *a13 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/mood/tags/layout: get: tags: @@ -8939,8 +12533,8 @@ paths: schema: $ref: "#/components/schemas/MoodTagLayoutGetEnvelope" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 put: tags: - Mood @@ -8970,7 +12564,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a10 - "429": *a4 + "429": *a5 /api/mood-entries: get: tags: @@ -9053,8 +12647,8 @@ paths: $ref: "#/components/schemas/MoodEntryListEnvelope" "401": *a1 "403": *a8 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 post: tags: - Mood @@ -9069,7 +12663,7 @@ paths: 422s (`mood.create.invalid`). An unstable `externalId` (an object description or memory address that rotates per launch) is refused at validation. parameters: - - *a5 + - *a6 requestBody: required: true content: @@ -9094,8 +12688,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/mood-entries/{id}: get: tags: @@ -9119,8 +12713,8 @@ paths: "401": *a1 "403": *a8 "404": *a13 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 put: tags: - Mood @@ -9155,8 +12749,8 @@ paths: "403": *a8 "404": *a13 "409": *a14 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 delete: tags: - Mood @@ -9181,8 +12775,8 @@ paths: "401": *a1 "403": *a8 "404": *a13 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/mood-entries/restore: post: tags: @@ -9194,7 +12788,7 @@ paths: 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: - - *a5 + - *a6 requestBody: required: true content: @@ -9210,9 +12804,9 @@ paths: $ref: "#/components/schemas/MoodEntriesRestoreEnvelope" "401": *a1 "403": *a8 - "409": *a6 - "422": *a3 - "429": *a4 + "409": *a7 + "422": *a4 + "429": *a5 /api/mood-entries/bulk-delete: post: tags: @@ -9223,7 +12817,7 @@ paths: 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: - - *a5 + - *a6 requestBody: required: true content: @@ -9239,9 +12833,9 @@ paths: $ref: "#/components/schemas/MoodEntriesBulkDeleteEnvelope" "401": *a1 "403": *a8 - "409": *a6 - "422": *a3 - "429": *a4 + "409": *a7 + "422": *a4 + "429": *a5 /api/mood/linked-context: get: tags: @@ -9283,8 +12877,185 @@ paths: $ref: "#/components/schemas/MoodLinkedContextEnvelope" "401": *a1 "403": *a9 - "422": *a3 - "429": *a4 + "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 + "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." + 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": &a15 + description: Body is not parseable JSON. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "401": *a1 + "413": &a16 + description: Body exceeds 64 KiB. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "415": &a17 + 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. + 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 + "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." + 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": *a15 + "401": *a1 + "404": + description: The session's user row no longer exists. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "413": *a16 + "415": *a17 + "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 + "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": *a15 + "401": *a1 + "413": *a16 + "415": *a17 + "422": + description: Body matched neither accepted shape, or `enabled` is true while no webhook URL is stored. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "429": *a5 /api/settings/reminder-thresholds: get: tags: @@ -9302,8 +13073,322 @@ paths: $ref: "#/components/schemas/GetReminderThresholdsResponse" "401": *a1 "403": *a8 - "422": *a3 - "429": *a4 + "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 + "422": *a4 + "429": *a5 + /api/settings/account: + delete: + tags: + - Auth + summary: Permanently delete the account and everything in it + description: >- + Erasure, not deactivation: the user row goes, every related row cascades with it, and the account's own audit + history — IP addresses, login geo — is purged inside the same transaction rather than being left orphaned. There + is no undo and no grace period. + + + 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 session must additionally carry a fresh + factor proof, which is cookie-only by construction: a Bearer transport carries no `mfaVerifiedAt` and therefore + cannot satisfy it at any scope. 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. + + + 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 and all associated data deleted. + 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 session carries no proof of it fresh enough + for a destructive action (`meta.errorCode` = `auth.stepup.required`, or `auth.stepup.mfa_not_enrolled`). + Re-verify the factor on the web and retry; a Bearer token cannot clear this arm. + 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 + "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 + "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 transport failure 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 + "422": *a4 + "429": *a5 + /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 publish failure is a 500. + responses: + "200": + description: The provider accepted the message. Not proof it was delivered. + content: + application/json: + schema: + $ref: "#/components/schemas/NtfyTestEnvelope" + "401": *a1 + "422": *a4 + "429": *a5 + /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 + "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 delivery failure 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. + responses: + "200": + description: The provider accepted the message. Not proof it was delivered. + content: + application/json: + schema: + $ref: "#/components/schemas/WebhookTestEnvelope" + "401": *a1 + "422": *a4 + "429": *a5 + /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. + + + Gated like account deletion: the typed confirmation, plus a fresh factor proof for an MFA-enrolled account, + which is cookie-only by construction. 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. + 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 session carries no proof of it fresh enough + for a destructive action (`meta.errorCode` = `auth.stepup.required`, or `auth.stepup.mfa_not_enrolled`). A + Bearer token cannot clear this arm. + 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 + /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 + "422": *a4 + "429": *a5 /api/consent/ai: post: tags: @@ -9349,8 +13434,8 @@ paths: schema: $ref: "#/components/schemas/ConsentValidationErrorEnvelope" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/consent/ai/web: post: tags: @@ -9382,8 +13467,95 @@ paths: schema: $ref: "#/components/schemas/WebConsentGrantResponseEnvelope" "401": *a1 - "422": *a3 - "429": *a4 + "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 + 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": &a18 + description: "`kind` was present but is not one of `ai_full`, `ai_insights_only`, `ai_coach`. 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 + "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` does NOT switch off `documentsAutoAiRead`; that flag is written through PATCH + /api/auth/me/documents-auto-ai-read and turning it on mints a fresh receipt. A client offering both controls + should show them as the two separate things they are. + 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 + 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": *a18 + "401": *a1 + "422": *a4 + "429": *a5 /api/import/csv: post: tags: @@ -9433,8 +13605,8 @@ paths: application/json: schema: $ref: "#/components/schemas/CsvImportTooLargeEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/import: post: tags: @@ -9470,8 +13642,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "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 @@ -9480,6 +13652,127 @@ paths: 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 + "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, so the job cannot be queued. The upload was staged and is + discarded; 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 + "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: @@ -9496,8 +13789,8 @@ paths: $ref: "#/components/schemas/MeasurementReminderListEnvelope" "401": *a1 "403": *a8 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 post: tags: - MeasurementReminders @@ -9505,7 +13798,7 @@ paths: 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: - - *a5 + - *a6 requestBody: required: true content: @@ -9521,9 +13814,9 @@ paths: $ref: "#/components/schemas/MeasurementReminderCreatedEnvelope" "401": *a1 "403": *a8 - "409": *a6 - "422": *a3 - "429": *a4 + "409": *a7 + "422": *a4 + "429": *a5 /api/measurement-reminders/{id}: get: tags: @@ -9545,14 +13838,14 @@ paths: $ref: "#/components/schemas/MeasurementReminderEnvelope" "401": *a1 "403": *a8 - "404": &a15 + "404": &a19 description: Measurement reminder not found / not owned. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 patch: tags: - MeasurementReminders @@ -9580,9 +13873,9 @@ paths: $ref: "#/components/schemas/MeasurementReminderPatchEnvelope" "401": *a1 "403": *a8 - "404": *a15 - "422": *a3 - "429": *a4 + "404": *a19 + "422": *a4 + "429": *a5 delete: tags: - MeasurementReminders @@ -9603,9 +13896,9 @@ paths: $ref: "#/components/schemas/MeasurementReminderDeleteEnvelope" "401": *a1 "403": *a8 - "404": *a15 - "422": *a3 - "429": *a4 + "404": *a19 + "422": *a4 + "429": *a5 /api/measurement-reminders/{id}/satisfy: post: tags: @@ -9628,9 +13921,9 @@ paths: $ref: "#/components/schemas/MeasurementReminderSatisfyEnvelope" "401": *a1 "403": *a8 - "404": *a15 - "422": *a3 - "429": *a4 + "404": *a19 + "422": *a4 + "429": *a5 /api/measurement-reminders/{id}/complete: post: tags: @@ -9656,9 +13949,9 @@ paths: $ref: "#/components/schemas/MeasurementReminderCompleteEnvelope" "401": *a1 "403": *a8 - "404": *a15 - "422": *a3 - "429": *a4 + "404": *a19 + "422": *a4 + "429": *a5 /api/measurement-reminders/{id}/skip: post: tags: @@ -9686,9 +13979,9 @@ paths: $ref: "#/components/schemas/MeasurementReminderSkipEnvelope" "401": *a1 "403": *a8 - "404": *a15 - "422": *a3 - "429": *a4 + "404": *a19 + "422": *a4 + "429": *a5 /api/measurement-reminders/{id}/snooze: post: tags: @@ -9720,9 +14013,9 @@ paths: $ref: "#/components/schemas/MeasurementReminderSnoozeEnvelope" "401": *a1 "403": *a8 - "404": *a15 - "422": *a3 - "429": *a4 + "404": *a19 + "422": *a4 + "429": *a5 /api/measurement-reminders/{id}/history: get: tags: @@ -9761,9 +14054,9 @@ paths: $ref: "#/components/schemas/MeasurementReminderHistoryEnvelope" "401": *a1 "403": *a8 - "404": *a15 - "422": *a3 - "429": *a4 + "404": *a19 + "422": *a4 + "429": *a5 /api/labs: get: tags: @@ -9834,8 +14127,8 @@ paths: $ref: "#/components/schemas/ListLabResultsEnvelope" "401": *a1 "403": *a8 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 post: tags: - Labs @@ -9845,7 +14138,7 @@ paths: 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: - - *a5 + - *a6 requestBody: required: true content: @@ -9922,9 +14215,9 @@ paths: $ref: "#/components/schemas/CreateLabResultResponse" "401": *a1 "403": *a8 - "409": *a6 - "422": *a3 - "429": *a4 + "409": *a7 + "422": *a4 + "429": *a5 /api/labs/{id}: get: tags: @@ -9946,14 +14239,14 @@ paths: $ref: "#/components/schemas/GetLabResultResponse" "401": *a1 "403": *a8 - "404": &a16 + "404": &a20 description: Lab result not found (or owned by another user). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 put: tags: - Labs @@ -10041,9 +14334,9 @@ paths: $ref: "#/components/schemas/UpdateLabResultResponse" "401": *a1 "403": *a8 - "404": *a16 - "422": *a3 - "429": *a4 + "404": *a20 + "422": *a4 + "429": *a5 delete: tags: - Labs @@ -10064,9 +14357,9 @@ paths: $ref: "#/components/schemas/DeleteLabResultResponse" "401": *a1 "403": *a8 - "404": *a16 - "422": *a3 - "429": *a4 + "404": *a20 + "422": *a4 + "429": *a5 /api/labs/restore: post: tags: @@ -10075,7 +14368,7 @@ paths: 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: - - *a5 + - *a6 requestBody: required: true content: @@ -10091,9 +14384,9 @@ paths: $ref: "#/components/schemas/RestoreLabResultsResponse" "401": *a1 "403": *a8 - "409": *a6 - "422": *a3 - "429": *a4 + "409": *a7 + "422": *a4 + "429": *a5 /api/labs/ocr/capability: get: tags: @@ -10109,8 +14402,8 @@ paths: schema: $ref: "#/components/schemas/OcrCapabilityEnvelope" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/labs/ocr/extract: post: tags: @@ -10149,8 +14442,8 @@ paths: schema: $ref: "#/components/schemas/OcrExtractEnvelope" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/labs/ocr/commit: post: tags: @@ -10161,7 +14454,7 @@ paths: Idempotent (Idempotency-Key). Audits as `labs.ocr.commit`. `userId` is narrowed from the session, never a body field.' parameters: - - *a5 + - *a6 requestBody: required: true content: @@ -10232,9 +14525,9 @@ paths: schema: $ref: "#/components/schemas/OcrCommitEnvelope" "401": *a1 - "409": *a6 - "422": *a3 - "429": *a4 + "409": *a7 + "422": *a4 + "429": *a5 /api/biomarkers: get: tags: @@ -10251,8 +14544,8 @@ paths: $ref: "#/components/schemas/ListBiomarkersEnvelope" "401": *a1 "403": *a8 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 post: tags: - Labs @@ -10260,7 +14553,7 @@ paths: description: Creates a user-scoped biomarker. The optional context note is AES-256-GCM encrypted before write. Audits as `biomarker.create`. parameters: - - *a5 + - *a6 requestBody: required: true content: @@ -10302,14 +14595,14 @@ paths: $ref: "#/components/schemas/CreateBiomarkerResponse" "401": *a1 "403": *a8 - "409": &a18 + "409": &a22 description: A biomarker with this name already exists for the caller. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/biomarkers/{id}: get: tags: @@ -10331,14 +14624,14 @@ paths: $ref: "#/components/schemas/GetBiomarkerResponse" "401": *a1 "403": *a8 - "404": &a17 + "404": &a21 description: Biomarker not found (or owned by another user). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 put: tags: - Labs @@ -10399,10 +14692,10 @@ paths: $ref: "#/components/schemas/UpdateBiomarkerResponse" "401": *a1 "403": *a8 - "404": *a17 - "409": *a18 - "422": *a3 - "429": *a4 + "404": *a21 + "409": *a22 + "422": *a4 + "429": *a5 delete: tags: - Labs @@ -10423,9 +14716,9 @@ paths: schema: $ref: "#/components/schemas/DeleteBiomarkerResponse" "401": *a1 - "404": *a17 - "422": *a3 - "429": *a4 + "404": *a21 + "422": *a4 + "429": *a5 /api/encounters: get: tags: @@ -10482,8 +14775,8 @@ paths: $ref: "#/components/schemas/ListEncountersEnvelope" "401": *a1 "403": *a8 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 post: tags: - Records @@ -10492,7 +14785,7 @@ paths: 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: - - *a5 + - *a6 requestBody: required: true content: @@ -10579,9 +14872,9 @@ paths: $ref: "#/components/schemas/CreateEncounterEnvelope" "401": *a1 "403": *a8 - "409": *a6 - "422": *a3 - "429": *a4 + "409": *a7 + "422": *a4 + "429": *a5 /api/encounters/suggest: get: tags: @@ -10610,8 +14903,8 @@ paths: $ref: "#/components/schemas/SuggestEncounterEnvelope" "401": *a1 "403": *a8 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/encounters/{id}: get: tags: @@ -10633,14 +14926,14 @@ paths: $ref: "#/components/schemas/GetEncounterEnvelope" "401": *a1 "403": *a8 - "404": &a19 + "404": &a23 description: Visit not found (or owned by another user). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 patch: tags: - Records @@ -10734,9 +15027,9 @@ paths: $ref: "#/components/schemas/UpdateEncounterEnvelope" "401": *a1 "403": *a8 - "404": *a19 - "422": *a3 - "429": *a4 + "404": *a23 + "422": *a4 + "429": *a5 delete: tags: - Records @@ -10759,9 +15052,9 @@ paths: $ref: "#/components/schemas/DeleteEncounterEnvelope" "401": *a1 "403": *a8 - "404": *a19 - "422": *a3 - "429": *a4 + "404": *a23 + "422": *a4 + "429": *a5 /api/encounters/{id}/restore: post: tags: @@ -10785,9 +15078,9 @@ paths: $ref: "#/components/schemas/RestoreEncounterEnvelope" "401": *a1 "403": *a8 - "404": *a19 - "422": *a3 - "429": *a4 + "404": *a23 + "422": *a4 + "429": *a5 /api/encounters/{id}/links: post: tags: @@ -10834,9 +15127,9 @@ paths: schema: $ref: "#/components/schemas/LinkEncounterEnvelope" "401": *a1 - "404": *a19 - "422": *a3 - "429": *a4 + "404": *a23 + "422": *a4 + "429": *a5 delete: tags: - Records @@ -10882,9 +15175,9 @@ paths: schema: $ref: "#/components/schemas/UnlinkEncounterEnvelope" "401": *a1 - "404": *a19 - "422": *a3 - "429": *a4 + "404": *a23 + "422": *a4 + "429": *a5 /api/practitioners: get: tags: @@ -10913,15 +15206,15 @@ paths: $ref: "#/components/schemas/ListPractitionersEnvelope" "401": *a1 "403": *a8 - "422": *a3 - "429": *a4 + "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: - - *a5 + - *a6 requestBody: required: true content: @@ -10970,9 +15263,9 @@ paths: $ref: "#/components/schemas/CreatePractitionerEnvelope" "401": *a1 "403": *a8 - "409": *a6 - "422": *a3 - "429": *a4 + "409": *a7 + "422": *a4 + "429": *a5 /api/practitioners/{id}: get: tags: @@ -10994,14 +15287,14 @@ paths: $ref: "#/components/schemas/GetPractitionerEnvelope" "401": *a1 "403": *a8 - "404": &a20 + "404": &a24 description: Practitioner not found (or owned by another user). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 patch: tags: - Records @@ -11059,9 +15352,9 @@ paths: $ref: "#/components/schemas/UpdatePractitionerEnvelope" "401": *a1 "403": *a8 - "404": *a20 - "422": *a3 - "429": *a4 + "404": *a24 + "422": *a4 + "429": *a5 delete: tags: - Records @@ -11083,9 +15376,9 @@ paths: $ref: "#/components/schemas/DeletePractitionerEnvelope" "401": *a1 "403": *a8 - "404": *a20 - "422": *a3 - "429": *a4 + "404": *a24 + "422": *a4 + "429": *a5 /api/practitioners/{id}/restore: post: tags: @@ -11109,9 +15402,9 @@ paths: $ref: "#/components/schemas/RestorePractitionerEnvelope" "401": *a1 "403": *a8 - "404": *a20 - "422": *a3 - "429": *a4 + "404": *a24 + "422": *a4 + "429": *a5 /api/vaccinations: get: tags: @@ -11153,8 +15446,8 @@ paths: $ref: "#/components/schemas/ListVaccinationsEnvelope" "401": *a1 "403": *a8 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 post: tags: - Records @@ -11164,7 +15457,7 @@ paths: 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: - - *a5 + - *a6 requestBody: required: true content: @@ -11254,9 +15547,9 @@ paths: $ref: "#/components/schemas/CreateVaccinationEnvelope" "401": *a1 "403": *a8 - "409": *a6 - "422": *a3 - "429": *a4 + "409": *a7 + "422": *a4 + "429": *a5 /api/vaccinations/{id}: get: tags: @@ -11278,14 +15571,14 @@ paths: $ref: "#/components/schemas/GetVaccinationEnvelope" "401": *a1 "403": *a8 - "404": &a21 + "404": &a25 description: Vaccination not found (or owned by another user). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 patch: tags: - Records @@ -11385,9 +15678,9 @@ paths: $ref: "#/components/schemas/UpdateVaccinationEnvelope" "401": *a1 "403": *a8 - "404": *a21 - "422": *a3 - "429": *a4 + "404": *a25 + "422": *a4 + "429": *a5 delete: tags: - Records @@ -11410,9 +15703,9 @@ paths: $ref: "#/components/schemas/DeleteVaccinationEnvelope" "401": *a1 "403": *a8 - "404": *a21 - "422": *a3 - "429": *a4 + "404": *a25 + "422": *a4 + "429": *a5 /api/vaccinations/{id}/restore: post: tags: @@ -11435,9 +15728,9 @@ paths: $ref: "#/components/schemas/RestoreVaccinationEnvelope" "401": *a1 "403": *a8 - "404": *a21 - "422": *a3 - "429": *a4 + "404": *a25 + "422": *a4 + "429": *a5 /api/vaccinations/{id}/booster: post: tags: @@ -11493,9 +15786,9 @@ paths: $ref: "#/components/schemas/MintVaccinationBoosterEnvelope" "401": *a1 "403": *a8 - "404": *a21 - "422": *a3 - "429": *a4 + "404": *a25 + "422": *a4 + "429": *a5 /api/vaccinations/suggest: get: tags: @@ -11522,8 +15815,8 @@ paths: $ref: "#/components/schemas/VaccinationSuggestEnvelope" "401": *a1 "403": *a8 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/vaccinations/{id}/links: post: tags: @@ -11568,9 +15861,9 @@ paths: schema: $ref: "#/components/schemas/LinkVaccinationEnvelope" "401": *a1 - "404": *a21 - "422": *a3 - "429": *a4 + "404": *a25 + "422": *a4 + "429": *a5 delete: tags: - Records @@ -11614,9 +15907,9 @@ paths: schema: $ref: "#/components/schemas/UnlinkVaccinationEnvelope" "401": *a1 - "404": *a21 - "422": *a3 - "429": *a4 + "404": *a25 + "422": *a4 + "429": *a5 /api/illness/episodes: get: tags: @@ -11648,8 +15941,8 @@ paths: $ref: "#/components/schemas/ListIllnessEpisodesEnvelope" "401": *a1 "403": *a8 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 post: tags: - Illness @@ -11658,7 +15951,7 @@ paths: AES-256-GCM encrypted before write. A `parentConditionId` must reference an owned, live episode. Audits as `illness.episode.create`. Born-gated. parameters: - - *a5 + - *a6 requestBody: required: true content: @@ -11721,9 +16014,9 @@ paths: $ref: "#/components/schemas/CreateIllnessEpisodeEnvelope" "401": *a1 "403": *a8 - "409": *a6 - "422": *a3 - "429": *a4 + "409": *a7 + "422": *a4 + "429": *a5 /api/illness/episodes/{id}: get: tags: @@ -11745,14 +16038,14 @@ paths: $ref: "#/components/schemas/GetIllnessEpisodeEnvelope" "401": *a1 "403": *a8 - "404": &a22 + "404": &a26 description: Illness episode not found (or owned by another user). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 patch: tags: - Illness @@ -11824,9 +16117,9 @@ paths: $ref: "#/components/schemas/UpdateIllnessEpisodeEnvelope" "401": *a1 "403": *a8 - "404": *a22 - "422": *a3 - "429": *a4 + "404": *a26 + "422": *a4 + "429": *a5 delete: tags: - Illness @@ -11848,9 +16141,9 @@ paths: $ref: "#/components/schemas/DeleteIllnessEpisodeEnvelope" "401": *a1 "403": *a8 - "404": *a22 - "422": *a3 - "429": *a4 + "404": *a26 + "422": *a4 + "429": *a5 /api/illness/episodes/{id}/restore: post: tags: @@ -11866,7 +16159,7 @@ paths: schema: type: string required: true - - *a5 + - *a6 responses: "200": description: Episode restored. @@ -11876,10 +16169,10 @@ paths: $ref: "#/components/schemas/RestoreIllnessEpisodeEnvelope" "401": *a1 "403": *a8 - "404": *a22 - "409": *a6 - "422": *a3 - "429": *a4 + "404": *a26 + "409": *a7 + "422": *a4 + "429": *a5 /api/illness/episodes/{id}/resolve: patch: tags: @@ -11914,9 +16207,9 @@ paths: $ref: "#/components/schemas/ResolveIllnessEpisodeEnvelope" "401": *a1 "403": *a8 - "404": *a22 - "422": *a3 - "429": *a4 + "404": *a26 + "422": *a4 + "429": *a5 /api/illness/episodes/{id}/day-logs: get: tags: @@ -11965,9 +16258,9 @@ paths: $ref: "#/components/schemas/GetIllnessDayLogEnvelope" "401": *a1 "403": *a8 - "404": *a22 - "422": *a3 - "429": *a4 + "404": *a26 + "422": *a4 + "429": *a5 post: tags: - Illness @@ -12045,9 +16338,9 @@ paths: $ref: "#/components/schemas/UpsertIllnessDayLogCreatedEnvelope" "401": *a1 "403": *a8 - "404": *a22 - "422": *a3 - "429": *a4 + "404": *a26 + "422": *a4 + "429": *a5 /api/illness/episodes/{id}/correlation: get: tags: @@ -12072,9 +16365,9 @@ paths: schema: $ref: "#/components/schemas/IllnessCorrelationEnvelope" "401": *a1 - "404": *a22 - "422": *a3 - "429": *a4 + "404": *a26 + "422": *a4 + "429": *a5 /api/illness/insights: get: tags: @@ -12106,8 +16399,8 @@ paths: schema: $ref: "#/components/schemas/IllnessInsightsEnvelope" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/mental-health/assessments: get: tags: @@ -12146,8 +16439,8 @@ paths: $ref: "#/components/schemas/ListMentalHealthAssessmentsEnvelope" "401": *a1 "403": *a8 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 post: tags: - Mental health @@ -12157,7 +16450,7 @@ paths: 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. parameters: - - *a5 + - *a6 requestBody: content: application/json: @@ -12215,9 +16508,9 @@ paths: $ref: "#/components/schemas/CreateMentalHealthAssessmentEnvelope" "401": *a1 "403": *a8 - "409": *a6 - "422": *a3 - "429": *a4 + "409": *a7 + "422": *a4 + "429": *a5 /api/mental-health/assessments/{id}: get: tags: @@ -12242,8 +16535,8 @@ paths: $ref: "#/components/schemas/GetMentalHealthAssessmentEnvelope" "401": *a1 "403": *a8 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/allergies: get: tags: @@ -12275,8 +16568,8 @@ paths: $ref: "#/components/schemas/ListAllergiesEnvelope" "401": *a1 "403": *a8 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 post: tags: - Records @@ -12284,7 +16577,7 @@ paths: 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: - - *a5 + - *a6 requestBody: required: true content: @@ -12353,9 +16646,9 @@ paths: $ref: "#/components/schemas/CreateAllergyEnvelope" "401": *a1 "403": *a8 - "409": *a6 - "422": *a3 - "429": *a4 + "409": *a7 + "422": *a4 + "429": *a5 /api/allergies/{id}: get: tags: @@ -12377,14 +16670,14 @@ paths: $ref: "#/components/schemas/GetAllergyEnvelope" "401": *a1 "403": *a8 - "404": &a23 + "404": &a27 description: Allergy not found (or owned by another user). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 patch: tags: - Records @@ -12460,9 +16753,9 @@ paths: $ref: "#/components/schemas/UpdateAllergyEnvelope" "401": *a1 "403": *a8 - "404": *a23 - "422": *a3 - "429": *a4 + "404": *a27 + "422": *a4 + "429": *a5 delete: tags: - Records @@ -12484,9 +16777,9 @@ paths: $ref: "#/components/schemas/DeleteAllergyEnvelope" "401": *a1 "403": *a8 - "404": *a23 - "422": *a3 - "429": *a4 + "404": *a27 + "422": *a4 + "429": *a5 /api/family-history: get: tags: @@ -12509,8 +16802,8 @@ paths: $ref: "#/components/schemas/ListFamilyHistoryEnvelope" "401": *a1 "403": *a8 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 post: tags: - Records @@ -12518,7 +16811,7 @@ paths: 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: - - *a5 + - *a6 requestBody: required: true content: @@ -12571,9 +16864,9 @@ paths: $ref: "#/components/schemas/CreateFamilyHistoryEnvelope" "401": *a1 "403": *a8 - "409": *a6 - "422": *a3 - "429": *a4 + "409": *a7 + "422": *a4 + "429": *a5 /api/family-history/{id}: get: tags: @@ -12595,14 +16888,14 @@ paths: $ref: "#/components/schemas/GetFamilyHistoryEnvelope" "401": *a1 "403": *a8 - "404": &a24 + "404": &a28 description: Family history entry not found (or owned by another user). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 patch: tags: - Records @@ -12664,9 +16957,9 @@ paths: $ref: "#/components/schemas/UpdateFamilyHistoryEnvelope" "401": *a1 "403": *a8 - "404": *a24 - "422": *a3 - "429": *a4 + "404": *a28 + "422": *a4 + "429": *a5 delete: tags: - Records @@ -12688,9 +16981,9 @@ paths: $ref: "#/components/schemas/DeleteFamilyHistoryEnvelope" "401": *a1 "403": *a8 - "404": *a24 - "422": *a3 - "429": *a4 + "404": *a28 + "422": *a4 + "429": *a5 /api/documents/inbound: get: tags: @@ -12802,8 +17095,8 @@ paths: $ref: "#/components/schemas/InboundDocumentListEnvelope" "401": *a1 "403": *a8 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 post: tags: - Documents @@ -12821,7 +17114,7 @@ paths: `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`." parameters: - - *a5 + - *a6 requestBody: required: true content: @@ -12881,7 +17174,7 @@ paths: schema: $ref: "#/components/schemas/InboundDocumentEnvelope" "401": *a1 - "409": *a6 + "409": *a7 "413": description: "Too large: `meta.reason` is `fileTooLarge` (with `maxFileBytes`) or `quotaExceeded` (with `quotaBytes` + `usedBytes`)." @@ -12896,8 +17189,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/documents/inbound/capability: get: tags: @@ -12917,8 +17210,8 @@ paths: schema: $ref: "#/components/schemas/DocumentAiCapabilityEnvelope" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/documents/inbound/usage: get: tags: @@ -12937,8 +17230,8 @@ paths: $ref: "#/components/schemas/DocumentUsageEnvelope" "401": *a1 "403": *a8 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/documents/inbound/bulk: post: tags: @@ -13003,8 +17296,8 @@ paths: schema: $ref: "#/components/schemas/DocumentBulkEnvelope" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/documents/inbound/{id}: get: tags: @@ -13026,8 +17319,8 @@ paths: $ref: "#/components/schemas/InboundDocumentDetailEnvelope" "401": *a1 "403": *a8 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 patch: tags: - Documents @@ -13091,8 +17384,8 @@ paths: schema: $ref: "#/components/schemas/InboundDocumentUpdateEnvelope" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 delete: tags: - Documents @@ -13113,8 +17406,8 @@ paths: schema: $ref: "#/components/schemas/InboundDiscardEnvelope" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/documents/inbound/{id}/restore: post: tags: @@ -13143,8 +17436,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/documents/inbound/{id}/original: get: tags: @@ -13182,8 +17475,8 @@ paths: format: binary "401": *a1 "403": *a8 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/documents/inbound/{id}/thumbnail: get: tags: @@ -13211,8 +17504,8 @@ paths: format: binary "401": *a1 "403": *a8 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/documents/inbound/{id}/extract: post: tags: @@ -13255,8 +17548,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/documents/inbound/{id}/suggest: post: tags: @@ -13311,8 +17604,8 @@ paths: schema: $ref: "#/components/schemas/DocumentSuggestEnvelope" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/documents/inbound/{id}/summary: post: tags: @@ -13389,8 +17682,8 @@ paths: schema: $ref: "#/components/schemas/DocumentSummaryEnvelope" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/documents/inbound/{id}/index: post: tags: @@ -13446,8 +17739,8 @@ paths: schema: $ref: "#/components/schemas/DocumentIndexEnvelope" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/documents/inbound/{id}/chat: post: tags: @@ -13491,8 +17784,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 get: tags: - Documents @@ -13533,8 +17826,8 @@ paths: schema: $ref: "#/components/schemas/DocumentChatHistoryEnvelope" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/documents/inbound/reindex: post: tags: @@ -13553,8 +17846,8 @@ paths: schema: $ref: "#/components/schemas/DocumentReindexEnvelope" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/documents/inbound/{id}/facts/{factId}: patch: tags: @@ -13726,8 +18019,8 @@ paths: schema: $ref: "#/components/schemas/ExtractedFactEnvelope" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/documents/inbound/{id}/confirm: post: tags: @@ -13737,7 +18030,7 @@ paths: 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: - - *a5 + - *a6 - name: id in: path required: true @@ -13779,9 +18072,9 @@ paths: schema: $ref: "#/components/schemas/InboundConfirmEnvelope" "401": *a1 - "409": *a6 - "422": *a3 - "429": *a4 + "409": *a7 + "422": *a4 + "429": *a5 /api/custom-metrics: get: tags: @@ -13798,8 +18091,8 @@ paths: $ref: "#/components/schemas/ListCustomMetricsEnvelope" "401": *a1 "403": *a8 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 post: tags: - Custom metrics @@ -13807,7 +18100,7 @@ paths: description: Creates a user-scoped custom metric. Re-creating a name that was previously soft-deleted revives that definition. Audits as `customMetric.create`. parameters: - - *a5 + - *a6 requestBody: required: true content: @@ -13848,14 +18141,14 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricResponse" "401": *a1 - "409": &a26 + "409": &a30 description: A custom metric with this name already exists for the caller. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/custom-metrics/{id}: get: tags: @@ -13877,14 +18170,14 @@ paths: $ref: "#/components/schemas/GetCustomMetricResponse" "401": *a1 "403": *a8 - "404": &a25 + "404": &a29 description: Custom metric not found (or owned by another user). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 patch: tags: - Custom metrics @@ -13941,10 +18234,10 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricResponse" "401": *a1 - "404": *a25 - "409": *a26 - "422": *a3 - "429": *a4 + "404": *a29 + "409": *a30 + "422": *a4 + "429": *a5 delete: tags: - Custom metrics @@ -13965,9 +18258,9 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricResponse" "401": *a1 - "404": *a25 - "422": *a3 - "429": *a4 + "404": *a29 + "422": *a4 + "429": *a5 /api/custom-metrics/{id}/entries: get: tags: @@ -14009,9 +18302,9 @@ paths: $ref: "#/components/schemas/ListCustomMetricEntriesEnvelope" "401": *a1 "403": *a8 - "404": *a25 - "422": *a3 - "429": *a4 + "404": *a29 + "422": *a4 + "429": *a5 post: tags: - Custom metrics @@ -14023,7 +18316,7 @@ paths: schema: type: string required: true - - *a5 + - *a6 requestBody: required: true content: @@ -14054,10 +18347,10 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricEntryResponse" "401": *a1 - "404": *a25 - "409": *a6 - "422": *a3 - "429": *a4 + "404": *a29 + "409": *a7 + "422": *a4 + "429": *a5 /api/custom-metrics/{id}/entries/{entryId}: patch: tags: @@ -14105,14 +18398,14 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricEntryResponse" "401": *a1 - "404": &a27 + "404": &a31 description: Custom metric entry not found (or owned by another user). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 delete: tags: - Custom metrics @@ -14138,9 +18431,9 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricEntryResponse" "401": *a1 - "404": *a27 - "422": *a3 - "429": *a4 + "404": *a31 + "422": *a4 + "429": *a5 /api/custom-metrics/{id}/entries/restore: post: tags: @@ -14169,8 +18462,143 @@ paths: schema: $ref: "#/components/schemas/RestoreCustomMetricEntriesResponse" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 + /api/onboarding/step: + post: + tags: + - Onboarding + summary: Advance the onboarding wizard by one step + description: >- + Persists progress through the four-step wizard. The contract is strictly ordered: the submitted `step` must be + exactly the stored step plus one, so a client cannot skip ahead or replay a step it already sent. + + + Submitting step 4 completes onboarding — the completion instant is stamped in the same write, the proxy-readable + pending cookie is cleared so the next navigation stops redirecting to `/onboarding`, and the stored goal + selection seeds the dashboard layout. That seed is ONE-TIME and conditional: it only runs while the layout + column is still unset, so a person who already arranged their tiles is never clobbered, and a concurrent layout + save that lands first wins. + + + The write is guarded on the state it validated, so two tabs submitting the same step do not both succeed — + exactly one lands and the other gets 409. + + + Rate-limited to 30 writes per 10 minutes per user, which is generous for a four-step flow and tight enough to + defang a stuck retry loop. Every accepted call writes an audit row. Cookie or wildcard Bearer; `userId` is never + read from the body. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/OnboardingStepRequest" + responses: + "200": + description: The stored step and completion stamp after the write. + content: + application/json: + schema: + $ref: "#/components/schemas/OnboardingStepEnvelope" + "401": *a1 + "404": + description: The session's user row no longer exists. `meta.errorCode` = `onboarding.user.notFound`. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "409": + description: "The write was refused and nothing changed. `meta.errorCode` distinguishes three cases: + `onboarding.step.completed` — onboarding is already finished, so the wizard has nothing left to advance; + `onboarding.step.outOfOrder` — the submitted step is not the stored step plus one, and the message names the + current step; `onboarding.step.concurrent` — the row moved between the check and the write, which is what a + second tab sees." + 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.step.invalid`. Single-message, not the + multi-issue envelope — the per-field detail is not on the wire. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "429": + description: More than 30 onboarding writes in 10 minutes. `meta.errorCode` = `onboarding.step.rateLimited`. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + /api/onboarding/complete: + post: + tags: + - Onboarding + summary: Stamp onboarding as complete (legacy single-shot path) + 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`. + + + The older sibling of `POST /api/onboarding/step`. It is unconditional in a way the step route is not: it stamps + the completion whatever the stored step is, it re-stamps on every call rather than refusing a second one, and it + writes no audit row and enforces no rate limit of its own. It also does NOT seed the dashboard from the goal + selection — that only happens on the step route's completing call. + + + Calling this mid-wizard makes every remaining `POST /api/onboarding/step` answer 409 + `onboarding.step.completed`, because that route's guarded update requires the completion stamp to still be null. + + + Cookie or wildcard Bearer; `userId` is never read from the body. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/OnboardingCompleteRequest" + responses: + "200": + description: "Onboarding stamped complete. The body is the fixed `{ completed: true }` acknowledgement — it does not + echo what was saved." + content: + application/json: + schema: + $ref: "#/components/schemas/OnboardingCompleteEnvelope" + "401": *a1 + "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. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "429": *a5 /api/onboarding/disclaimer: post: tags: @@ -14193,8 +18621,8 @@ paths: schema: $ref: "#/components/schemas/DisclaimerAckEnvelope" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/onboarding/tour: post: tags: @@ -14217,8 +18645,8 @@ paths: schema: $ref: "#/components/schemas/TourUpdateEnvelope" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/nutrients/batch: post: tags: @@ -14291,7 +18719,7 @@ paths: | caffeine | HKQuantityTypeIdentifierDietaryCaffeine | mg | parameters: - - *a5 + - *a6 requestBody: required: true content: @@ -14314,9 +18742,9 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "409": *a6 - "422": *a3 - "429": *a4 + "409": *a7 + "422": *a4 + "429": *a5 /api/nutrients: get: tags: @@ -14341,7 +18769,7 @@ paths: schema: $ref: "#/components/schemas/NutrientOverviewEnvelope" "401": *a1 - "403": &a28 + "403": &a32 description: >- The opt-in `nutrients` module is off for this account (errorCode `module.disabled`). Stop syncing and do not retry; offer the enable-in-settings hint. Only run the HealthKit read-authorization flow while the module is @@ -14361,8 +18789,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/nutrients/daily: get: tags: @@ -14396,9 +18824,9 @@ paths: schema: $ref: "#/components/schemas/NutrientDailySeriesEnvelope" "401": *a1 - "403": *a28 - "422": *a3 - "429": *a4 + "403": *a32 + "422": *a4 + "429": *a5 /api/nutrients/water: post: tags: @@ -14410,7 +18838,7 @@ paths: 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: - - *a5 + - *a6 requestBody: required: true content: @@ -14425,10 +18853,10 @@ paths: schema: $ref: "#/components/schemas/NutrientWaterWriteEnvelope" "401": *a1 - "403": *a28 - "409": *a6 - "422": *a3 - "429": *a4 + "403": *a32 + "409": *a7 + "422": *a4 + "429": *a5 /api/integrations/healthkit: get: tags: @@ -14444,8 +18872,8 @@ paths: schema: $ref: "#/components/schemas/HealthKitConfigEnvelope" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 patch: tags: - Integrations @@ -14467,8 +18895,8 @@ paths: schema: $ref: "#/components/schemas/HealthKitConfigPatchEnvelope" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/oura/sync: post: tags: @@ -14484,8 +18912,8 @@ paths: schema: $ref: "#/components/schemas/OuraSyncEnvelope" "401": *a1 - "422": *a3 - "429": *a4 + "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. @@ -14509,8 +18937,8 @@ paths: schema: $ref: "#/components/schemas/PolarSyncEnvelope" "401": *a1 - "422": *a3 - "429": *a4 + "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. @@ -14536,8 +18964,8 @@ paths: schema: $ref: "#/components/schemas/StravaSyncEnvelope" "401": *a1 - "422": *a3 - "429": *a4 + "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. @@ -14569,7 +18997,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": *a4 + "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. @@ -14595,8 +19023,1329 @@ paths: schema: $ref: "#/components/schemas/GoogleHealthSyncStatusEnvelope" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 + /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 + "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 + "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 + "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 + "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 + "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 + "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 + "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 + "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 + "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 + "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 + "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": &a33 + description: Body is not parseable JSON. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "401": *a1 + "413": &a34 + description: Body exceeds 64 KiB. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "415": &a35 + description: Content-Type is not `application/json`. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": &a36 + 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 + "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 + "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 + "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 + "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 + "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": *a33 + "401": *a1 + "413": *a34 + "415": *a35 + "422": *a36 + "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 + "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 + "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 + "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 + "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 + "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": *a33 + "401": *a1 + "413": *a34 + "415": *a35 + "422": *a36 + "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 + "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 + "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 + "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 + "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 + "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": *a33 + "401": *a1 + "413": *a34 + "415": *a35 + "422": *a36 + "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 + "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 + "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 + "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 + "422": &a37 + 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": &a38 + 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": &a39 + 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 + "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": *a33 + "401": *a1 + "413": *a34 + "415": *a35 + "422": *a36 + "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 + "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 + "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 + "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 + "422": *a37 + "429": *a38 + "502": *a39 + /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 + "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": *a33 + "401": *a1 + "413": *a34 + "415": *a35 + "422": *a36 + "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 + "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 + "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 + "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 + "422": *a37 + "429": *a38 + "502": *a39 + /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 eight providers, always, in a + fixed order, each with the ledger core, the server-resolved liveness verdict and its own extras. Prefer this + over eight per-provider round-trips. Pure read. Auth via cookie or Bearer." + responses: + "200": + description: All eight provider entries plus the alert threshold. + content: + application/json: + schema: + $ref: "#/components/schemas/IntegrationStatusEnvelopeResponse" + "401": *a1 + "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 + "422": *a4 + "429": *a38 + /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 + "422": *a37 + "429": *a38 + "502": *a39 + /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 + "422": *a4 + "429": *a38 + /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 + "413": + description: Body exceeds 64 KiB. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a37 + "429": *a38 + "502": *a39 + /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 + "422": *a4 + "429": *a38 + /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 + "422": *a37 + "429": *a38 + "502": *a39 + /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 + "422": *a4 + "429": *a38 + /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 + "422": *a37 + "429": *a38 + "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: @@ -14615,8 +20364,8 @@ paths: $ref: "#/components/schemas/DailyDigestEnvelope" "401": *a1 "403": *a9 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/daily/digest/dismiss: post: tags: @@ -14651,8 +20400,65 @@ paths: $ref: "#/components/schemas/DismissPriorityItemEnvelope" "401": *a1 "403": *a12 - "422": *a3 - "429": *a4 + "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 + "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": *a8 + "422": *a4 + "429": *a5 /api/dashboard/widgets: get: tags: @@ -14669,8 +20475,8 @@ paths: $ref: "#/components/schemas/DashboardLayout" "401": *a1 "403": *a8 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 put: tags: - Dashboard @@ -14703,7 +20509,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a10 - "429": *a4 + "429": *a5 delete: tags: - Dashboard @@ -14718,8 +20524,8 @@ paths: schema: $ref: "#/components/schemas/DashboardLayoutReset" "401": *a1 - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/auth/me: get: tags: @@ -14744,8 +20550,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/account/activity: get: tags: @@ -14764,7 +20570,7 @@ paths: schema: $ref: "#/components/schemas/RecordActivityEnvelope" "401": *a1 - "403": &a29 + "403": &a40 description: Refused. `meta.errorCode` is `sharing.not_permitted` when the request was made while acting on another account (grant management is never delegable), or `sharing.access.denied` when the caller may not act as the account they named. The latter is byte-identical for an account that does not exist and one that granted @@ -14773,8 +20579,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/account/grants: get: tags: @@ -14789,9 +20595,9 @@ paths: schema: $ref: "#/components/schemas/AccountGrantListEnvelope" "401": *a1 - "403": *a29 - "422": *a3 - "429": *a4 + "403": *a40 + "422": *a4 + "429": *a5 post: tags: - Account sharing @@ -14884,7 +20690,7 @@ paths: schema: $ref: "#/components/schemas/AcceptedAccountGrantEnvelope" "401": *a1 - "403": *a29 + "403": *a40 "404": description: No such invitation for this account. content: @@ -14903,8 +20709,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/account/grants/{id}: delete: tags: @@ -14928,7 +20734,7 @@ paths: schema: $ref: "#/components/schemas/RevokedAccountGrantEnvelope" "401": *a1 - "403": *a29 + "403": *a40 "404": description: No such grant on this account's record. content: @@ -14945,8 +20751,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/account/grants/{id}/renounce: post: tags: @@ -14969,7 +20775,7 @@ paths: schema: $ref: "#/components/schemas/RenouncedAccountGrantEnvelope" "401": *a1 - "403": *a29 + "403": *a40 "404": description: No such grant held by this account. content: @@ -14986,8 +20792,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/managed-profiles: post: tags: @@ -15022,7 +20828,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 + "422": *a4 "429": description: More than ten profiles from one caller within the hour. No error code — rate-limit refusals carry none anywhere in this API; branch on the status. Nothing was created. @@ -15066,8 +20872,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/managed-profiles/{id}/guardians: get: tags: @@ -15103,8 +20909,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 post: tags: - Account sharing @@ -15232,8 +21038,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a3 - "429": *a4 + "422": *a4 + "429": *a5 /api/account/switch: post: tags: @@ -15268,7 +21074,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a29 + "403": *a40 "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 — @@ -15286,9 +21092,1091 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": *a4 + "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. + 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 + "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 + "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 + "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 + "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/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 + "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 + "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 + "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 + "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": &a41 + description: The operator has disabled the API instance-wide. Every route in this module refuses while that setting is + off, for a cookie session as well as for a Bearer token; it is not a per-user permission and retrying will + not help. + 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": *a41 + "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": *a41 + "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. + 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": *a41 + "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": *a41 + "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": *a8 + "422": *a4 + "429": *a5 + /api/record-settings/integrations: + get: + tags: + - Account sharing + summary: Read the managed record's integration connection states + description: "Disclosure only: whether each of the record's integrations is connected and when it last succeeded. There + is deliberately no control arm and no credential projection anywhere under this path — a Guardian cannot + connect, disconnect, probe or resume a managed record's provider, because an OAuth flow cannot safely carry a + record selector. 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": *a8 + "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: + - &a42 + 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": *a8 + "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: + - *a42 + 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": *a8 + "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, or a role this process runs is not up. Same body shape as the 200, + with `status: \"degraded\"`. 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/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": *a9 + "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": *a12 + "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 2 dp 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": *a12 + "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. + 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": *a12 + "409": + description: No home location is set, so there is nothing to resolve the days against. `meta.errorCode` = + `environment.no_home`. + 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": *a12 + "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 2 dp 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": *a12 + "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": *a12 + "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, which is + why a token scoped to one medication gets 404 for a name it may not touch only when the name does not exist, and + 403 when it does. + + + **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 returns the existing row with **200**; a fresh dose returns **201**. 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. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "404": + description: 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" + "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." + 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 `X-RateLimit-Remaining` / + `X-RateLimit-Reset` headers. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" components: schemas: + CreateCycleCustomSymptomRequest: + type: object + properties: + label: + type: string + minLength: 1 + maxLength: 40 + icon: + anyOf: + - type: string + - type: "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: + anyOf: + - type: string + - type: "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." EncryptedExportRequest: type: object properties: @@ -15678,6 +22566,175 @@ components: required: - refreshToken description: Exchange a one-time-use refresh token for a fresh access + refresh pair. + 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-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-z]{2,}$ + - type: "null" + heightCm: + anyOf: + - type: number + minimum: 50 + maximum: 300 + - type: "null" + dateOfBirth: + anyOf: + - type: string + - type: "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: + anyOf: + - type: string + minLength: 1 + 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. + anyOf: + - type: string + - type: "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). + anyOf: + - type: string + - type: "null" + 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. AppleHealthBatchRequest: type: object properties: @@ -15913,6 +22970,178 @@ components: - 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: @@ -16691,6 +23920,130 @@ components: 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: + anyOf: + - type: string + - type: "null" + baseUrl: + anyOf: + - type: string + - type: "null" + compatBaseUrl: + anyOf: + - type: string + - type: "null" + compatModel: + anyOf: + - type: string + - type: "null" + compatKey: + anyOf: + - type: string + - type: "null" + anthropicKey: + anyOf: + - type: string + - type: "null" + localKey: + anyOf: + - type: string + - type: "null" + openaiKey: + anyOf: + - type: string + - type: "null" + responseTimeoutSeconds: + anyOf: + - type: integer + minimum: 10 + maximum: 600 + - 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: @@ -16897,95 +24250,6 @@ components: 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. - 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-z]{2,}$ - - type: "null" - heightCm: - anyOf: - - type: number - minimum: 50 - maximum: 300 - - type: "null" - dateOfBirth: - anyOf: - - type: string - - type: "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: - anyOf: - - type: string - minLength: 1 - 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. - anyOf: - - type: string - - type: "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). - anyOf: - - type: string - - type: "null" - 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. NotificationPrefsPatchRequest: type: object properties: @@ -17096,6 +24360,340 @@ components: 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. + 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_full` + 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 + 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. + 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 + 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. + 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: @@ -17269,6 +24867,22 @@ components: - 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. + 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. CoachReminderCreate: type: object properties: @@ -17382,6 +24996,111 @@ components: 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 + 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 + required: + - providerType + - enabled + 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 + description: Replaces the account's provider chain wholesale. No credential is written or read here — only which + provider types exist, in what order, and which are enabled. + 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." + 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: @@ -18286,6 +26005,161 @@ components: - 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. 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: "" + 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. 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." + 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-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. JsonImportRequest: type: object properties: @@ -18677,6 +26551,53 @@ components: minLength: 1 required: - ids + OnboardingStepRequest: + type: object + properties: + step: + type: integer + minimum: 1 + maximum: 4 + goals: + maxItems: 6 + type: array + items: + type: string + enum: + - weight-management + - bp-tracking + - glucose-tracking + - sleep-improvement + - medication-compliance + - general-wellness + required: + - step + description: The wizard step being COMPLETED, 1–4. It must equal the stored step plus one — the server does not clamp or + skip. `goals` rides the step-2 submit; every slug is checked against the closed set, so one unknown slug fails + the whole request rather than being dropped. + OnboardingCompleteRequest: + type: object + properties: + displayName: + type: string + minLength: 1 + maxLength: 50 + heightCm: + type: number + minimum: 50 + maximum: 300 + dateOfBirth: + type: string + gender: + type: string + enum: + - MALE + - FEMALE + - OTHER + description: "Optional profile fields to save alongside the completion stamp. Every field is optional and only a truthy + value is written — sending `heightCm: 0` or an empty `displayName` leaves the column alone rather than clearing + it. 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." DisclaimerAckRequest: type: object properties: @@ -18863,6 +26784,249 @@ components: - 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. + 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 + 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: @@ -19246,6 +27410,766 @@ components: 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 + 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 + - es + - fr + - it + - pl + - 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 + additionalProperties: false + required: + - modulePreferences + additionalProperties: false + description: Partial module toggle. Merged onto the record's current preferences; omitted keys keep their value. + 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 + - allTime + 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: 62 + type: array + items: + type: object + properties: + id: + type: string + enum: + - overview + - blood-pressure + - pulse + - oxygen + - body-temperature + - respiratory-rate + - pain + - weight + - bmi + - 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. + 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." MedicationTreatmentClass: type: string enum: @@ -19368,6 +28292,170 @@ components: 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: + anyOf: + - type: string + - type: "null" + 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. + icon: + anyOf: + - type: string + - type: "null" + 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. + 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 + meta: + type: object + properties: + requestId: + type: string + errorCode: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + description: "Standard error response: data is null, error is human prose." + 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: + anyOf: + - type: string + - type: "null" + 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. + icon: + anyOf: + - type: string + - type: "null" + 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. + 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: @@ -19551,26 +28639,6 @@ components: additionalProperties: false description: The canonical day-log row iOS mirrors. `note` is decrypted on read. Soft-deleted rows ride `/api/sync/changes` as tombstones. - ErrorEnvelope: - type: object - properties: - data: - type: "null" - error: - type: string - meta: - type: object - properties: - requestId: - type: string - errorCode: - type: string - additionalProperties: false - required: - - data - - error - additionalProperties: false - description: "Standard error response: data is null, error is human prose." CycleDayLogEnvelope: type: object properties: @@ -20819,6 +29887,187 @@ components: 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: + anyOf: + - type: string + - type: "null" + 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. + 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 kill-switch for every assistant surface. + coach: + type: boolean + description: Coach drawer, chat stream, history. + briefing: + type: boolean + description: Daily Briefing card and its recommendations. + insightStatus: + type: boolean + description: Per-metric status cards on the insight sub-pages. + correlations: + type: boolean + description: Correlation narration on the insights page. + required: + - enabled + - coach + - briefing + - insightStatus + - correlations + additionalProperties: false + description: "The resolved matrix. The master is already applied: when `enabled` is false every sub-flag is false in + this payload, so a client reads `coach` directly and never composes `enabled && coach`." + required: + - assistant + additionalProperties: false + description: The operator's assistant visibility matrix, resolved. Gates the server-routed AND the on-device assistant + surfaces, so a client hides the surface end-to-end rather than degrading it. + 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: @@ -21030,6 +30279,4538 @@ components: - 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: + anyOf: + - type: string + - type: "null" + webPushVapidPrivateKeyEncrypted: + anyOf: + - type: string + - type: "null" + webPushVapidSubject: + anyOf: + - type: string + - type: "null" + apiGlobal: + type: boolean + umamiEnabled: + type: boolean + umamiScriptUrl: + anyOf: + - type: string + - type: "null" + umamiWebsiteId: + anyOf: + - type: string + - type: "null" + glitchtipEnabled: + type: boolean + glitchtipDsn: + anyOf: + - type: string + - type: "null" + glitchtipEnvironment: + anyOf: + - type: string + - type: "null" + reminderLateMinutes: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + reminderMissedMinutes: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + adminAiKeyEncrypted: + anyOf: + - type: string + - type: "null" + adminAiModel: + type: string + adminAiBaseUrl: + type: string + adminCodexAccessTokenEncrypted: + anyOf: + - type: string + - type: "null" + adminCodexRefreshTokenEncrypted: + anyOf: + - type: string + - type: "null" + adminCodexAccountIdEncrypted: + anyOf: + - type: string + - type: "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: + anyOf: + - type: string + - type: "null" + assistantEnabled: + type: boolean + assistantCoachEnabled: + type: boolean + assistantBriefingEnabled: + type: boolean + assistantInsightStatusEnabled: + type: boolean + assistantCorrelationsEnabled: + type: boolean + 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 + - assistantCorrelationsEnabled + - moduleAvailabilityJson + - documentMaxFileBytes + - documentQuotaBytes + 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 + value: + type: number + valueMin: + anyOf: + - type: number + - type: "null" + valueMax: + anyOf: + - type: number + - type: "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 + notes: + anyOf: + - type: string + - type: "null" + notesEncrypted: + anyOf: + - type: string + minLength: 1 + - type: "null" + externalId: + anyOf: + - type: string + - type: "null" + externalSourceVersion: + anyOf: + - type: string + - type: "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: + anyOf: + - type: string + - 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 + 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 + 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: + anyOf: + - type: string + - type: "null" + rxNormCode: + anyOf: + - type: string + - type: "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: + anyOf: + - type: string + - type: "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: + anyOf: + - type: string + - type: "null" + dose: + anyOf: + - type: string + - type: "null" + unitsPerDose: + anyOf: + - anyOf: + - type: string + - type: number + - type: "null" + daysOfWeek: + anyOf: + - type: string + - type: "null" + timesOfDay: + type: array + items: + type: string + reminderGraceMinutes: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + rrule: + anyOf: + - type: string + - type: "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: + anyOf: + - type: string + - type: "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: {} + 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: + anyOf: + - type: string + - type: "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: + anyOf: + - type: string + - type: "null" + doseStrength: + anyOf: + - type: string + - type: "null" + notes: + anyOf: + - type: string + - type: "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 + - 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: {} + required: + - name + - dose + - schedules + - sideEffects + - pauseEras + - doseChanges + - inventoryItems + - inventoryEvents + - efficacyTargets + - scheduleRevisions + 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 + attributionSource: + type: string + enum: + - AUTO + - USER_PIN + source: + type: string + enum: + - WEB + - API + - REMINDER + - IMPORT + - APPLE_HEALTH + idempotencyKey: + anyOf: + - type: string + - type: "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: + anyOf: + - type: string + - type: "null" + inventoryConsumption: + anyOf: + - {} + - type: "null" + externalId: + anyOf: + - type: string + - type: "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: + anyOf: + - anyOf: + - type: "null" + - type: string + - type: "null" + note: + anyOf: + - type: string + - type: "null" + noteEncrypted: + anyOf: + - type: string + minLength: 1 + - type: "null" + source: + type: string + minLength: 1 + externalId: + anyOf: + - type: string + - type: "null" + loggedAt: + type: string + minLength: 1 + tz: + anyOf: + - type: string + - type: "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 + context: + anyOf: + - type: object + properties: + workStatus: + anyOf: + - type: string + - type: "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: + anyOf: + - type: string + - type: "null" + contactForm: + anyOf: + - type: string + - type: "null" + contactExtent: + anyOf: + - type: string + - type: "null" + contactQuality: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + contactSupport: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + leisureCategories: + anyOf: + - type: string + - type: "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: + anyOf: + - type: string + - type: "null" + eventValence: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + eventAt: + anyOf: + - type: string + minLength: 1 + - type: "null" + note: + anyOf: + - type: string + - type: "null" + notesEncrypted: + anyOf: + - type: string + minLength: 1 + - type: "null" + 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: + anyOf: + - type: boolean + - type: "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: + anyOf: + - type: string + - type: "null" + periodEndDate: + anyOf: + - type: string + - type: "null" + lengthDays: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + ovulationDate: + anyOf: + - type: string + - type: "null" + ovulationConfirmed: + type: boolean + isPredicted: + type: boolean + tz: + anyOf: + - type: string + - 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: + - startDate + additionalProperties: {} + cycleDayLogs: + default: [] + type: array + items: + type: object + properties: + id: + type: string + minLength: 1 + date: + type: string + minLength: 1 + cycleId: + anyOf: + - type: string + - type: "null" + flow: + anyOf: + - type: string + enum: + - NONE + - SPOTTING + - LIGHT + - MEDIUM + - HEAVY + - type: "null" + intermenstrualBleeding: + type: boolean + basalBodyTempC: + anyOf: + - type: number + - 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: + anyOf: + - type: boolean + - type: "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: + anyOf: + - type: string + - type: "null" + notesEncrypted: + anyOf: + - type: string + - type: "null" + source: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + externalId: + anyOf: + - type: string + - type: "null" + tz: + anyOf: + - type: string + - 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 + 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: {} + 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: + anyOf: + - type: string + - type: "null" + sortOrder: + default: 0 + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + isActive: + default: true + type: boolean + labelEncrypted: + anyOf: + - type: string + - type: "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: + anyOf: + - type: string + - type: "null" + sortOrder: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + isActive: + type: boolean + labelEncrypted: + anyOf: + - type: string + - type: "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: + anyOf: + - type: string + - type: "null" + sortOrder: + default: 0 + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + labelEncrypted: + anyOf: + - type: string + - type: "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 + panel: + anyOf: + - type: string + - type: "null" + analyte: + type: string + minLength: 1 + value: + anyOf: + - type: number + - type: "null" + valueText: + anyOf: + - type: string + - type: "null" + unit: + type: string + minLength: 1 + referenceLow: + anyOf: + - type: number + - type: "null" + referenceHigh: + anyOf: + - type: number + - type: "null" + sourceReferenceLow: + anyOf: + - type: number + - type: "null" + sourceReferenceHigh: + anyOf: + - type: number + - type: "null" + sourceReferenceText: + anyOf: + - type: string + - type: "null" + takenAt: + type: string + minLength: 1 + source: + type: string + minLength: 1 + biomarkerName: + anyOf: + - type: string + - type: "null" + biomarkerId: + anyOf: + - type: string + - type: "null" + note: + anyOf: + - type: string + - type: "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 + minLength: 1 + lowerBound: + anyOf: + - type: number + - type: "null" + upperBound: + anyOf: + - type: number + - type: "null" + panel: + anyOf: + - type: string + - type: "null" + hidden: + type: boolean + context: + anyOf: + - type: string + - type: "null" + createdAt: + type: string + minLength: 1 + updatedAt: + type: string + minLength: 1 + required: + - name + - unit + 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: + anyOf: + - type: string + - type: "null" + note: + anyOf: + - type: string + - type: "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 + 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: + anyOf: + - type: number + - type: "null" + symptoms: + 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: {} + note: + anyOf: + - type: string + - type: "null" + updatedAt: + type: string + minLength: 1 + noteEncrypted: + anyOf: + - type: string + minLength: 1 + - type: "null" + tz: + anyOf: + - type: string + - type: "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: {} + 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: + anyOf: + - type: string + - type: "null" + note: + anyOf: + - type: string + - type: "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: + anyOf: + - type: string + - type: "null" + createdAt: + type: string + minLength: 1 + updatedAt: + type: string + minLength: 1 + 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: + anyOf: + - type: number + - type: "null" + totalDistanceM: + anyOf: + - type: number + - type: "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: + anyOf: + - type: number + - type: "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 + externalId: + anyOf: + - type: string + - type: "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 + - OTHER + title: + anyOf: + - type: string + - type: "null" + filename: + anyOf: + - type: string + - type: "null" + mimeType: + type: string + minLength: 1 + byteSize: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + status: + type: string + enum: + - STORED + - EXTRACTING + - EXTRACTED + - FAILED + - CONFIRMED + - DISCARDED + reportDate: + anyOf: + - type: string + - type: "null" + documentDate: + anyOf: + - type: string + - type: "null" + contentEncrypted: + type: string + minLength: 1 + contentSha256: + anyOf: + - type: string + pattern: ^[a-f0-9]{64}$ + - type: "null" + contentCodec: + type: string + minLength: 1 + providerType: + anyOf: + - type: string + - type: "null" + errorReason: + anyOf: + - type: string + - type: "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: + anyOf: + - type: string + - type: "null" + summary: + anyOf: + - type: string + - type: "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: + anyOf: + - type: string + - type: "null" + committedRecordType: + anyOf: + - type: string + - type: "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: {} + 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 + required: + - day + - nutrient + - amount + - unit + - source + additionalProperties: {} + healthProfile: + default: null + anyOf: + - type: object + properties: + id: + type: string + minLength: 1 + aboutMe: + default: null + anyOf: + - type: string + - type: "null" + conditions: + default: null + anyOf: + - type: string + - type: "null" + allergies: + default: null + anyOf: + - type: string + - type: "null" + coachFocus: + default: null + anyOf: + - type: string + - type: "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 + 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 + anyOf: + - type: string + - type: "null" + emergencyImplants: + default: null + anyOf: + - type: string + - type: "null" + emergencyNote: + default: null + anyOf: + - type: string + - type: "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 + value: + default: null + anyOf: + - type: string + - type: "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 + anyOf: + - type: number + - type: "null" + targetHigh: + default: null + anyOf: + - type: number + - type: "null" + decimals: + default: null + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + description: + default: null + anyOf: + - type: string + - type: "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 + anyOf: + - type: string + - 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 + anyOf: + - type: number + - type: "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 + 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 + timezone: + type: string + minLength: 1 + createdAt: + type: string + minLength: 1 + updatedAt: + type: string + minLength: 1 + required: + - type + - dateKey + - hourlyCumulative + - dayTotal + - sampleCount + - 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: {} + practitioners: + default: [] + type: array + items: + type: object + properties: + id: + type: string + minLength: 1 + name: + type: string + minLength: 1 + specialty: + anyOf: + - type: string + - type: "null" + practice: + anyOf: + - type: string + - type: "null" + location: + anyOf: + - type: string + - type: "null" + phone: + anyOf: + - type: string + - type: "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: + - 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 + practitionerId: + anyOf: + - type: string + - type: "null" + reasonEncrypted: + anyOf: + - type: string + minLength: 1 + - type: "null" + outcomeEncrypted: + anyOf: + - type: string + minLength: 1 + - type: "null" + reminderId: + anyOf: + - type: string + - type: "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: {} + vaccinations: + default: [] + type: array + items: + type: object + properties: + id: + type: string + minLength: 1 + occurredAt: + type: string + minLength: 1 + antigenSlug: + anyOf: + - type: string + - type: "null" + vaccineName: + anyOf: + - type: string + - type: "null" + doseNumber: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + seriesDoses: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + lotNumber: + anyOf: + - type: string + - type: "null" + site: + anyOf: + - type: string + enum: + - LEFT_ARM + - RIGHT_ARM + - LEFT_THIGH + - RIGHT_THIGH + - ORAL + - NASAL + - OTHER + - type: "null" + practitionerId: + anyOf: + - type: string + - type: "null" + encounterId: + anyOf: + - type: string + - type: "null" + reminderId: + anyOf: + - type: string + - type: "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: + - 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: {} + coachConversations: + default: [] + type: array + items: + type: object + properties: + id: + type: string + minLength: 1 + title: + type: string + documentScoped: + type: boolean + summaryEncrypted: + anyOf: + - type: string + minLength: 1 + - type: "null" + summary: + anyOf: + - type: string + - type: "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: + anyOf: + - type: string + - type: "null" + providerType: + anyOf: + - type: string + - type: "null" + promptVersion: + anyOf: + - type: string + - type: "null" + tokensUsed: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + model: + anyOf: + - type: string + - type: "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 + - title + - 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 + locale: + type: string + minLength: 1 + 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: + anyOf: + - type: string + - type: "null" + source: + type: string + externalId: + anyOf: + - type: string + - 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: + - 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: + anyOf: + - type: string + - type: "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: + anyOf: + - type: string + - type: "null" + outcomeEncrypted: + anyOf: + - type: string + minLength: 1 + - type: "null" + outcome: + anyOf: + - type: string + - type: "null" + status: + type: string + reviewDate: + anyOf: + - type: string + minLength: 1 + - type: "null" + sourceConversationId: + anyOf: + - type: string + - type: "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: + anyOf: + - type: string + - type: "null" + relatedPlanId: + anyOf: + - type: string + - type: "null" + triggerKind: + type: string + dueAt: + anyOf: + - type: string + minLength: 1 + - type: "null" + contextCue: + anyOf: + - type: string + - type: "null" + status: + type: string + source: + type: string + minLength: 1 + sourceConversationId: + anyOf: + - type: string + - type: "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 + - type: "null" + intervalDays: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + rrule: + anyOf: + - type: string + - type: "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: + anyOf: + - type: string + - type: "null" + nextDueAt: + anyOf: + - type: string + minLength: 1 + - type: "null" + lastSatisfiedAt: + anyOf: + - type: string + minLength: 1 + - type: "null" + enabled: + type: boolean + vaccinationAntigen: + anyOf: + - type: string + - type: "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 + metricSlot: + anyOf: + - type: string + - type: "null" + direction: + type: string + enum: + - MAX + - MIN + value: + type: number + unit: + type: string + minLength: 1 + achievedAt: + type: string + minLength: 1 + sourceMeasurementId: + anyOf: + - type: string + - type: "null" + source: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + externalId: + anyOf: + - type: string + - type: "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 + lon: + type: number + locationLabel: + type: string + source: + type: string + enum: + - HOME + - TRAVEL + - DEVICE + tempMin: + anyOf: + - type: number + - type: "null" + tempMax: + anyOf: + - type: number + - type: "null" + tempMean: + anyOf: + - type: number + - type: "null" + apparentMean: + anyOf: + - type: number + - type: "null" + sunshineSec: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + daylightSec: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + precipSum: + anyOf: + - type: number + - type: "null" + pressureMean: + anyOf: + - type: number + - type: "null" + pressureDelta: + anyOf: + - type: number + - type: "null" + humidityMean: + anyOf: + - type: number + - type: "null" + cloudMean: + anyOf: + - type: number + - type: "null" + weatherCode: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - 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 + lon: + type: number + label: + type: string + createdAt: + type: string + minLength: 1 + updatedAt: + type: string + minLength: 1 + required: + - startDate + - endDate + - lat + - lon + - label + 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 + 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: + anyOf: + - type: number + - type: "null" + lead: + anyOf: + - type: string + - type: "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 + - measurements + - medications + - intakeEvents + - moodEntries + - cycleProfile + - cycles + - cycleDayLogs + - customSymptoms + - customMoodTagCategories + - hiddenMoodTags + - customMoodTags + - labResults + - biomarkers + - illnessEpisodes + - allergies + - familyHistory + - workouts + - documents + - documentConditionLinks + - extractedFacts + - nutrientDays + - healthProfile + - healthProfileFacts + - customMetrics + - correlationPatterns + - intradayProfiles + - healthScoreRecords + - practitioners + - encounters + - encounterDocumentLinks + - encounterLabLinks + - encounterConditionLinks + - vaccinations + - vaccinationDocumentLinks + - coachConversations + - mentalHealthAssessments + - consentReceipts + - coachFacts + - coachPlans + - coachReminders + - measurementReminders + - measurementReminderEvents + - personalRecords + - userAchievements + - environmentContexts + - environmentTravelLocations + - 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`." CheckUserEnvelope: type: object properties: @@ -21989,6 +35770,578 @@ components: 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: + anyOf: + - type: string + - type: "null" + description: Operator-set label for the SSO button (`OIDC_BUTTON_LABEL`, defaulting to “Single Sign-On”). Null when no + provider is configured. + 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. + required: + - registrationEnabled + 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. + required: + - id + - name + - permissions + - lastUsedAt + - expiresAt + - createdAt + - revoked + additionalProperties: false + 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 + 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: + anyOf: + - type: string + - type: "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: + anyOf: + - type: string + - type: "null" + role: + type: string + heightCm: + anyOf: + - type: number + - type: "null" + dateOfBirth: + anyOf: + - type: string + - type: "null" + gender: + anyOf: + - type: string + - type: "null" + timezone: + anyOf: + - type: string + - type: "null" + fullName: + anyOf: + - type: string + - type: "null" + insurerName: + anyOf: + - type: string + - type: "null" + insurerIkNumber: + anyOf: + - type: string + - type: "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 failed validation and 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." + type: array + items: + type: object + properties: + path: + type: string + code: + type: string + message: + type: string + required: + - path + - code + - message + additionalProperties: false + 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: @@ -24221,6 +38574,181 @@ components: 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." + ListPersonalRecordsEnvelope: + type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/PersonalRecord" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + 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: + 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 + metricSlot: + anyOf: + - type: string + - type: "null" + 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. + 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: + anyOf: + - type: string + - type: "null" + 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. + source: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + externalId: + anyOf: + - type: string + - 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 + - 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. ListWorkoutsResponse: type: object properties: @@ -24825,6 +39353,948 @@ components: - 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. + 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: + anyOf: + - type: string + - 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)))$ + 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: + anyOf: + - type: string + - type: "null" + description: Free-text dose actually taken when it differed from the scheduled dose (titration weeks, split doses). NULL + = the scheduled dose. + 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: + anyOf: + - type: string + - type: "null" + 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. + 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 + - 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: + anyOf: + - type: string + - type: "null" + description: Recorded site, or null when none was captured. + 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: + anyOf: + - type: string + - type: "null" + description: Titration note, decrypted on read. Null when none was recorded. + 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: + anyOf: + - type: string + - type: "null" + 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. + 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: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + 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: + anyOf: + - type: string + - type: "null" + description: The free-text note, decrypted on read. Stored encrypted; the ciphertext column is stripped before the row + leaves the server. + 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: @@ -25509,127 +40979,6 @@ components: - 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. - 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: - anyOf: - - type: string - - 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)))$ - 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: - anyOf: - - type: string - - type: "null" - description: Free-text dose actually taken when it differed from the scheduled dose (titration weeks, split doses). NULL - = the scheduled dose. - 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: - anyOf: - - type: string - - type: "null" - 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. - 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 - - 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). CreateMedicationIntakeResponse: type: object properties: @@ -27537,6 +42886,164 @@ components: 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: + anyOf: + - type: string + - type: "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: @@ -27648,6 +43155,30 @@ components: 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: @@ -29945,8 +45476,10 @@ components: - insurerIkNumber - hasInsuranceNumber additionalProperties: false - description: Profile echo returned by the PATCH/PUT update path. Reports KVNR presence as a boolean rather than - re-emitting the decrypted value. + 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: @@ -30133,6 +45666,588 @@ components: 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 + 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: + anyOf: + - type: string + - type: "null" + locale: + anyOf: + - type: string + - type: "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 + 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 + 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: @@ -30443,6 +46558,32 @@ components: additionalProperties: false description: Provenance envelope attached to an assistant message — labels and counts only, plus the optional evidence key-values. No raw timestamps. + 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: @@ -31638,6 +47779,43 @@ components: - 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: @@ -33615,6 +49793,2124 @@ components: additionalProperties: false description: Acknowledgement that a full assessment warm was enqueued for the calling user. 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: + anyOf: + - type: number + - type: "null" + description: Derived from `summaries.WEIGHT.latest` and the profile height; null when either is missing. + bpInTargetPct: + anyOf: + - type: number + - type: "null" + description: Trailing 90 days — the headline the BP tile shows. + bpInTargetPct7d: + anyOf: + - type: number + - type: "null" + bpInTargetPct30d: + anyOf: + - type: number + - type: "null" + bpInTargetPctAllTime: + anyOf: + - type: number + - type: "null" + bpInTargetPctPriorMonth: + anyOf: + - type: number + - type: "null" + bpInTargetPctPriorYear: + anyOf: + - type: number + - type: "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) over the trailing 30 days, + canonical mg/dL. Contexts 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: + anyOf: + - type: string + - type: "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: + anyOf: + - type: number + - type: "null" + min: + anyOf: + - type: number + - type: "null" + max: + anyOf: + - type: number + - type: "null" + mean: + anyOf: + - type: number + - type: "null" + median: + anyOf: + - type: number + - type: "null" + description: 50th percentile over the trailing 90 days on this route (the SQL summaries path fixes that window). + Linear-interpolated midpoint. + avg7: + anyOf: + - type: number + - type: "null" + avg30: + anyOf: + - type: number + - type: "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. + anyOf: + - type: number + - type: "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'. + anyOf: + - type: number + - type: "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. + 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: + anyOf: + - type: number + - type: "null" + description: Move against the previous window, or null when none forms. + 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" + 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. + 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 + unit: + type: string + label: + type: string + description: The complete display value, paired or panel values included. + 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: + anyOf: + - type: number + - type: "null" + high: + anyOf: + - type: number + - type: "null" + label: + type: string + 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: + anyOf: + - type: number + - type: "null" + high: + anyOf: + - type: number + - type: "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: + anyOf: + - type: number + - type: "null" + description: Positive means the distance to the personal band narrowed. + 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: + anyOf: + - type: string + - type: "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. + required: + - summaries + - bmi + - lastSeenByType + - sleepSourceDiscrepancy + additionalProperties: false + description: "What `?slice=summaries` answers: the per-type summaries and their freshness, and nothing thick. Four 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, verbatim from the account's cache row. 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: + anyOf: + - type: string + - type: "null" + 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. + 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`. + 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: + anyOf: + - type: string + - type: "null" + 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. + cachedActiveProvider: + anyOf: + - type: string + - type: "null" + 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." + 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: Today's latest reading, same units. + placement: + type: string + enum: + - within + - above + - below + sampleDays: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Distinct days behind the band, for transparency. + required: + - low + - high + - latest + - 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. + CoachSeededQuestionEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/CoachSeededQuestion" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CoachSeededQuestion: + type: object + properties: + signal: + anyOf: + - type: object + properties: + sourceMetric: + type: string + description: Sentinel id the client keys its localised copy on (`readiness` / `recovery`). + score: + type: number + description: The latest 0..100 score. + band: + type: string + description: "`yellow` or `red`. Green never surfaces — a good day is not notable." + required: + - sourceMetric + - score + - band + additionalProperties: false + - type: "null" + description: Null whenever nothing crossed the detector's confidence and notability gate, AND whenever the account has + turned proactive suggestions off — the two are indistinguishable on the wire, and both mean the hero keeps + its neutral greeting rather than showing a fabricated opener. + required: + - signal + additionalProperties: false + description: Today's single most notable derived wellness signal, resolved server-side into a tappable opener for the + Coach's blank-chat hero. The client renders the resolved DTO and never recomputes the selection. + 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. + 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." + required: + - period + - locale + - narrative + - revalidating + 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. + anyOf: + - type: string + - type: "null" + injectionSite: + anyOf: + - type: string + - type: "null" + inventoryDelta: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + reason: + type: string + tags: + description: The recognised side-effect tags picked up that day. Matched against a FIXED bilingual list of English and + German tag strings, so a tag written in any other language — or spelled differently — does not appear + here at all. + type: array + items: + type: string + 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." + required: + - hasGlp1 + - entries + 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: + anyOf: + - type: number + - type: "null" + description: The resting baseline the day was judged against. + 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: + anyOf: + - type: number + - type: "null" + average30: + anyOf: + - type: number + - type: "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: + anyOf: + - type: number + - type: "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: + anyOf: + - type: number + - type: "null" + average30: + anyOf: + - type: number + - type: "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: + anyOf: + - type: string + - type: "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 + 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: @@ -34130,70 +52426,6 @@ components: 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. - 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 DerivedAssessment: type: object properties: @@ -35294,6 +53526,1043 @@ components: - 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: + anyOf: + - type: number + - type: "null" + min: + anyOf: + - type: number + - type: "null" + max: + anyOf: + - type: number + - type: "null" + mean: + anyOf: + - type: number + - type: "null" + median: + anyOf: + - type: number + - type: "null" + avg7: + anyOf: + - type: number + - type: "null" + avg30: + anyOf: + - type: number + - type: "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: + anyOf: + - type: number + - type: "null" + avg30LastYear: + anyOf: + - type: number + - type: "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: + anyOf: + - type: number + - type: "null" + min: + anyOf: + - type: number + - type: "null" + max: + anyOf: + - type: number + - type: "null" + latest: + anyOf: + - type: number + - type: "null" + delta: + anyOf: + - type: number + - type: "null" + description: Latest daily mean minus the previous daily mean. + inTargetPct: + anyOf: + - type: number + - type: "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: + anyOf: + - type: number + - type: "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: + anyOf: + - type: number + - type: "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: Null for a sparse logger below the day floor. + 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. + anyOf: + - type: string + - type: "null" + icon: + anyOf: + - type: string + - type: "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: + anyOf: + - type: string + - type: "null" + label: + anyOf: + - type: string + - type: "null" + categoryKey: + anyOf: + - type: string + - type: "null" + icon: + anyOf: + - type: string + - type: "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: + anyOf: + - type: number + - type: "null" + description: Raw mood-point delta for a tag row; null for a metric row. + r: + anyOf: + - type: number + - type: "null" + description: Raw Pearson r for a metric row; null for a tag row. + 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: + anyOf: + - type: string + - type: "null" + categoryKey: + type: string + icon: + anyOf: + - type: string + - type: "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: + anyOf: + - type: string + - type: "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. + 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." + required: + - summary + - heatmap + - distribution + - dimensions + - weekday + - timeOfDay + - stability + - 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: + anyOf: + - type: number + - type: "null" + avg30: + anyOf: + - type: number + - type: "null" + avg90: + anyOf: + - type: number + - type: "null" + description: Trailing-window means; null when that window is empty. + latest: + anyOf: + - type: number + - type: "null" + latestDate: + anyOf: + - type: string + - type: "null" + description: "`YYYY-MM-DD` of `latest`." + 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: + anyOf: + - type: string + - type: "null" + description: i18n label key for a structured tag; null for a flat one. + label: + description: Decrypted custom-tag label. Absent for catalogue and flat tags — the key is absent, not null. + anyOf: + - type: string + - type: "null" + categoryKey: + anyOf: + - type: string + - type: "null" + icon: + anyOf: + - type: string + - type: "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: + anyOf: + - type: number + - type: "null" + 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. + 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 MoodPrognosisEnvelope: type: object properties: @@ -36514,6 +55783,191 @@ components: 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: + anyOf: + - type: string + - type: "null" + description: The stored chat id, returned in full. It is an addressing value, not a credential — the bot token is what + authorises a send. + 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. + required: + - enabled + - url + - headerName + - hasHeaderValue + 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: @@ -36567,6 +56021,327 @@ components: 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 + required: + - deleted + 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. + required: + - cleared + - deletedRows + - models + 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. + required: + - coachMessagesDays + - auditLogDays + - deliveryLogDays + 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: @@ -36672,6 +56447,176 @@ components: - kind additionalProperties: false description: Outcome of the idempotent web `ai_full` grant. Safe to call on every AI-settings mount. + 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 + 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 + 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" + required: + - ai_full + - ai_insights_only + - ai_coach + additionalProperties: false + description: "The latest active receipt for every kind. All three keys are 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 + 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 CsvImportResponseEnvelope: type: object properties: @@ -36805,6 +56750,122 @@ components: - 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: + anyOf: + - type: string + - type: "null" + description: Free text on `failed`; null otherwise. + 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: @@ -41732,6 +61793,66 @@ components: - data - error additionalProperties: false + OnboardingStepEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/OnboardingStepResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + OnboardingStepResponse: + type: object + properties: + step: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: The stored step after the write. + onboardingCompletedAt: + 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: Non-null once step 4 landed. + required: + - step + - onboardingCompletedAt + additionalProperties: false + OnboardingCompleteEnvelope: + type: object + properties: + data: + type: object + properties: + completed: + type: boolean + const: true + 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: @@ -42475,6 +62596,1779 @@ components: - truncated - reasonCode 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: + type: string + enum: + - connected + - error_transient + - error_reauth + - disconnected + - parked + description: Last recorded ledger state for this provider. `connected` is also the value a user who has never synced + sees — the ledger row is created on the first attempt, and its absence reads as `connected`, not as an + error. + 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: + description: Last recorded failure message, decrypted from the ledger. Null when the last attempt succeeded or none has + run. + anyOf: + - type: string + - type: "null" + syncHealth: + $ref: "#/components/schemas/SyncHealth" + 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: + description: 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. + anyOf: + - type: string + - 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: + description: 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. + anyOf: + - type: string + - type: "null" + hasActivityScope: + description: Pre-computed convenience flag the reconnect banner reads. A null `scope` counts as missing activity. + type: boolean + 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: + description: 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. + anyOf: + - type: string + - 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: + description: 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. + anyOf: + - type: string + - type: "null" + needsReauth: + description: "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: + type: string + enum: + - connected + - error_transient + - error_reauth + - disconnected + - parked + description: Last recorded ledger state for this provider. `connected` is also the value a user who has never synced + sees — the ledger row is created on the first attempt, and its absence reads as `connected`, not as an + error. + 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: + anyOf: + - type: string + - type: "null" + syncHealth: + $ref: "#/components/schemas/SyncHealth" + 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 all + eight providers: 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 eight providers, always, in the fixed order withings, whoop, fitbit, google-health, polar, oura, + strava, nightscout. A provider the user has never touched still appears, with `connected: false` and a + synthesised `connected` ledger state — 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 eight 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 + description: Which provider this entry describes. + state: + type: string + enum: + - connected + - error_transient + - error_reauth + - disconnected + - parked + description: Last recorded ledger state for this provider. `connected` is also the value a user who has never synced + sees — the ledger row is created on the first attempt, and its absence reads as `connected`, not as an + error. + 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: + anyOf: + - type: string + - type: "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" + description: 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: + $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: + description: Null — not false — when there is no connection to judge. + anyOf: + - type: boolean + - type: "null" + backfillCompleted: + anyOf: + - type: boolean + - type: "null" + scope: + description: Withings only. + anyOf: + - type: string + - type: "null" + hasActivityScope: + description: Withings only. + type: boolean + needsReauth: + description: Google Health only. + type: boolean + available: + description: Oura / Polar / Strava only. + type: boolean + hasOwnCredentials: + description: Oura / Polar / Strava only. + type: boolean + hasToken: + description: Nightscout only. + type: boolean + allowPrivateHost: + description: Nightscout only. + type: boolean + required: + - integration + - state + - lastSuccessAt + - lastAttemptAt + - lastError + - consecutiveFailuresByKind + - syncHealth + - metricFreshness + - connected + - configured + 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." + 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: @@ -42721,6 +64615,294 @@ components: - 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: + anyOf: + - type: number + - type: "null" + 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. + secondaryValue: + anyOf: + - type: number + - type: "null" + description: Diastolic value on the `bloodPressure` card; null on every other kind. + unitKey: + type: string + description: i18n key for the display unit, resolved the same way. + unit: + anyOf: + - type: string + - type: "null" + 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. + 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: + anyOf: + - type: string + - type: "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 day START + of the latest daily bucket, not a reading time — the series pins a day, so inventing an instant would be a + lie. 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. + 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: @@ -43814,6 +65996,1846 @@ components: 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 + 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. + required: + - channels + - preferences + - eventTypes + 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 + 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 the + server-side default for that category, which is ON for most and OFF for the opt-in ones (personal records, mood + and cycle reminders). 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: + anyOf: + - type: string + - type: "null" + description: Why the dispatcher auto-disabled the channel; null when it did not. This is the field that separates + `auto_disabled` from `manually_disabled`. + 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: + anyOf: + - type: string + - type: "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: + anyOf: + - type: string + - type: "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" + required: + - integration + - state + - lastSuccessAt + - lastAttemptAt + 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 nothing else. Deliberately no control and no + credential projection — OAuth and provider state cannot safely carry a record selector, so a Guardian can see + that a pipe exists but cannot connect, disconnect, probe or resume it from here. 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. + 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: + anyOf: + - type: string + - type: "null" + heightCm: + anyOf: + - type: number + - 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" + 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 + - es + - fr + - it + - pl + - type: "null" + timezone: + type: string + unitPreference: + type: string + enum: + - metric + - imperial + 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" + 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." + HealthPublicResponse: + type: object + properties: + status: + $ref: "#/components/schemas/HealthStatus" + 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." + 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." + 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: + anyOf: + - type: string + - type: "null" + description: Day key of the newest stored observation. + 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. + required: + - home + - travel + - context + - lastFetchFailure + - attribution + 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 2 dp (~1 km). + lon: + type: number + description: Coarse longitude, rounded to 2 dp. + label: + anyOf: + - type: string + - type: "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 2 dp. + lon: + type: number + description: Coarse longitude, rounded to 2 dp. + 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 + __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: diff --git a/src/__tests__/openapi-security-declaration-guard.test.ts b/src/__tests__/openapi-security-declaration-guard.test.ts index 9677c09c1..66740a788 100644 --- a/src/__tests__/openapi-security-declaration-guard.test.ts +++ b/src/__tests__/openapi-security-declaration-guard.test.ts @@ -52,6 +52,12 @@ const HTTP_METHODS = ["get", "post", "put", "patch", "delete"] as const; * merely sits on the proxy's public-path list does not belong here. */ const UNAUTHENTICATED: Readonly> = { + // `/api/auth/oidc/callback` is deliberately absent. It is anonymous, but it + // is not published at all: the provider drives it with its own query string + // and no client constructs one, so it is exempted from the contract by the + // coverage guard rather than published with an opt-out. This test is what + // caught the entry when it was written from the proxy's public-path list + // instead of from the published set. "/api/health": "Container healthcheck. Answers before any user exists and outside the response envelope.", "/api/version": @@ -68,8 +74,6 @@ const UNAUTHENTICATED: Readonly> = { "Completes the passkey sign-in; the assertion is the credential.", "/api/auth/oidc/login": "Starts the identity-provider handoff, for the browser and for the native client.", - "/api/auth/oidc/callback": - "The provider drives this one with its own query string.", "/api/auth/oidc/status": "Says whether single sign-on is configured, so the sign-in screen can offer it.", "/api/notifications/vapid": diff --git a/src/lib/openapi/routes/auth.ts b/src/lib/openapi/routes/auth.ts index 8d9ebd76f..ef464a1b1 100644 --- a/src/lib/openapi/routes/auth.ts +++ b/src/lib/openapi/routes/auth.ts @@ -618,6 +618,12 @@ export const authPaths: NonNullable = { }, "/api/auth/login": { post: { + // No credential: this operation is reachable before one exists. + // The document-level default offers the Bearer token and the session + // cookie as alternatives; an empty array is how OpenAPI says neither + // is required. The list of paths allowed to say it lives in + // `openapi-security-declaration-guard.test.ts`. + security: [], tags: ["Auth"], summary: "Email-or-username login (password)", description: @@ -1124,6 +1130,12 @@ export const authPaths: NonNullable = { }, "/api/auth/passkey/login-verify": { post: { + // No credential: this operation is reachable before one exists. + // The document-level default offers the Bearer token and the session + // cookie as alternatives; an empty array is how OpenAPI says neither + // is required. The list of paths allowed to say it lives in + // `openapi-security-declaration-guard.test.ts`. + security: [], tags: ["Auth"], summary: "Passkey assertion verification", requestBody: { @@ -1397,6 +1409,12 @@ export const authPaths: NonNullable = { }, "/api/auth/oidc/status": { get: { + // No credential: this operation is reachable before one exists. + // The document-level default offers the Bearer token and the session + // cookie as alternatives; an empty array is how OpenAPI says neither + // is required. The list of paths allowed to say it lives in + // `openapi-security-declaration-guard.test.ts`. + security: [], tags: ["Auth"], summary: "Whether SSO is configured, and whether it is the only way in", description: @@ -1415,6 +1433,12 @@ export const authPaths: NonNullable = { }, "/api/auth/registration-status": { get: { + // No credential: this operation is reachable before one exists. + // The document-level default offers the Bearer token and the session + // cookie as alternatives; an empty array is how OpenAPI says neither + // is required. The list of paths allowed to say it lives in + // `openapi-security-declaration-guard.test.ts`. + security: [], tags: ["Auth"], summary: "Whether self-registration is open", description: @@ -1437,6 +1461,12 @@ export const authPaths: NonNullable = { }, "/api/auth/passkey/login-options": { post: { + // No credential: this operation is reachable before one exists. + // The document-level default offers the Bearer token and the session + // cookie as alternatives; an empty array is how OpenAPI says neither + // is required. The list of paths allowed to say it lives in + // `openapi-security-declaration-guard.test.ts`. + security: [], tags: ["Auth"], summary: "Begin a passkey sign-in (discoverable credentials)", description: @@ -1582,6 +1612,12 @@ export const authPaths: NonNullable = { // the Codex device-auth flow. "/api/auth/register": { post: { + // No credential: this operation is reachable before one exists. + // The document-level default offers the Bearer token and the session + // cookie as alternatives; an empty array is how OpenAPI says neither + // is required. The list of paths allowed to say it lives in + // `openapi-security-declaration-guard.test.ts`. + security: [], tags: ["Auth"], summary: "Create an account", description: @@ -1696,6 +1732,12 @@ export const authPaths: NonNullable = { }, "/api/auth/oidc/login": { get: { + // No credential: this operation is reachable before one exists. + // The document-level default offers the Bearer token and the session + // cookie as alternatives; an empty array is how OpenAPI says neither + // is required. The list of paths allowed to say it lives in + // `openapi-security-declaration-guard.test.ts`. + security: [], tags: ["Auth"], summary: "Start an SSO sign-in (browser redirect, or the native leg)", description: diff --git a/src/lib/openapi/routes/health.ts b/src/lib/openapi/routes/health.ts index 21e35b7e7..8d394f38b 100644 --- a/src/lib/openapi/routes/health.ts +++ b/src/lib/openapi/routes/health.ts @@ -61,6 +61,12 @@ const healthAdminResponse = z export const healthPaths: NonNullable = { "/api/health": { get: { + // No credential: this operation is reachable before one exists. + // The document-level default offers the Bearer token and the session + // cookie as alternatives; an empty array is how OpenAPI says neither + // is required. The list of paths allowed to say it lives in + // `openapi-security-declaration-guard.test.ts`. + security: [], tags: ["Meta"], summary: "Liveness / readiness probe", description: diff --git a/src/lib/openapi/routes/meta.ts b/src/lib/openapi/routes/meta.ts index 5ad026e15..08470389a 100644 --- a/src/lib/openapi/routes/meta.ts +++ b/src/lib/openapi/routes/meta.ts @@ -339,6 +339,12 @@ export const metaPaths: NonNullable = { // ── Appended: two discovery reads the registry never carried. "/api/version": { get: { + // No credential: this operation is reachable before one exists. + // The document-level default offers the Bearer token and the session + // cookie as alternatives; an empty array is how OpenAPI says neither + // is required. The list of paths allowed to say it lives in + // `openapi-security-declaration-guard.test.ts`. + security: [], tags: ["Meta"], summary: "The running build's version, and how it was built", description: diff --git a/src/lib/openapi/routes/notifications-transport.ts b/src/lib/openapi/routes/notifications-transport.ts index e364f0754..238b4bf30 100644 --- a/src/lib/openapi/routes/notifications-transport.ts +++ b/src/lib/openapi/routes/notifications-transport.ts @@ -102,6 +102,12 @@ export const notificationTransportPaths: NonNullable< > = { "/api/notifications/vapid": { get: { + // No credential: this operation is reachable before one exists. + // The document-level default offers the Bearer token and the session + // cookie as alternatives; an empty array is how OpenAPI says neither + // is required. The list of paths allowed to say it lives in + // `openapi-security-declaration-guard.test.ts`. + security: [], tags: ["Notifications"], summary: "Read the instance's VAPID public key", description: