diff --git a/.dockerignore b/.dockerignore index daa14cbc6..1901f009e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -28,6 +28,12 @@ playwright/.cache playwright.config.ts vitest.config.mts vitest.integration.config.mts +vitest.mutation.config.mts +# The unit setup imports tests/ (excluded above); the image never runs it and +# the build's type check must not see a half-present test harness. +vitest.setup.ts +stryker.config.mjs +stryker.floors.json tests tests/ e2e diff --git a/.github/ISSUE_TEMPLATE/translation.yml b/.github/ISSUE_TEMPLATE/translation.yml index 4e481ce85..331815c1d 100644 --- a/.github/ISSUE_TEMPLATE/translation.yml +++ b/.github/ISSUE_TEMPLATE/translation.yml @@ -1,16 +1,16 @@ name: Translation feedback -description: Report a wording, grammar, or register problem in HealthLog's AI-initial locales (FR / ES / IT / PL / KO / NL). +description: Report a wording, grammar, or register problem in HealthLog's community-maintained locales (FR / ES / IT / PL / KO / NL / PT-BR). title: "[Translation] " labels: ["i18n", "translation-feedback"] body: - type: markdown attributes: value: | - HealthLog ships German and English as maintainer-curated translations. French, Spanish, Italian, Polish, Korean, and Dutch ship as **AI-initial translations** — the prose passes the locale-integrity test (full key parity, no empty values) but has not been hand-reviewed for register or regional vocabulary. + HealthLog ships German and English as maintainer-curated translations. French, Spanish, Italian, Polish, Korean, Dutch, and Brazilian Portuguese ship as **AI-initial, community-maintained translations** — the prose passes the locale-integrity test (full key parity, no empty values) but has not been hand-reviewed for register or regional vocabulary. This template captures concrete fixes you would like to see. Phrasing nuance, regional vocabulary (e.g. "Tú" vs "Usted"), gendered forms, and domain-specific health terms are all in scope. The maintainer applies fixes during the regular release cycle. - For wholly new locale requests (e.g. "please add Portuguese"), open a [Discussion](https://github.com/MBombeck/HealthLog/discussions) instead. + For wholly new locale requests (e.g. "please add Swedish"), open a [Discussion](https://github.com/MBombeck/HealthLog/discussions) instead. - type: dropdown id: locale attributes: @@ -22,6 +22,7 @@ body: - Polish (pl) - Korean (ko) - Dutch (nl) + - Brazilian Portuguese (pt-BR) - Other (specify under "Surface") validations: required: true diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 743788fac..3710d15fc 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -95,11 +95,12 @@ jobs: - run: NODE_OPTIONS=--max-old-space-size=8192 pnpm build # Client-bundle budget gate — rides this job because it is the one - # place CI already has a production build on disk. Catches the - # regressions the dead webpack-era tooling (ANALYZE=1, the old - # bundle-report) let through: a statically imported message catalog - # (~100 KB gz on every route) or a chart import bypassing the shared - # chart-runtime boundary (a duplicated ~92 KB gz recharts chunk). + # place CI already has a production build on disk. Measures the first + # load of every page (files deduplicated by content hash) against its + # baseline, the p95 and max across pages, and the distinct client code + # (modules deduplicated by id); see the script header. Catches a + # statically imported message catalog, a duplicated recharts chunk, or + # a heavy module (Zod) pulled into a page that had none. - run: pnpm bundle-check - run: pnpm exec playwright test --shard=${{ matrix.shard }}/3 diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 789810a8d..a0b66a61f 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -10,20 +10,23 @@ permissions: contents: read jobs: - # The suite outgrew a single runner, then a half-suite outgrew a slow one, - # and then a third of it did too. Three shards of about 68 files cleared in - # 13 minutes on an ordinary runner, but a slow runner day ran every test at - # twice its usual duration and the first shard was still working through - # its list when the 25-minute guard cut it off: three of four pushes to main - # between 2026-08-31 and 2026-09-01 failed that way, with green tests logged - # up to the last second. Four shards keep each slice around 50 files, and - # the guard sits at three times the ordinary run rather than under two. A - # wedged test still surfaces through vitest's own 60-second budget, so the - # job timeout only needs to catch a container that never boots. Each shard - # boots its own Postgres testcontainer. + # Four shards of about equal duration, each running its files in parallel + # against one database per worker. The shards used to be four slices of + # equal file count, run serially: 954 s to 1 449 s, because a slice holding + # the sharing matrix and the backup files took half again as long as one + # holding small files. `--shard` now goes through the duration sequencer + # (`tests/durations/sequencer.ts`, timings in + # `tests/durations/integration.json`), and every file in a shard runs on + # one of three workers, each against its own clone of the migrated template + # (`tests/integration/worker-database.ts`). Three workers leave a core of + # the four-core runner to the Postgres container. The job guard stays well + # above an ordinary run; a wedged test still surfaces through vitest's own + # 60-second budget, so the guard only needs to catch a container that never + # boots. Each shard boots its own Postgres testcontainer and uploads its + # JSON report, which `scripts/test-durations.mjs` turns into the timing file. shard: runs-on: ubuntu-24.04 - timeout-minutes: 40 + timeout-minutes: 30 strategy: fail-fast: false matrix: @@ -48,8 +51,13 @@ jobs: # vitest a stray positional argument and the shard flag never applies — # both "shards" then run the full suite. Proven by a paired local run # before this workflow shipped. - - run: pnpm exec vitest run --config vitest.integration.config.mts --shard=${{ matrix.shard }}/4 + - run: >- + pnpm exec vitest run --config vitest.integration.config.mts + --shard=${{ matrix.shard }}/4 + --reporter=default --reporter=json + --outputFile.json=integration-report-${{ matrix.shard }}.json env: + HEALTHLOG_INTEGRATION_WORKERS: "3" # The integration suite boots a Postgres testcontainer and points # Prisma at it via DATABASE_URL set inside `tests/integration/setup.ts`. # These env vars satisfy code paths that read them at module load. @@ -63,6 +71,15 @@ jobs: ENCRYPTION_KEY: "0000000000000000000000000000000000000000000000000000000000000000" SESSION_SECRET: "integration-tests" + - name: Upload the timing report + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: integration-report-${{ matrix.shard }} + path: integration-report-${{ matrix.shard }}.json + if-no-files-found: ignore + retention-days: 14 + # Branch protection requires a check named exactly "integration". This job # carries that name and answers for every shard: it fails when any shard # failed or was cancelled, so a half-green matrix cannot satisfy the gate. diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml new file mode 100644 index 000000000..a49998145 --- /dev/null +++ b/.github/workflows/mutation.yml @@ -0,0 +1,53 @@ +name: Mutation tests + +# Stryker on the core libraries (stryker.config.mjs): encryption at rest, +# medication compliance, rollups, source ranking, unit conversion and backup +# validation. Too slow for a pull request; it runs once a night and on +# demand, and fails when a module's score drops below its floor in +# stryker.floors.json. The HTML report is uploaded either way. +on: + schedule: + - cron: "23 1 * * *" + workflow_dispatch: + +permissions: + contents: read + +jobs: + mutation: + runs-on: ubuntu-24.04 + timeout-minutes: 120 + name: mutation (core libraries) + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 22 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - run: pnpm db:generate + + - name: Run Stryker + run: pnpm exec stryker run --concurrency 4 + + # Without pipefail the step takes the exit status of `tee`, which is + # always 0, so a module under its floor could never fail the run. + - name: Check the per-module floors + shell: bash + run: | + set -o pipefail + node scripts/mutation-floors.mjs | tee -a "$GITHUB_STEP_SUMMARY" + + - name: Upload the report + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: mutation-report + path: reports/mutation/ + if-no-files-found: ignore + retention-days: 30 diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index ed3843be4..406816ccc 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -15,11 +15,72 @@ permissions: contents: read jobs: - quality: - name: Lint, Typecheck & Test + # The static checks: everything in the old single quality job except the + # unit suite, which runs sharded below. + static: + name: Lint, Typecheck & OpenAPI runs-on: ubuntu-24.04 if: github.event_name != 'schedule' + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 22 + cache: "pnpm" + + - run: pnpm install --frozen-lockfile + + - run: pnpm db:generate + + # Blocking since v1.15.20 — the settings-split refactor that kept + # this warn-only is long done and the tree lints clean. + - name: ESLint + run: pnpm lint + + # Prettier formatting gate. The format pass excludes generator-owned + # artifacts via .prettierignore (notably docs/api/openapi.yaml, which + # the OpenAPI drift check below enforces byte-for-byte). + - name: Prettier format check + run: pnpm format:check + + - name: TypeScript strict check + run: pnpm typecheck + + # OpenAPI spec drift gate — hard-fail (flipped from warn-only in v1.4.25). + # + # The Zod registry is the source of truth for the public API contract. + # Any route signature change must be reflected in `docs/api/openapi.yaml` + # so downstream consumers (notably the v1.5 iOS Swift codegen) stay in + # lockstep. If this step fails, run `pnpm openapi:generate` locally and + # commit the regenerated spec. + - name: OpenAPI spec drift check + run: | + pnpm openapi:generate + if ! git diff --exit-code docs/api/openapi.yaml; then + echo "::error::OpenAPI drift detected — regenerate the spec via 'pnpm openapi:generate' and commit the result. The Zod registry under src/lib/openapi/ is the source of truth for the public API contract." + exit 1 + fi + echo "::notice::OpenAPI spec in sync with Zod source schemas." + + # The unit suite in three slices of about equal duration. `--shard` goes + # through the duration sequencer in `vitest.config.mts`, which deals files + # out by their measured time (`tests/durations/unit.json`) instead of by + # count. Each slice uploads its JSON report, which is what + # `scripts/test-durations.mjs` regenerates the timing file from. + unit: + runs-on: ubuntu-24.04 + if: github.event_name != 'schedule' + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3] + name: unit shard ${{ matrix.shard }}/3 + steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -38,7 +99,8 @@ jobs: # real browser and measures the boxes. Without a browser here it caught # its own launch failure and passed anyway, so from its first day until # v1.38 it measured nothing on any run of this job. The browser is part - # of the gate now; the test refuses to skip when CI is set. + # of the gate now; the test refuses to skip when CI is set. Every slice + # installs it, because the sequencer decides which slice the file lands in. # # Headless shell only: `chromium.launch()` resolves to # chromium_headless_shell, which is what the failing runs named, and the @@ -54,38 +116,40 @@ jobs: - run: pnpm exec playwright install --only-shell chromium - # Blocking since v1.15.20 — the settings-split refactor that kept - # this warn-only is long done and the tree lints clean. - - name: ESLint - run: pnpm lint - - # Prettier formatting gate. The format pass excludes generator-owned - # artifacts via .prettierignore (notably docs/api/openapi.yaml, which - # the OpenAPI drift check below enforces byte-for-byte). - - name: Prettier format check - run: pnpm format:check - - - name: TypeScript strict check - run: pnpm typecheck - + # vitest directly, not `pnpm test --`: pnpm hands the separator through + # literally and the shard flag would never reach vitest. - name: Unit tests - run: pnpm test + run: >- + pnpm exec vitest run --shard=${{ matrix.shard }}/3 + --reporter=default --reporter=json + --outputFile.json=unit-report-${{ matrix.shard }}.json - # OpenAPI spec drift gate — hard-fail (flipped from warn-only in v1.4.25). - # - # The Zod registry is the source of truth for the public API contract. - # Any route signature change must be reflected in `docs/api/openapi.yaml` - # so downstream consumers (notably the v1.5 iOS Swift codegen) stay in - # lockstep. If this step fails, run `pnpm openapi:generate` locally and - # commit the regenerated spec. - - name: OpenAPI spec drift check + - name: Upload the timing report + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: unit-report-${{ matrix.shard }} + path: unit-report-${{ matrix.shard }}.json + if-no-files-found: ignore + retention-days: 14 + + # Branch protection requires a check named exactly "Lint, Typecheck & Test". + # That used to be one job running everything in turn; it is now this one, + # which answers for the static checks and every unit slice, so a half-green + # set cannot satisfy the gate. + quality: + name: Lint, Typecheck & Test + runs-on: ubuntu-24.04 + needs: [static, unit] + if: always() && github.event_name != 'schedule' + timeout-minutes: 5 + steps: + - name: The static checks and every unit slice finished green run: | - pnpm openapi:generate - if ! git diff --exit-code docs/api/openapi.yaml; then - echo "::error::OpenAPI drift detected — regenerate the spec via 'pnpm openapi:generate' and commit the result. The Zod registry under src/lib/openapi/ is the source of truth for the public API contract." + if [ "${{ needs.static.result }}" != "success" ] || [ "${{ needs.unit.result }}" != "success" ]; then + echo "static: ${{ needs.static.result }}, unit: ${{ needs.unit.result }}" exit 1 fi - echo "::notice::OpenAPI spec in sync with Zod source schemas." # Not on pull requests. `pnpm audit` answers a question about the published # advisory database, not about the diff: the same lockfile passes on Monday diff --git a/.github/workflows/test-nightly.yml b/.github/workflows/test-nightly.yml new file mode 100644 index 000000000..88c1e0a27 --- /dev/null +++ b/.github/workflows/test-nightly.yml @@ -0,0 +1,178 @@ +name: Tests (nightly, shuffled) + +# Looks for what a pull request run cannot see by design. +# +# - Order dependence. Both suites run with files and tests in a random order +# (`--sequence.shuffle`), seeded from the clock and printed at the top of +# the log and in the job summary. A failure that does not reproduce on a +# pull request reproduces here with `--sequence.seed=`. +# - New property-test inputs. Pull requests run every fast-check property +# with a fixed seed; this run picks a fresh one (`FC_SEED`) and five times +# the cases, and fast-check prints the seed and the shrunk counterexample +# on a failure (see tests/property/fast-check.ts). +# - Stale shard timings. The integration reports of this run are merged into +# a regenerated `tests/durations/integration.json`, uploaded as an +# artifact for a maintainer to commit when the shards drift apart. +# +# Not a gate: nothing here blocks a merge. A red run is an issue to open. +on: + schedule: + - cron: "41 2 * * *" + workflow_dispatch: + +permissions: + contents: read + +jobs: + unit: + runs-on: ubuntu-24.04 + timeout-minutes: 45 + name: unit (shuffled) + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 22 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - run: pnpm db:generate + + - name: Cache Playwright headless shell + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.cache/ms-playwright + key: playwright-shell-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + playwright-shell-${{ runner.os }}- + + - run: pnpm exec playwright install --only-shell chromium + + - name: Pick the seeds + run: | + echo "TEST_SEED=$(date +%s)" >> "$GITHUB_ENV" + echo "FC_SEED=$(( $(date +%s%N) % 2147483647 ))" >> "$GITHUB_ENV" + + - name: Unit tests, shuffled + shell: bash + env: + # Five times each property's own case count (tests/property/fast-check.ts). + FC_RUNS_FACTOR: "5" + run: | + set -o pipefail + echo "Order seed ${TEST_SEED}, property seed ${FC_SEED}" | tee -a "$GITHUB_STEP_SUMMARY" + pnpm exec vitest run --sequence.shuffle --sequence.seed="${TEST_SEED}" \ + --reporter=default --reporter=json --outputFile.json=unit-report.json + + - name: Upload the timing report + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: nightly-unit-report + path: unit-report.json + if-no-files-found: ignore + retention-days: 14 + + integration: + runs-on: ubuntu-24.04 + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3, 4] + name: integration shard ${{ matrix.shard }}/4 (shuffled) + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 22 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - run: pnpm db:generate + + # One seed per run, shared by the shards through the run id, so every + # shard shuffles the same way and a failure names one seed to replay. + - name: Pick the seeds + run: | + echo "TEST_SEED=${{ github.run_id }}" >> "$GITHUB_ENV" + echo "FC_SEED=$(( ${{ github.run_id }} % 2147483647 ))" >> "$GITHUB_ENV" + + - name: Integration tests, shuffled + shell: bash + env: + HEALTHLOG_INTEGRATION_WORKERS: "3" + # Four times each property's own case count; a property's timeout + # grows by the same factor (`propertyTimeout`). + FC_RUNS_FACTOR: "4" + # Quoted for the reason given in integration.yml. + API_TOKEN_HMAC_KEY: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + ENCRYPTION_KEY: "0000000000000000000000000000000000000000000000000000000000000000" + SESSION_SECRET: "integration-tests" + run: | + set -o pipefail + echo "Order seed ${TEST_SEED}, property seed ${FC_SEED}" | tee -a "$GITHUB_STEP_SUMMARY" + pnpm exec vitest run --config vitest.integration.config.mts \ + --shard=${{ matrix.shard }}/4 \ + --sequence.shuffle --sequence.seed="${TEST_SEED}" \ + --reporter=default --reporter=json \ + --outputFile.json=integration-report-${{ matrix.shard }}.json + + - name: Upload the timing report + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: nightly-integration-report-${{ matrix.shard }} + path: integration-report-${{ matrix.shard }}.json + if-no-files-found: ignore + retention-days: 14 + + # Merge the timings of this run into fresh timing files. Uploaded, not + # committed: the workflow has read-only access, and a maintainer decides + # whether the drift is worth a commit. + durations: + runs-on: ubuntu-24.04 + needs: [unit, integration] + if: always() + timeout-minutes: 10 + name: regenerate shard timings + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 22 + + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + pattern: nightly-*-report* + path: reports + + - name: Regenerate + shell: bash + run: | + set -o pipefail + shopt -s nullglob + integration=(reports/nightly-integration-report-*/*.json) + unit=(reports/nightly-unit-report/*.json) + if [ ${#integration[@]} -gt 0 ]; then + node scripts/test-durations.mjs integration "${integration[@]}" + fi + if [ ${#unit[@]} -gt 0 ]; then + node scripts/test-durations.mjs unit "${unit[@]}" + fi + git diff --stat -- tests/durations | tee -a "$GITHUB_STEP_SUMMARY" + + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: test-durations + path: tests/durations/*.json + retention-days: 14 diff --git a/.gitignore b/.gitignore index 9cd46eebf..655bb269e 100644 --- a/.gitignore +++ b/.gitignore @@ -98,3 +98,6 @@ next-env.d.ts # same reason in a different tool. /.wt-*/ /.worktrees/ +# Mutation testing (stryker.config.mjs): reports and the sandbox copy. +/reports/mutation +/.stryker-tmp diff --git a/.gitleaks.toml b/.gitleaks.toml index b5d9433d0..ee6dfb046 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -11,6 +11,9 @@ paths = [ # All test files contain hardcoded fake tokens / idempotency-keys. '''(^|/)__tests__/''', '''\.test\.ts$''', + # Golden copies of the backup file written by the parity test: synthetic + # fixture values, and every `…Encrypted` value already opened to its text. + '''^tests/integration/__golden__/''', # OpenAPI spec contains example tokens (`hlk_example…`) used to document the schema. '''^docs/api/openapi\.yaml$''', ] diff --git a/.prettierignore b/.prettierignore index 68f690586..87bd3e86c 100644 --- a/.prettierignore +++ b/.prettierignore @@ -16,3 +16,21 @@ docs/api/openapi.yaml # Build-generated service worker (the PWA build emits it). public/sw.js + +# Ratchet baselines, written by the guards themselves (RATCHET_WRITE=1). The +# writer's JSON layout is the canonical one, so a write-back never fails the +# format check. +src/__tests__/baselines + +# Benchmark results, written by scripts/bench/run.ts and committed as they +# come out, so a re-run diffs cleanly against the last one. +scripts/bench/results + +# Golden copies of the backup file and the restored account, written by the +# parity test itself (BACKUP_PARITY_WRITE=1), so a rewrite diffs cleanly. +tests/integration/__golden__ + +# inlang writes its working files (cache, metadata, README) next to the +# project settings when its CLI or SDK runs; only settings.json is tracked. +project.inlang/* +!project.inlang/settings.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 834d69ad7..8c7c022fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,102 @@ # Changelog +## [1.45.0] — 2026-10-23 + +### Added + +- **Brazilian Portuguese.** The whole app, the Coach and the reports are + available in pt-BR, with Brazil's crisis lines (CVV 188, SAMU 192) next + to the international ones. Portuguese readers outside Brazil see 112 and + Portugal's SNS 24 and SOS Voz Amiga as well. +- **Translate in the browser.** Translators can edit any language on the + web with their GitHub account and send the change as a pull request; the + contributing guide links the editor and explains what reviewers check. +- **Dengue and tuberculosis in the vaccine catalogue.** Dengue is listed + with the travel vaccines (two doses three months apart) and tuberculosis + with the childhood ones (one dose at birth); neither has a booster. Each + cites its WHO position paper, and searching "bcg" finds tuberculosis. +- **The Coach knows your whole lab history.** Asked about a marker such as + LDL or PSA, it now sees your recent values and a yearly summary going + back ten years, instead of stopping at twelve months. Other readings keep + the one-year window. (#1263) + +### Changed + +- **Faster.** On an account with ten years of data the dashboard loads in + well under half the time, a ten-year chart about five times faster and a + one-year chart about three times faster. The values are the same: the + new reads are checked against the old ones across time zones, daylight + saving changes and multiple sources. +- **Steadier start and less memory.** The app no longer freezes for most of + a second while it starts, holds only the language you use in memory, and + pauses heavy background work when the container gets close to its memory + limit instead of being restarted. Stopping the container lets running + jobs finish. +- **The questionnaires follow the validated originals.** PHQ-9, GAD-7, + WHO-5 and the sleep questionnaire now share one flow: the question's time + frame is always shown, answers are a proper choice list you can use with + the keyboard, and a review step lets you check every answer before it is + saved. The result explains the score in plain words and whether it moved + meaningfully since last time. The German, Spanish, Italian, Polish and + French wording now matches the published validated versions word for + word; where no validated translation exists, the original English is + shown rather than an unvalidated one. +- **Re-sent Apple Health data no longer counts as new.** When the iPhone + sends values the server already has, nothing is rewritten and the Apple + Health card no longer claims fresh data arrived; it says when data was + last re-sent without anything new. (#1259) +- Fonts are now part of the app instead of being fetched from Google when + an image is built. + +### Fixed + +- A workout synced by two services counts once everywhere, including when + the two copies fall on either side of a five-minute mark, and opening + either copy shows the same session. +- A backup restored for disaster recovery keeps the original creation + dates of medications, courses, ingredients, nutrient days and mood and + symptom links, and keeps sealed labels and notes sealed. Deleted family + history entries are part of that backup too. +- The Coach refuses more ways of telling you to change a dose, such as + halving it or taking "the same as before" when that is not your plan, in + every language. Several of its safety checks never matched words with + accents; they do now. +- A Strava connection whose refresh token expired now asks you to + reconnect instead of failing quietly. +- Ending a session from the session list records correctly whether it was + your own. +- Values that round to zero no longer show as "-0". +- Clearing a filter on the documents, measurements or mood pages, closing + a document or zooming the timeline no longer brings the old address back + a moment later. +- The nightly measurement maintenance no longer reports every run as + failed: it recorded the table's bloat as a decimal where only whole + numbers were accepted, although the maintenance itself had run. (#1271) +- Mood dimensions logged between local and UTC midnight count for today + again instead of going missing until the next day. +- Sheets on a phone open without a slide when your device asks for reduced + motion, and every control's accessible name now matches its visible + label. +- A large import from Apple Health, Health Connect, Withings or Oura + refreshes the charts that depend on it straight away. + +### Upgrade notes + +- Migration 0471 adds one column for Apple Health bookkeeping. +- Single-container installs need nothing else. If you run the web and + worker roles in separate containers, start the worker first: the job + queue updates its schema on start, and a web-only process waits for it. + See `docs/ops/deploy.md`. +- The supported memory floor stays at 1536 MB for the app container. + +### Thanks + +Thanks to @dareoon for the Brazilian Portuguese translation (#1251), to +@asmala for adding dengue and tuberculosis to the vaccine catalogue +(#1266) and for asking the Coach to look further back in the lab history +(#1263), and to @mills1975 for spotting the failing maintenance job +(#1271). + ## [1.44.2] — 2026-10-22 ### Changed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4424d2695..22f7d22e5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -121,7 +121,7 @@ Contributors: branch off `main`; PRs target `main`. ## Translations -HealthLog ships with English (`en`) and German (`de`) maintained by the project, plus French (`fr`), Spanish (`es`), Italian (`it`), Polish (`pl`), Korean (`ko`), and Dutch (`nl`) that are **AI-initial, community-maintained**. The maintained locales are reviewed line-by-line; the community-maintained ones surface a small banner inviting users to improve them. +HealthLog ships with English (`en`) and German (`de`) maintained by the project, plus French (`fr`), Spanish (`es`), Italian (`it`), Polish (`pl`), Korean (`ko`), Dutch (`nl`), and Brazilian Portuguese (`pt-BR`) that are **AI-initial, community-maintained**. The maintained locales are reviewed line-by-line; the community-maintained ones surface a small banner inviting users to improve them. ### Improving an existing locale @@ -134,16 +134,36 @@ To suggest a fix or improvement: 3. Edit `messages/.json`. Keep the JSON valid; the i18n integrity test will flag missing or extra keys against `en.json` before CI accepts the PR. 4. Open a PR titled `i18n(): `. Describe what was unclear, awkward, or wrong in the previous wording. +### Translating in the browser + +Every locale can be edited in [Fink](https://fink.inlang.com/?repo=MBombeck/HealthLog&project=project.inlang), inlang's browser editor, without cloning the repository. Fink reads `project.inlang/settings.json`, which points it at `messages/.json`, shows each message next to its translations, and flags strings that are missing or use other placeholders than English. + +1. Open the link and sign in with GitHub. +2. Without write access to this repository, choose **Fork**. Fink asks you to install the Inlang GitHub App on your own account so it can push to your fork. +3. Edit, then choose **Push**. Fink commits to the fork; **Open pull request** takes you to GitHub's compare page, where you submit the change against `main`. + +Fink writes the same nested JSON this repository uses and changes only the lines you edited. A string you add for a key the locale did not have yet goes to the end of its section instead of the position it has in `en.json`, which is harmless. + +What a reviewer checks on a translation pull request: + +- Only `messages/*.json` changed, and only the strings the description names. +- Every `{placeholder}` of the English string is still there and spelled the same. The i18n integrity test fails the build otherwise. +- Medical terms are the ones the language's clinical guidelines use, and the register matches the rest of the locale (`du` in German, `você` in Brazilian Portuguese). +- Words the app or the Telegram bot matches literally stay as they are: slash commands, `taken `, and the unit symbols. +- Machine translation is fine as a first draft, but someone who reads the language has gone through every string before the pull request is opened, and the description says so. +- Validated questionnaire items (the PHQ-9, GAD-7, WHO-5 and SCI items, their answer options and their stems under `mentalHealth.items`, `mentalHealth.options`, `mentalHealth.gad7Options`, `mentalHealth.who5Options`, `mentalHealth.sciOptions`, `mentalHealth.stems`, `mentalHealth.functional*`) are never translated freely, not even as a draft. A language gets them only from a published, licensed validated version, copied verbatim and named in `src/lib/mental-health/instruments.ts`; until then the app shows the English original and the bundle carries none of those keys, which the i18n tests enforce. + +To run the checks Fink shows on a local checkout: `npx @inlang/cli@3.4.4 check --project ./project.inlang`. It reads the files and changes nothing. + ### Adding a new locale -If you want to introduce a locale that isn't shipped (e.g. `pt-BR`, `cs`): +If you want to introduce a locale that isn't shipped (e.g. `sv`, `cs`): 1. Open an issue first using the **Translation request** template. We'll confirm the locale is in scope and decide whether it joins the AI-initial-community-maintained tier or the maintained tier. -2. Copy `messages/en.json` to `messages/.json`. +2. Copy `messages/en.json` to `messages/.json`, then delete the questionnaire wording the new language has no validated version of (the test output lists the keys). 3. Translate. AI assistance is welcome as a starting point but the PR description should note whether it's an AI-initial pass or a fully human-reviewed one — that information drives the banner state. -4. Add the locale to the supported list in `src/lib/i18n/locales.ts` (or wherever the registry lives — grep `SUPPORTED_LOCALES`). -5. The Coach system prompts at `src/lib/ai/prompts/coach-prompt.ts` and `src/lib/ai/prompts/insight-generator.ts` and the doctor-report PDF strings need translation too. Include those in the same PR. -6. Run `pnpm test -- i18n-integrity` locally before submitting. +4. Add the locale to `locales` in `src/lib/i18n/config.ts` and to `locales` in `project.inlang/settings.json`, then run `pnpm typecheck`. Every per-locale table that still lacks the new locale fails to compile, and that list is the work: date and number formatting, the reply-language footers of the Coach and insights prompts, the outbound safety banks, the prose-grounding patterns and the no-key fallback copy. Include those in the same PR. +5. Run `pnpm test` before submitting. The i18n guards hold the new bundle to the English keys and placeholders. ### Style guide for translators @@ -156,7 +176,7 @@ If you want to introduce a locale that isn't shipped (e.g. `pt-BR`, `cs`): ### Long-term -When the community grows beyond ad-hoc PRs, we'll spin up a self-hosted [Weblate](https://weblate.org/) instance. That replaces the JSON-PR workflow with a web UI but keeps every translation under our own roof — no third-party telemetry. Tracking issue: `v1.6 — Weblate self-hosted on edge-01`. +Fink covers browser editing today. It edits the files in this repository directly and keeps no copy of the translations anywhere else. If the community outgrows it, a self-hosted [Weblate](https://weblate.org/) instance remains the fallback. Tracking issue: `v1.6 — Weblate self-hosted on edge-01`. ## Code of Conduct diff --git a/Dockerfile b/Dockerfile index 338364c89..db86834e2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,9 @@ RUN corepack enable && corepack prepare pnpm@11.15.1 --activate WORKDIR /app COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +# The dependency patches pnpm-workspace.yaml names; the frozen install +# refuses to run without them. +COPY patches patches COPY scripts/prepare.mjs scripts/prepare.mjs RUN pnpm install --frozen-lockfile --prod=false @@ -57,6 +60,11 @@ RUN node -p "require('/app/node_modules/pdfjs-dist/package.json').version" \ # The same for @napi-rs/canvas, whose native binary must match its JS loader. RUN node -p "require('/app/node_modules/@napi-rs/canvas/package.json').version" \ > /app/.canvas-version +# Every shipped locale's message catalog is present and parses, checked +# against the locale list in the source; the list is written out so the runner +# stage can check the catalogs the image actually carries. +RUN node scripts/verify-message-catalogs.mjs --dir messages \ + --config src/lib/i18n/config.ts --write /app/.message-locales # ── Stage 3: Production runner ───────────────────────────── FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2 AS runner @@ -105,6 +113,15 @@ RUN addgroup --system --gid 1001 nodejs && \ # Copy built assets COPY --from=builder /app/public ./public +# The server reads its message catalogs from here on first use, one locale at +# a time (src/lib/i18n/server-catalogs.ts); they are not bundled. +COPY --from=builder /app/messages ./messages +# A missing or broken catalog is only noticed when a page asks for that +# locale, so check the copies in this stage against the builder's list. +COPY --from=builder /app/.message-locales /app/scripts/verify-message-catalogs.mjs /tmp/catalog-check/ +RUN node /tmp/catalog-check/verify-message-catalogs.mjs --dir /app/messages \ + --locales /tmp/catalog-check/.message-locales && \ + rm -rf /tmp/catalog-check COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static # Next's standalone output must carry the externalized worker dependencies. @@ -265,7 +282,7 @@ RUN mkdir -p /opt/prisma-cli && \ npm pkg set 'overrides.mysql2=^3.22.0' && \ npm pkg set 'overrides.@hono/node-server=^1.19.15' && \ npm pkg set 'overrides.valibot=^1.4.2' && \ - npm install --omit=dev prisma@7.10.0 @prisma/engines@7.10.0 tsx@4.23.1 dotenv@18.0.6 && \ + npm install --omit=dev prisma@7.10.1 @prisma/engines@7.10.1 tsx@4.23.1 dotenv@18.0.8 && \ ln -sfn /opt/prisma-cli/node_modules/.bin/tsx /usr/local/bin/healthlog-tsx && \ ln -sfn /opt/prisma-cli/node_modules/dotenv /app/node_modules/dotenv && \ ln -sfn /opt/prisma-cli/node_modules/prisma /app/node_modules/prisma && \ diff --git a/README.md b/README.md index 7dfc102f6..299fa3065 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ Try the [live demo](https://demo.healthlog.dev) to see a working install — sig - **AI insights you own.** Daily briefing, health scores, correlations, and a conversational coach grounded in your own measurements and interpreted against cited guideline reference bands — where a value sits and what that means in plain words, never a diagnosis. The coach proposes concrete if-then plans you accept or decline, and suppresses proactive check-ins when there's nothing genuine to say. Bring your own OpenAI or Anthropic key, or point at a local endpoint (Ollama, LM Studio, vLLM) so AI requests stay on your network. - **Clinician-ready output.** A doctor-report PDF rendered on your server, a read-only HL7 FHIR R4 API, and scoped, time-limited share links you revoke after the visit. - **Private by construction.** AES-256-GCM encryption at rest for notes and other free text, documents, questionnaire answers, AI-written text and credentials, with zero-downtime key rotation. Measurement values, lab values, medication names and mood scores are stored unencrypted in the database, so the database volume and its backups need the same protection as the host. Passkey login, server-side sessions, strict CSP, and no third-party tracking anywhere. -- **Built to be lived in.** Installable PWA with offline support, medication reminders over APNs, Telegram, ntfy, and Web Push, a sub-second dashboard on years of imported history — in English, German, Spanish, French, Italian, Polish, Korean, and Dutch end to end. +- **Built to be lived in.** Installable PWA with offline support, medication reminders over APNs, Telegram, ntfy, and Web Push, a sub-second dashboard on years of imported history — in English, German, Spanish, French, Italian, Polish, Korean, Dutch, and Brazilian Portuguese end to end. The full feature tour, integration guides, and API reference live at [docs.healthlog.dev](https://docs.healthlog.dev). @@ -109,13 +109,14 @@ Next.js 16 (App Router, React Server Components), TypeScript strict, PostgreSQL ## Documentation -| | | -| ---------------------- | ------------------------------------------------ | -| User and operator docs | [docs.healthlog.dev](https://docs.healthlog.dev) | -| Release history | [CHANGELOG.md](CHANGELOG.md) | -| API contract | [`docs/api/openapi.yaml`](docs/api/openapi.yaml) | -| Security policy | [SECURITY.md](SECURITY.md) | -| Contributing | [CONTRIBUTING.md](CONTRIBUTING.md) | +| | | +| ---------------------- | ------------------------------------------------------------ | +| User and operator docs | [docs.healthlog.dev](https://docs.healthlog.dev) | +| Release history | [CHANGELOG.md](CHANGELOG.md) | +| API contract | [`docs/api/openapi.yaml`](docs/api/openapi.yaml) | +| Security policy | [SECURITY.md](SECURITY.md) | +| Contributing | [CONTRIBUTING.md](CONTRIBUTING.md) | +| Translating | [CONTRIBUTING.md#translations](CONTRIBUTING.md#translations) | ## Status diff --git a/bundle-budget.json b/bundle-budget.json index 2fdc452d6..0553bb199 100644 --- a/bundle-budget.json +++ b/bundle-budget.json @@ -1,14 +1,121 @@ { - "$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.15's async-truth charge then added the refresh settling state with its two-locale-key copy, the index-outcome plain-words line (seven keys across the six catalogs) and the reindex-count wiring; the measured total rose to 3429 while every per-route ceiling stayed met (/page 456, /insights 445, /measurements 442, /insights/mood 455). The aggregate cap moves from 3428 to 3452, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.27 found /insights sitting a few hundred bytes under 445 on main and a few hundred over it on the release branch — the page had been at its ceiling for three releases and the release only had to breathe on it. The weight it was carrying was not its own: the authenticated shell imported one destination predicate from the `@/lib/record-settings` barrel, and the barrel also re-exports the managed settings-patch schemas, so Zod, the module registry and three validation modules rode into the eager bundle of every authenticated route for a function that reads a URL slug. The shell imports the `classification` leaf now. Measured across all 585 client-rendered routes: none grew by a byte, 100 fell, by 4.7 KB at the median and 8.1 KB at the most; /insights 444 to 437, /measurements 442 to 435, / 456 to 450, /insights/mood 455 to 450. The whole-build sum moved the other way, 3451 to 3476, and that direction is the metric rather than the change: the aggregate counts each emitted chunk once, so a graph that leaves the chunk group every route shares gets counted again wherever it lands — here inside the deferred settings chunks that actually use it. Bytes on disk rose 25 KB; bytes a reader downloads only ever fell. The aggregate cap moves from 3452 to 3500, the same 24 KB headroom as the four re-measurements before it. Per-route ceilings keep their old values rather than tightening to the new floor: the CI runner measures each route about 1.5 KB heavier than a local arm64 build does, and a ceiling set from a local number would fail on a build that had not regressed. The static-catalog guard and the one-Recharts-chunk guard remain unchanged. Raised to 3700 at v1.37.32 for the seventh interface language (Korean): the lazy message catalogs went 6->7 chunks (1026 KB gz measured), pushing the aggregate to 3631 against the old 3500. The raise is entirely the new catalog; no route budget moved, and the four eager route ceilings are unchanged and still passing. Most of the aggregate is the dynamic locale-switch and fallback path that a first paint never loads (see issue #253), so this ceiling guards bytes almost no visitor requests; 3700 leaves ~69 KB for ordinary drift until that double-count is cleaned up. v1.38.14 re-measures the four eager routes and changes what the number means. The ceilings had been re-stated but never re-based since v1.37.4, so /insights and /measurements sat within a kilobyte of theirs and / and /insights/mood within one of theirs; e2e run 34043772954 failed on `/insights/page: 445 KB gz exceeds the 445 KB budget` with the other two at 459 of 460, and none of it was a regression anyone had made. A ceiling a route is already touching does not measure the route, it measures whichever dependency bump landed that week. The file states the measured value per route now, plus one drift allowance over it: measured on a local arm64 build at d48bbff01 as / 447, /insights 434, /measurements 433, /insights/mood 447, with a 20 KB allowance, so the effective ceilings are 467 / 454 / 453 / 467 — a raise for all four. The allowance covers the runner delta (the CI runner measures each route a little heavier than a local arm64 build) and ordinary week-to-week drift, while the step change the gate exists for — a statically imported message catalog, a duplicated chart library — is several times larger and still fails. Every run now prints the delta against the baseline, so a route that has crept 15 KB is legible in the log long before it is red; the flat ceiling could only ever say pass or fail. Re-base by re-measuring on a build with nothing else in it and writing the new numbers here with the reason, exactly as a raised ceiling was written before. Raised to 3724 at v1.39.4 on a measured delta, not a feature. The release branch alone measured 3699 KB gz (3698.9) against the 3700 cap, so the ordinary drift room the v1.37.32 raise left had been used up by the releases since; the review fixes on top of it added 1.3 KB gz across the whole build (3700.2), almost all of it new copy in the seven lazy locale catalogs (the total and reused-table labels, the step and chip wording) plus the result-panel states. The four eager routes measured the same on both builds (/ 458, /insights/mood 456, /insights 445, /measurements 442), all inside their ceilings. The aggregate cap moves from 3700 to 3724, the same 24 KB headroom as the re-measurements before it. Raised to 3749 at v1.39.9 on a measured delta. Main measured 3718 KB gz after v1.39.7 and v1.39.8 (unit-aware copy, the phone layouts, the time-zone work and the account-settings restore labels, almost all of it new strings in the seven lazy locale catalogs), leaving 6 KB of the v1.39.4 headroom; the manual workout entry (#1085: the form, its sheet, the delete action and their copy in seven locales) adds about 7 KB and measured 3725. The four eager routes stay inside their drift allowances (/ +12.5, /insights +12.1, /insights/mood +10.2 KB of 20 on main). The aggregate cap moves from 3724 to 3749, the same 24 KB headroom as the re-measurements before it. Raised to 3790 at v1.40.0 on a measured delta. With the background document reads on main (#1103: the run polling hook, the still-reading states and their copy in seven locales), the key backup step and the plain-http sign-in notice (#1104) and the v1.40 fixes (#1106: the duration reader, the marker unit lock, the chart window) each branch measured 3752 KB gz on its own against the 3749 cap; together they add a few KB more. The four eager routes stay inside their drift allowances (/ +18.4, /insights +16.5, /measurements +14.9, /insights/mood +14.7 KB of 20). The aggregate cap moves from 3749 to 3790, about 24 KB above the combined build, the same headroom as the re-measurements before it. Re-based at v1.40.0 when the quick-entry forms began loading on demand. Custom symptoms added a fifth quick-entry form, and with all five forms bundled eagerly every one of the four eager routes measured over its ceiling (/ 472, /insights 457, /insights/mood 468, /measurements 454 KB gz, each 21 to 25 KB over the baseline). Loading the forms on demand brings them to / 429, /insights 368, /insights/mood 379, /measurements 411: 43 to 90 KB less on first load. The four route baselines move down to those measured values, so the gain is protected by the same 20 KB allowance rather than spent. The aggregate moves the other way, 3774 to 4077, for the reason described at v1.37.27: the aggregate counts each emitted chunk once per route group, and on-demand form chunks land in every route group that can open a quick-entry sheet. Bundling the five forms into one shared on-demand module measured the same (4078), so the split is not the cause. The aggregate cap moves from 3790 to 4100. Bytes on disk rose; bytes a reader downloads fell on every eager route. Raised to 4155 at v1.40.2 on a measured delta. The Coach redesign (the right conversation panel, its row menu and the quick-settings overlay, all loaded on demand), the answer action row, the dashboard's Today overview, the document procedure filter and their copy in seven locales took the whole-build sum from 4080 to 4129 KB gz; most of it is new strings in the seven lazy locale catalogs and the on-demand Coach chunks, which a first paint does not load. The four eager routes stay well inside their drift allowances (/ +1.9, /measurements +1.9, /insights/mood +1.4, /insights +1.0 KB of 20). The aggregate cap moves from 4100 to 4155, about 26 KB above the build, the same headroom as before; per-route baselines are unchanged. Raised to 4182 at v1.42.0 on a measured delta. Main measured 4146 KB gz (lazy catalogs 1177, other chunks 2969); the release branch's shared contract change measured 4158 (lazy catalogs 1188, other chunks 2969): the whole 12 KB is new strings in the seven lazy locale catalogs (the Health Connect import card, air quality and pollen, the managed-profile handover and claim, own vaccines, the lab conversion note, the per-type Apple Health list), and no eager route moved (/ +3.6, /measurements +1.5, /insights/mood +1.1, /insights +0.7 KB of 20). The aggregate cap moves from 4155 to 4182, about 24 KB above the build, the same headroom as before; per-route baselines are unchanged. Re-based at v1.42.0 when the settings sections and Zod left the shared bundles. The settings section route imported all twenty-one sections statically; each now loads on its own, and /settings/[section] measured 611 KB gz before and 403 after. The account-access parse that runs on every boot was the shell's only Zod import, about 63 KB gz on every route; it is plain TypeScript now, held to the published schema by an equivalence test, and the layout entry fell from 199 to 136 KB gz. The canonical unit table moved out of the measurement validation module, which took Zod off the routes whose only reason for it was the display transform: /insights 369 to 304, /medications 428 to 365, /labs 400 to 337. Across the 627 client routes 62 fell, none grew by more than 2 KB, and the per-route sum fell by 1844 KB gz. The /insights baseline moves down to 304 and /settings/[section] joins the watched routes at 403, so both gains are held by the same 20 KB allowance rather than spent. The aggregate moves the other way, 4167 to 4311, for the reason described at v1.37.27 and v1.40.0: the aggregate counts each emitted chunk once, and modules that used to sit in one shared chunk are now emitted into each route group that still needs them (the measurement validation module and the unit table into about twenty-five each, the settings sections into their own chunks). Bytes on disk rose; bytes a reader downloads fell on every route that moved. The aggregate cap moves from 4182 to 4335, about 24 KB above the build, the same headroom as before. Raised to 4400 at v1.42.0 on a measured delta. The release branch after its tickets merge measured 4365 KB gz (lazy catalogs 1206, other chunks 3159), already 30 KB over the 4335 cap; the day view and timeline contract adds 10 more (catalogs 1215, other 3160), almost all of it the base copy for the day, the timeline and life events in seven locales. The aggregate cap moves from 4335 to 4400, about 25 KB above the build, the same headroom as before; per-route baselines are unchanged. Raised to 4470 at v1.42.0 on a measured delta. The release branch with the day view, the timeline and their server measured 4386 KB gz (lazy catalogs 1228, other chunks 3158); giving every chart drawn in days the same doors to its day (one shared hook for the click, the touch tooltip's button, the open day's line, the row of day dots and the caption, a shared calendar adapter, a data table under each newly linked chart) measured 4446 (catalogs 1228, other chunks 3218). The 60 KB is the aggregate's double count again: the hook, the data table and the day link are emitted into each chart's own chunk group rather than once. The eager routes moved by a kilobyte or a few (/ +1.6, /measurements +1.7, /settings/[section] +0.8, /insights/mood +4.6, /insights +1.5 KB) and stay inside their allowances. The aggregate cap moves from 4400 to 4470, about 24 KB above the build, the same headroom as before; per-route baselines are unchanged. Raised to 4760 at v1.42.0 on a measured delta, the other way round from a feature: the day panel and the date field's calendar left the first load. The day view (its scores, sections and calendar) now loads when a day first opens, not with the shell, and the date field loads react-day-picker and the seven date-fns locales when its popover first opens. Every watched route fell: / 460 to 420, /measurements 439 to 398, /insights/mood 438 to 395, /settings/[section] 432 to 387, /insights 360 to 317 KB gz, all five back under their unchanged ceilings. The aggregate rose from 4426 to 4736 for the reason described at v1.37.27 and v1.40.0: while the day view sat in the shell, the small primitives it shares with the pages (card, table, popover, switch, input, badge, the chart day links) were emitted once into the shared chunk; with the day view on demand, each route group that uses them gets its own copy, and the aggregate counts every copy. Bytes on disk rose; bytes a reader downloads fell on every route. The aggregate cap moves from 4470 to 4760, about 24 KB above the build, the same headroom as before; per-route baselines are unchanged. Raised to 4790 at v1.42.3 on a measured delta. The release branch measured 4762 KB gz (lazy catalogs 1243, other chunks 3519) against the 4760 cap; the 15 KB catalog rise is new copy in the seven lazy locale catalogs (the Withings plan note and its type labels, the Oura reconnect hint, the lab conversion and printed-range notes, the issuer refusal), and the rest is the conversion badge and range note in the scan review's own chunk. The five watched routes stay inside their allowances (/ 422, /measurements 399, /insights/mood 396, /settings/[section] 389, /insights 318 KB gz). The aggregate cap moves from 4760 to 4790, about 28 KB above the build; per-route baselines are unchanged. Raised to 4860 at v1.43.0 on a measured delta. The release branch measured 4834 KB gz (lazy catalogs 1302, other chunks 3532) against the 4790 cap. 29 KB is new copy in the seven lazy locale catalogs (the AI usage levels, the life circumstances and AUDIT-C, the supplement composition, the marker merge, Dawarich, the related-records lists, the pulse day-mean card and the support report), and 13 KB is the on-demand chunks of those surfaces. The five watched routes stay inside their allowances and barely moved since v1.42.3 (/ 423, /measurements 400, /insights/mood 397, /settings/[section] 405, /insights 320 KB gz). The aggregate cap moves from 4790 to 4860, about 26 KB above the build; per-route baselines are unchanged. Re-based at v1.43.0 when Zod left four eager routes. Between the v1.43 budget raise and the merged release branch the four Zod-carrying eager routes grew by about 26 KB gz each (/ 423 to 450, /settings/[section] 405 to 431, /measurements 400 to 426, /insights/mood 397 to 423) while /insights, which loads no Zod, did not move. The whole step was the shared Zod chunk, 63.0 to 88.9 KB gz: zod 4.6 adds an ahead-of-time schema compiler and its memoizer to the core, and because every schema is written against the `z` namespace the bundler keeps the whole namespace (every locale, the JSON Schema converters and now the compiler) wherever Zod loads at all. None of the four routes needed Zod for its first paint. The dashboard and the measurement list read the source list, the notes limit and the screening check out of the two measurement validation modules, the mood page read the Coach source list out of the Coach request schemas, and every settings section rendered a gate that imported the managed settings form and the allergy and family-history forms for a branch only a shared or managed record reaches. The values now live in value-only modules (`@/lib/measurements/sources`, `@/lib/measurements/notes-limit`, `@/lib/measurement-reminders/screening`, `@/lib/ai/coach/scope-sources`) the schemas are built from, and the gate loads its two record-scoped sections on demand. Measured on a local arm64 build: / 450 to 355, /measurements 426 to 335, /insights/mood 423 to 334, /settings/[section] 431 to 307 KB gz; /insights stays at 320. The four route baselines move down to those values, so the gain is held by the same 20 KB allowance rather than spent; a Zod import back on any of them is about 89 KB and fails. The aggregate moves the other way, 4883 to 4949, for the reason described at v1.37.27, v1.40.0 and v1.42.0: the small primitives the removed forms shared with the lazy settings sections and quick-entry forms (popover, switch, the date and time fields) used to be emitted once into the routes' first load, and each on-demand chunk group now carries its own copy, which the aggregate counts every time. Bytes on disk rose; bytes a reader downloads fell by 89 to 124 KB gz on every route that moved. The aggregate cap moves from 4860 to 4975, about 26 KB above the build, the same headroom as before. Raised to 5000 at v1.43.3 on a measured delta. The release branch measured 4977 KB gz (lazy catalogs 1307, other chunks 3670) against the 4975 cap. Every fresh-factor refusal now opens the re-proof dialog, so the hook and the dialog behind it are imported by the managed-profile create and edit forms, the guardian and handover panels, the card, and the advanced and admin key-rotation sections as well, each of them an on-demand chunk the aggregate counts on its own. Loading the dialog on demand through next/dynamic was measured and not kept: 4979 KB gz, and every watched route grew by 0.1 to 0.7 KB for the loader. The five watched routes stay inside their allowances (/ 355, /measurements 335, /insights/mood 334, /settings/[section] 308, /insights 321 KB gz). The aggregate cap moves from 4975 to 5000, about 23 KB above the build; per-route baselines are unchanged. Split at v1.43.7 for the eighth interface language (Dutch). The aggregate counted every lazy message catalog, although a reader downloads only the one for their language, so each new language read as a regression of about 190 KB and the cap had to be raised for copy nobody else loads. The aggregate now covers the client chunks without the lazy catalogs (3670 KB gz measured at v1.43.3, cap 3700, the same headroom as before), and each catalog is held to its own ceiling (about 187 KB gz each at v1.43.3, cap 210). The static-catalog guard, the route baselines and the one-Recharts-chunk guard are unchanged. Raised to 3755 at v1.43.8 on a measured delta, and the /insights baseline re-based. The release branch measured 3729 KB gz without the lazy catalogs against the 3700 cap (v1.43.7: 3665). Bundling the client entry graph of v1.43.7 and of the release branch once each, deduplicated, measured 3462.6 and 3462.8 KB gz: no dependency, schema or catalog entered the client. The rise is the double count described at v1.37.27 and v1.40.0. The release replaces hand-written controls with shared primitives (the segmented toggle and its roving focus hook, the inline action, the score pill, the page header with its back link, the display-unit resolver), each well under a kilobyte, and the aggregate counts a copy in every chunk group that uses them, up to 88 of them. The /insights baseline still stated the 304 measured when Zod left the page at v1.42.0, although the page measured 320 at v1.43.0 and 321 at v1.43.3, so it had been running on a 4 KB allowance for three releases; it moves to the 323 measured on the runner now, protected by the same 20 KB allowance. The other four watched routes moved by 1.8 to 2.5 KB and keep their baselines. The aggregate cap moves from 3700 to 3755, about 26 KB above the build, the same headroom as before. Raised to 3900 at v1.44.0 on a measured delta, with two first-load reductions in the same release. Main measured 3737 KB gz without the lazy catalogs and the release branch 3863, the same figure the CI runner reported. Bundling every client module of each build once, deduplicated, measured 1494 and 1509 KB gz: the release adds about 15 KB of new client code (the CSV import card, the activity calendar and its goals sheet, the source provenance view, the efficacy summary, the training load and lab trend cards, the settings sheet on phones) and no dependency. The rest is the double count described at v1.37.27 and v1.40.0, attributed module by module from source maps: the shared destructive-control confirmation now used by fifteen more settings sections sits in 26 chunks instead of 11, and the two new routes (/settings/modules/[module] and /insights/sources) each carry their own copy of the record-settings gate's on-demand chunks, the date field and its popover, the settings shell and the type label map. Two reductions land beside the raise. The value domain read the plausibility band out of the measurement validation module, which put Zod into the first load of every metric sub-page; the band lives in a Zod-free module now and about fifty metric routes fell by 88.5 KB gz each (/insights/pulse 428 to 339). The arrangement pages imported their module link card from the file that holds the labs, mood and injection-site settings and shipped all three; the card has its own module (/settings/layout/[module] 391 to 380). Across the 108 pages the summed first load fell from 39998 to 35895 KB gz; 48 pages fell by more than 1 KB, 13 grew by more than 1 KB and none by more than 9 (/insights/workouts with the training load card, /enroll-mfa with the sessions and trusted devices that moved into the security section). The aggregate moved the other way, 3863 to 3875: the band is a small module now copied into each chunk group that reads it, where it used to ride in the one shared schema chunk. The five watched routes stay inside their allowances (/ 359, /measurements 339, /insights/mood 337, /insights 323, /settings/[section] 311 KB gz). The aggregate cap moves from 3755 to 3900, about 25 KB above the build, the same headroom as before; per-route baselines are unchanged.", - "routeBaselineKbGz": { - "/page": 355, - "/insights/page": 323, - "/measurements/page": 335, - "/insights/mood/page": 334, - "/settings/[section]/page": 307 - }, + "$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs --check after `pnpm build` in CI; the script header describes the method. Numbers are KB gzip. routeBaselineKbGz is the measured first load of every page (the shared rootMainFiles plus the page's client-reference chunks, each file counted once by content hash), written with --write-baselines; a page fails when it is more than routeDriftAllowanceKbGz over its baseline, and a page without a baseline fails until one is written in the same change. firstLoadP95KbGz and firstLoadMaxKbGz cap the distribution across pages. uniqueClientKbGz caps the distinct client code: every module counted once by its module id, the lazy message catalogs excluded. maxCatalogKbGz holds each catalog, maxRechartsChunks the one shared chart chunk. The summed-chunk aggregate the gate used up to v1.44.0 (3875 KB there, capped at 3900) is still printed but no longer gated: it counted a module once per chunk group it landed in, so it rose whenever a page moved code into on-demand chunks and needed a raise in most releases while no reader downloaded more. Re-based at v1.45.0 from the v1.44.0 build, measured on a local arm64 machine: 110 pages, first load p50 336, p95 437, max 451 KB; unique client code 1904 KB in 1224 modules. The five pages watched before keep their numbers within a kilobyte (/ 359, /measurements 339, /insights/mood 337, /insights 323, /settings/[section] 311), and the metric pages that no watched route covered before are gated now too (/insights/weight 339, /insights/pulse 339). Caps: p95 447 and max 461 KB (10 KB over the measurement), unique client code 1930 KB (about 25 KB, roughly two releases of new client code). A raise or a re-base is a reviewed change with the measured numbers and the reason written here; earlier re-bases are in the history of this file.", "routeDriftAllowanceKbGz": 20, - "totalClientKbGz": 3900, + "firstLoadP95KbGz": 447, + "firstLoadMaxKbGz": 461, + "uniqueClientKbGz": 1930, "maxRechartsChunks": 1, - "maxCatalogKbGz": 210 + "maxCatalogKbGz": 210, + "routeBaselineKbGz": { + "/_global-error/page": 133, + "/_not-found/page": 282, + "/about/page": 282, + "/achievements/page": 292, + "/admin/[section]/page": 451, + "/admin/page": 297, + "/auth/claim/page": 298, + "/auth/login/page": 290, + "/auth/register/page": 288, + "/c/[token]/page": 284, + "/checkups/page": 365, + "/checkups/practitioners/page": 289, + "/claim/[token]/page": 282, + "/coach/conversations/page": 294, + "/coach/page": 355, + "/coach/plans/page": 288, + "/confirm-access/page": 287, + "/custom-metrics/[id]/page": 303, + "/custom-metrics/[id]/values/page": 311, + "/cycle/page": 412, + "/documents/page": 378, + "/enroll-mfa/page": 305, + "/illness/[id]/page": 447, + "/illness/page": 421, + "/insights/active-energy/page": 336, + "/insights/audio-events/page": 337, + "/insights/blood-glucose/page": 340, + "/insights/blood-pressure/page": 336, + "/insights/bmi/page": 331, + "/insights/body-fat/page": 336, + "/insights/body-temperature/page": 336, + "/insights/body-water/page": 336, + "/insights/bone-mass/page": 336, + "/insights/breathing-disturbances/page": 337, + "/insights/cardio-fitness/page": 336, + "/insights/cardio-recovery/page": 337, + "/insights/catalog/page": 317, + "/insights/daylight/page": 336, + "/insights/double-support-time/page": 336, + "/insights/ecg/[id]/page": 316, + "/insights/ecg/page": 316, + "/insights/environmental-audio/page": 337, + "/insights/falls/page": 336, + "/insights/fat-free-mass/page": 336, + "/insights/fat-mass/page": 336, + "/insights/flights-climbed/page": 336, + "/insights/grip-strength/page": 337, + "/insights/headphone-audio/page": 336, + "/insights/health-score/page": 315, + "/insights/hrv/page": 337, + "/insights/lean-body-mass/page": 336, + "/insights/medications/page": 338, + "/insights/mood/page": 337, + "/insights/muscle-mass/page": 337, + "/insights/nutrients/page": 319, + "/insights/oxygen/page": 337, + "/insights/page": 323, + "/insights/pain/page": 336, + "/insights/pulse-wave-velocity/page": 336, + "/insights/pulse/page": 339, + "/insights/recovery/page": 330, + "/insights/respiratory-rate/page": 336, + "/insights/resting-pulse/page": 337, + "/insights/scores/[metric]/page": 328, + "/insights/six-minute-walk/page": 336, + "/insights/skin-temperature/page": 338, + "/insights/sleep/page": 337, + "/insights/sources/page": 321, + "/insights/stair-ascent-speed/page": 336, + "/insights/stair-descent-speed/page": 336, + "/insights/step-length/page": 336, + "/insights/steps/page": 337, + "/insights/values/[type]/page": 350, + "/insights/vascular-age/page": 337, + "/insights/visceral-fat/page": 336, + "/insights/waist-to-height/page": 336, + "/insights/waist/page": 336, + "/insights/walking-asymmetry/page": 336, + "/insights/walking-distance/page": 336, + "/insights/walking-heart-rate/page": 337, + "/insights/walking-speed/page": 336, + "/insights/walking-steadiness/page": 336, + "/insights/weight/page": 339, + "/insights/workouts/[id]/page": 437, + "/insights/workouts/page": 437, + "/insights/wrist-temperature/page": 336, + "/invite/[token]/page": 282, + "/labs/[biomarkerId]/page": 327, + "/labs/[biomarkerId]/values/page": 335, + "/labs/page": 423, + "/measurements/page": 339, + "/medications/[id]/history/page": 283, + "/medications/[id]/page": 402, + "/medications/new/page": 282, + "/medications/page": 355, + "/mental-wellbeing/page": 297, + "/mood/page": 438, + "/notifications/page": 289, + "/onboarding/[step]/page": 445, + "/onboarding/handover/page": 295, + "/onboarding/page": 286, + "/page": 359, + "/privacy/page": 282, + "/profile/page": 285, + "/settings/[section]/page": 311, + "/settings/layout/[module]/page": 380, + "/settings/modules/[module]/page": 374, + "/settings/page": 282, + "/timeline/page": 331, + "/vaccinations/page": 327 + } } diff --git a/docker-compose.yml b/docker-compose.yml index dad7cf80d..aa17c56b1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -508,6 +508,11 @@ services: # support report and Admin > System status say so. On a busy instance, # raise it with APP_MEMORY_LIMIT in .env; do not lower it. mem_limit: "${APP_MEMORY_LIMIT:-1536m}" + # On `docker stop` (a redeploy) the app stops taking requests, lets the + # requests in flight finish and gives running jobs up to 30 s to end + # before it exits. Docker's default of 10 s cut that short and left + # jobs marked active until their expiry, holding exclusive queues. + stop_grace_period: 45s healthcheck: test: [ @@ -564,6 +569,7 @@ services: # max-size: "10m" # max-file: "3" # mem_limit: "${WORKER_MEMORY_LIMIT:-1536m}" + # stop_grace_period: 45s db: # Pinned to the concrete minor instead of the floating `16-alpine` diff --git a/docs/README.md b/docs/README.md index aabff21cc..7b99ebc09 100644 --- a/docs/README.md +++ b/docs/README.md @@ -59,6 +59,12 @@ notes, and machine-readable specs that ship alongside the source: - [`contributing/ui-standards.md`](./contributing/ui-standards.md) — the binding UI standards: card anatomy, spacing, text colour, tokens and which primitive to use where. Read before touching any surface. +- [`contributing/benchmarks.md`](./contributing/benchmarks.md) — the + large-account benchmark (latency and statements per request) and the + statement budgets on the hot routes. +- [`contributing/testing.md`](./contributing/testing.md) — which suite a + test belongs in, the route test kit, the integration database, sharding, + property and mutation tests, and the flaky-test quarantine. - [`ui-guidelines.md`](./ui-guidelines.md) — UI / a11y house style; where it disagrees with the UI standards, the standards win. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 7bc8d2929..42491d862 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.44.2 + version: 1.45.0 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -90,8 +90,10 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "403": &a2 description: >- - Cycle tracking is not enabled for this account (errorCode `cycle.disabled`). Resolved against the RECORD - being read, not the caller: a delegate inside somebody else's record sees the owner's setting. + `meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: Cycle tracking is not enabled for + this account (errorCode `cycle.disabled`). Resolved against the RECORD being read, not the caller: a + delegate inside somebody else's record sees the owner's setting. Refused: this request named a record the caller may not act on (`meta.errorCode` = `sharing.access.denied`). @@ -259,8 +261,10 @@ paths: $ref: "#/components/schemas/CycleCustomSymptomUpdatedEnvelope" "401": *a1 "403": &a3 - description: "Cycle tracking is not enabled for this account (errorCode `cycle.disabled`). Resolved against the RECORD - being read, not the caller: a delegate inside somebody else's record sees the owner's setting." + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: Cycle tracking is not enabled for + this account (errorCode `cycle.disabled`). Resolved against the RECORD being read, not the caller: a + delegate inside somebody else's record sees the owner's setting." content: application/json: schema: @@ -395,7 +399,7 @@ paths: "403": *a2 "409": *a7 "422": *a4 - "429": &a12 + "429": &a16 description: "Rate limit exceeded. This route shares one per-account bucket with every other authenticated write — `record-write:`, 300 requests per 60 seconds — keyed on the ACTING account, so a delegate burns their own allowance rather than the record owner's. Only the routes that meter themselves with a dedicated @@ -736,6 +740,15 @@ paths: schema: $ref: "#/components/schemas/CyclePrefsGetEnvelope" "401": *a1 + "403": &a9 + description: "The credential is valid but its scope does not reach this operation. `meta.errorCode` = + `auth.scope.insufficient`: a narrow Bearer token on a route that names no scope it holds, or a + connector-bound token on a write. Retrying with the same token will not help; use a session, or a token + minted with the scope the route names." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 patch: @@ -757,7 +770,7 @@ paths: application/json: schema: $ref: "#/components/schemas/CyclePrefsPatchEnvelope" - "400": &a14 + "400": &a18 description: The request body is not parseable JSON. Nothing was read and nothing was written. `meta.errorCode` names the surface that refused it (`.body.invalid_json`). This is a client-side serialisation fault, not a validation failure — a body that parses but fails the schema is the 422 beside this. @@ -766,6 +779,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/measurement-categories: @@ -785,6 +799,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementCategoriesEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/meta/capabilities: @@ -805,6 +820,7 @@ paths: schema: $ref: "#/components/schemas/CapabilitiesEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/version: @@ -877,7 +893,9 @@ paths: $ref: "#/components/schemas/AssistantFlagsEnvelope" "401": *a1 "403": - description: A selector header was attached to an actor surface (`meta.errorCode` = `sharing.not_permitted`). + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: A selector header was attached to + an actor surface (`meta.errorCode` = `sharing.not_permitted`)." content: application/json: schema: @@ -916,6 +934,7 @@ paths: schema: $ref: "#/components/schemas/UpdateCheckEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 "502": @@ -964,16 +983,17 @@ paths: type: string format: binary "401": *a1 - "403": &a11 - description: "Refused: this request named a record the caller may not act on (`meta.errorCode` = - `sharing.access.denied`). Byte-identical for an account that does not exist, an account that granted - nothing, a grant whose sections do not reach this surface, and a read grant on a request that writes — the - response carries no reason, so it is not an account-enumeration oracle. The reason reaches the record - owner's activity feed and the operator's audit trail instead. On the cookie transport the same code answers - one further case: a session that has entered a shared record and sends a request without its record-context - assertion, which is a client older than the fence — leave the record and reload. A request that names no - record but the caller's own never reaches this response, so a client that never switches and never sends the - per-request account selector will not see it." + "403": &a15 + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: Refused: this request named a + record the caller may not act on (`meta.errorCode` = `sharing.access.denied`). Byte-identical for an account + that does not exist, an account that granted nothing, a grant whose sections do not reach this surface, and + a read grant on a request that writes — the response carries no reason, so it is not an account-enumeration + oracle. The reason reaches the record owner's activity feed and the operator's audit trail instead. On the + cookie transport the same code answers one further case: a session that has entered a shared record and + sends a request without its record-context assertion, which is a client older than the fence — leave the + record and reload. A request that names no record but the caller's own never reaches this response, so a + client that never switches and never sends the per-request account selector will not see it." content: application/json: schema: @@ -1011,6 +1031,7 @@ paths: schema: $ref: "#/components/schemas/ShareLinkCreated" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 get: @@ -1027,6 +1048,7 @@ paths: schema: $ref: "#/components/schemas/ShareLinkList" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/share-links/{id}: @@ -1050,6 +1072,7 @@ paths: schema: $ref: "#/components/schemas/ShareLinkRevoked" "401": *a1 + "403": *a9 "404": description: Link not found (or owned by another user). content: @@ -1257,6 +1280,7 @@ paths: type: string format: binary "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/fhir/Patient: @@ -1285,9 +1309,38 @@ paths: schema: type: string format: binary + "400": &a10 + description: A search parameter the server cannot honour, such as an `_offset` above 9007199254740991. The body is an + `OperationOutcome` (`issue[0].code` = `invalid`, the reason in `diagnostics`) served as + `application/fhir+json`. + content: + application/fhir+json: + schema: + type: string + format: binary "401": *a1 + "403": &a11 + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: Module disabled for this account: + the user (or operator) has the module turned off. `meta.errorCode` = `module.disabled` and `meta.module` + carries the disabled module key (e.g. \"sleep\"). Returned even for a valid Bearer token. Clients hide the + whole module surface end-to-end rather than retry." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 - "429": *a5 + "429": &a12 + description: "Rate limit exceeded: the per-account `fhir:` bucket, 120 requests per hour, shared by every + `/api/fhir/*` data route. The body is an `OperationOutcome` (`issue[0].code` = `throttled`) served as + `application/fhir+json`, not the JSON error envelope. The `Retry-After`, `X-RateLimit-Limit`, + `X-RateLimit-Remaining` and `X-RateLimit-Reset` headers describe the bucket; back off on `Retry-After`." + headers: *a8 + content: + application/fhir+json: + schema: + type: string + format: binary /api/fhir/Observation: get: tags: @@ -1331,9 +1384,11 @@ paths: schema: type: string format: binary + "400": *a10 "401": *a1 + "403": *a11 "422": *a4 - "429": *a5 + "429": *a12 /api/fhir/MedicationStatement: get: tags: @@ -1360,9 +1415,11 @@ paths: schema: type: string format: binary + "400": *a10 "401": *a1 + "403": *a11 "422": *a4 - "429": *a5 + "429": *a12 /api/fhir/MedicationAdministration: get: tags: @@ -1389,9 +1446,11 @@ paths: schema: type: string format: binary + "400": *a10 "401": *a1 + "403": *a11 "422": *a4 - "429": *a5 + "429": *a12 /api/fhir/Coverage: get: tags: @@ -1419,9 +1478,11 @@ paths: schema: type: string format: binary + "400": *a10 "401": *a1 + "403": *a11 "422": *a4 - "429": *a5 + "429": *a12 /api/fhir/Patient/{id}: get: tags: @@ -1445,6 +1506,7 @@ paths: type: string format: binary "401": *a1 + "403": *a11 "404": description: OperationOutcome (application/fhir+json). content: @@ -1453,7 +1515,7 @@ paths: type: string format: binary "422": *a4 - "429": *a5 + "429": *a12 /api/fhir/Coverage/{id}: get: tags: @@ -1477,6 +1539,7 @@ paths: type: string format: binary "401": *a1 + "403": *a11 "404": description: OperationOutcome (application/fhir+json). content: @@ -1485,7 +1548,7 @@ paths: type: string format: binary "422": *a4 - "429": *a5 + "429": *a12 /api/fhir/Observation/{id}: get: tags: @@ -1510,6 +1573,7 @@ paths: type: string format: binary "401": *a1 + "403": *a11 "404": description: OperationOutcome (application/fhir+json). content: @@ -1518,7 +1582,7 @@ paths: type: string format: binary "422": *a4 - "429": *a5 + "429": *a12 /api/fhir/MedicationStatement/{id}: get: tags: @@ -1542,6 +1606,7 @@ paths: type: string format: binary "401": *a1 + "403": *a11 "404": description: OperationOutcome (application/fhir+json). content: @@ -1550,7 +1615,7 @@ paths: type: string format: binary "422": *a4 - "429": *a5 + "429": *a12 /api/fhir/MedicationAdministration/{id}: get: tags: @@ -1574,6 +1639,7 @@ paths: type: string format: binary "401": *a1 + "403": *a11 "404": description: OperationOutcome (application/fhir+json). content: @@ -1582,7 +1648,7 @@ paths: type: string format: binary "422": *a4 - "429": *a5 + "429": *a12 /api/fhir/Patient/$everything: get: tags: @@ -1616,9 +1682,11 @@ paths: schema: type: string format: binary + "400": *a10 "401": *a1 + "403": *a11 "422": *a4 - "429": *a5 + "429": *a12 /api/export: get: tags: @@ -1676,6 +1744,7 @@ paths: schema: type: string "401": *a1 + "403": *a9 "422": description: "`format` or `type` carried a value outside its enum. The message names the accepted set; nothing is exported. Note that this route validates by hand rather than through Zod, so the response is the @@ -1719,6 +1788,7 @@ paths: schema: $ref: "#/components/schemas/FullBackupPayload" "401": *a1 + "403": *a9 "429": description: More than 10 exports in the trailing hour for this account (`export:`, shared across the export family). @@ -1779,6 +1849,7 @@ paths: schema: type: string "401": *a1 + "403": *a9 "429": description: More than 10 exports in the trailing hour for this account. content: @@ -1845,6 +1916,7 @@ paths: schema: type: string "401": *a1 + "403": *a9 "404": description: "`medicationId` named a medication this account does not hold. An unknown id and another account's id are indistinguishable." @@ -1900,6 +1972,7 @@ paths: schema: type: string "401": *a1 + "403": *a9 "429": description: More than 10 exports in the trailing hour for this account. content: @@ -1938,8 +2011,10 @@ paths: "401": *a1 "403": description: >- - The Bearer token carries a narrow scope. The encrypted archive needs a cookie session or a full-access - token; the refusal happens before any second-factor proof is looked at. + `meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: The Bearer token carries a narrow + scope. The encrypted archive needs a cookie session or a full-access token; the refusal happens before any + second-factor proof is looked at. Refused: the request was made while the session is acting on another account (`meta.errorCode` = @@ -2135,7 +2210,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "409": &a9 + "409": &a13 description: 'Refused before anything was checked or spent: the request stated `clientTransport.protocol: "http"` on a non-loopback host while this server issues `Secure` session cookies, so the browser would drop the session. `meta.errorCode` = `auth.session.insecure_transport`, `meta.sessionCookieSecure` = `true`. No password was @@ -2170,7 +2245,8 @@ paths: schema: $ref: "#/components/schemas/MfaVerifyResponse" "401": *a1 - "409": *a9 + "403": *a9 + "409": *a13 "422": *a4 "429": *a5 /api/auth/oidc/native/token: @@ -2203,6 +2279,7 @@ paths: schema: $ref: "#/components/schemas/OidcNativeTokenResponse" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/auth/native/login: @@ -2276,6 +2353,7 @@ paths: schema: $ref: "#/components/schemas/NativeHandoffTokenResponse" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/auth/step-up/options: @@ -2302,6 +2380,7 @@ paths: schema: $ref: "#/components/schemas/StepUpOptionsEnvelope" "401": *a1 + "403": *a9 "409": description: No passkey registered on this account. content: @@ -2370,6 +2449,7 @@ paths: schema: $ref: "#/components/schemas/StepUpMintEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/auth/reproof: @@ -2414,6 +2494,7 @@ paths: schema: $ref: "#/components/schemas/ReproofEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 get: @@ -2432,6 +2513,7 @@ paths: schema: $ref: "#/components/schemas/ReproofStatusEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/auth/me/mfa/totp/setup: @@ -2471,13 +2553,14 @@ paths: application/json: schema: $ref: "#/components/schemas/TotpSetupEnvelope" - "401": &a10 + "401": &a14 description: No session or elevation; or, on the cookie path, `auth.reproof.required` (no recent sign-in and no proof in the body) or `auth.reproof.failed` (the proof did not verify). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" + "403": *a9 "409": description: A second factor is already active on this account. content: @@ -2514,6 +2597,7 @@ paths: schema: $ref: "#/components/schemas/TotpConfirmEnvelope" "401": *a1 + "403": *a9 "409": description: A second factor is already active, or enrollment was never started (no pending secret to confirm). content: @@ -2544,6 +2628,7 @@ paths: schema: $ref: "#/components/schemas/MfaDisableEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/auth/me/mfa/recovery-codes/regenerate: @@ -2562,6 +2647,7 @@ paths: schema: $ref: "#/components/schemas/MfaRecoveryRegenEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/auth/me/mfa: @@ -2581,6 +2667,7 @@ paths: schema: $ref: "#/components/schemas/MfaStatusEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/auth/me/mfa/webauthn/register/options: @@ -2619,7 +2706,8 @@ paths: application/json: schema: $ref: "#/components/schemas/MfaWebauthnRegisterOptionsEnvelope" - "401": *a10 + "401": *a14 + "403": *a9 "422": *a4 "429": *a5 /api/auth/me/mfa/webauthn/register/verify: @@ -2650,6 +2738,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/auth/me/mfa/webauthn/{id}: @@ -2680,6 +2769,7 @@ paths: schema: $ref: "#/components/schemas/MfaWebauthnRenameEnvelope" "401": *a1 + "403": *a9 "404": description: No such security key for this account. content: @@ -2708,6 +2798,7 @@ paths: schema: $ref: "#/components/schemas/MfaWebauthnRemoveEnvelope" "401": *a1 + "403": *a9 "404": description: No such security key for this account. content: @@ -2737,6 +2828,7 @@ paths: schema: $ref: "#/components/schemas/MfaWebauthnLoginOptionsEnvelope" "401": *a1 + "403": *a9 "409": description: No security key is registered on the account — fall back to the TOTP / recovery-code challenge. content: @@ -2767,7 +2859,8 @@ paths: schema: $ref: "#/components/schemas/MfaWebauthnVerifyResponse" "401": *a1 - "409": *a9 + "403": *a9 + "409": *a13 "422": *a4 "429": *a5 /api/auth/passkey/login-verify: @@ -2852,6 +2945,7 @@ paths: - $ref: "#/components/schemas/RefreshResponse" - $ref: "#/components/schemas/RefreshRevokeResponse" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/auth/passkeys: @@ -2870,6 +2964,7 @@ paths: schema: $ref: "#/components/schemas/PasskeyListEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/auth/me/sessions: @@ -2888,6 +2983,7 @@ paths: schema: $ref: "#/components/schemas/SessionListEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 delete: @@ -2920,6 +3016,7 @@ paths: schema: $ref: "#/components/schemas/SignOutEverywhereEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/auth/me/sessions/{id}: @@ -2943,6 +3040,7 @@ paths: schema: $ref: "#/components/schemas/SessionRevokeEnvelope" "401": *a1 + "403": *a9 "404": description: Session not found or not owned by the caller. content: @@ -2966,6 +3064,7 @@ paths: schema: $ref: "#/components/schemas/TrustedDeviceListEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 delete: @@ -2981,6 +3080,7 @@ paths: schema: $ref: "#/components/schemas/TrustedDeviceRevokeAllEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/auth/me/trusted-devices/{id}: @@ -3003,6 +3103,7 @@ paths: schema: $ref: "#/components/schemas/TrustedDeviceRevokeEnvelope" "401": *a1 + "403": *a9 "404": description: Trusted device not found or not owned by the caller. content: @@ -3036,6 +3137,7 @@ paths: schema: $ref: "#/components/schemas/SecurityActivityEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/auth/logout: @@ -3124,7 +3226,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "409": *a9 + "409": *a13 "429": *a5 /api/auth/passkeys/{id}: patch: @@ -3166,6 +3268,7 @@ paths: schema: $ref: "#/components/schemas/PasskeyRenameEnvelope" "401": *a1 + "403": *a9 "404": description: No such passkey for this account. content: @@ -3228,6 +3331,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" + "403": *a9 "404": description: No such passkey for this account. content: @@ -3264,6 +3368,7 @@ paths: schema: $ref: "#/components/schemas/ApiTokenListEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/tokens/measurements: @@ -3322,8 +3427,10 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 "403": - description: The operator has switched the API off instance-wide (`AppSettings.apiGlobal`). Nothing was minted. A Bearer - caller does not reach this arm — it is refused 401 before the switch is read. + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: The operator has switched the API + off instance-wide (`AppSettings.apiGlobal`). Nothing was minted. A Bearer caller does not reach this arm — + it is refused 401 before the switch is read." content: application/json: schema: @@ -3412,8 +3519,9 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 "403": - description: The operator has switched the API off instance-wide. Nothing was minted. A Bearer caller is refused 401 - before the switch is read. + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: The operator has switched the API + off instance-wide. Nothing was minted. A Bearer caller is refused 401 before the switch is read." content: application/json: schema: @@ -3499,8 +3607,9 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 "403": - description: The operator has switched the API off instance-wide. Nothing was minted. A Bearer caller is refused 401 - before the switch is read. + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: The operator has switched the API + off instance-wide. Nothing was minted. A Bearer caller is refused 401 before the switch is read." content: application/json: schema: @@ -3564,10 +3673,11 @@ paths: $ref: "#/components/schemas/ApiTokenRevokeEnvelope" "401": *a1 "403": - description: The operator has switched the API off instance-wide (`AppSettings.apiGlobal`). `meta.errorCode` is the - status default `http.forbidden`; nothing was revoked. The sibling list is deliberately not gated this way — - a token stays live while the switch is off, so its owner can still see it here even though this call will - not kill it. + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: The operator has switched the API + off instance-wide (`AppSettings.apiGlobal`). `meta.errorCode` is the status default `http.forbidden`; + nothing was revoked. The sibling list is deliberately not gated this way — a token stays live while the + switch is off, so its owner can still see it here even though this call will not kill it." content: application/json: schema: @@ -3685,6 +3795,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" + "403": *a9 "422": *a4 "429": *a5 /api/auth/profile: @@ -3721,6 +3832,7 @@ paths: schema: $ref: "#/components/schemas/AuthProfileUpdateEnvelope" "401": *a1 + "403": *a9 "409": description: The requested email belongs to another account (`meta.errorCode` = `profile.update.emailInUse`). Nothing was written. @@ -3736,7 +3848,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": &a16 + "429": &a20 description: "Too many email-address changes, and the request asked for nothing else. The address change shares one per-account bucket across both profile routes — `profile-email:`, 10 requests per 3600 seconds — keyed on the ACTING account, so an account cannot collect a fresh allowance by probing a different address. @@ -3933,6 +4045,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" + "403": *a9 "409": description: The re-proof arm asked for a method the account has no credential for — fall back to another factor. content: @@ -3993,6 +4106,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" + "403": *a9 "422": *a4 "429": *a5 /api/auth/codex/device-start: @@ -4020,6 +4134,7 @@ paths: schema: $ref: "#/components/schemas/CodexDeviceStartEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/auth/codex/device-poll: @@ -4050,6 +4165,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 "502": @@ -4080,6 +4196,7 @@ paths: schema: $ref: "#/components/schemas/CodexDisconnectEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/sync/state: @@ -4098,6 +4215,7 @@ paths: schema: $ref: "#/components/schemas/SyncStateEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/sync/changes: @@ -4137,6 +4255,7 @@ paths: schema: $ref: "#/components/schemas/SyncChangesEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/measurements/series-batch: @@ -4186,7 +4305,7 @@ paths: schema: $ref: "#/components/schemas/GetMeasurementsSeriesBatchResponse" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 /api/measurements/series: @@ -4241,7 +4360,7 @@ paths: schema: $ref: "#/components/schemas/GetMeasurementsSeriesResponse" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 /api/measurements: @@ -4462,7 +4581,7 @@ paths: schema: $ref: "#/components/schemas/ListMeasurementsResponse" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 post: @@ -4518,7 +4637,7 @@ paths: schema: $ref: "#/components/schemas/CreateMeasurementResponse" "401": *a1 - "403": *a11 + "403": *a15 "409": description: A measurement with this data already exists — the `(userId, type, source, externalId)` dedup index rejected the write. Never for a named sleep-stage body, which answers a re-post with 200 instead. @@ -4534,7 +4653,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": *a12 + "429": *a16 /api/measurements/{id}: get: tags: @@ -4555,7 +4674,7 @@ paths: schema: $ref: "#/components/schemas/GetMeasurementResponse" "401": *a1 - "403": *a11 + "403": *a15 "404": description: Measurement not found (or owned by another user). content: @@ -4603,7 +4722,7 @@ paths: schema: $ref: "#/components/schemas/UpdateMeasurementResponse" "401": *a1 - "403": *a11 + "403": *a15 "404": description: Measurement not found (or owned by another user). content: @@ -4639,7 +4758,7 @@ paths: schema: $ref: "#/components/schemas/DeleteMeasurementResponse" "401": *a1 - "403": *a11 + "403": *a15 "404": description: Measurement not found (or owned by another user). content: @@ -4683,6 +4802,7 @@ paths: schema: $ref: "#/components/schemas/BatchMeasurementsResponse" "401": *a1 + "403": *a9 "409": *a7 "422": description: "The batch exceeded the 500-entry limit (`measurement.batch.too_large`), failed validation @@ -4723,6 +4843,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementsDeleteByExternalIdsResponse" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/measurements/bulk-delete: @@ -4750,7 +4871,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementsBulkDeleteResponse" "401": *a1 - "403": *a11 + "403": *a15 "409": *a7 "422": *a4 "429": *a5 @@ -4779,7 +4900,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementsRestoreResponse" "401": *a1 - "403": *a11 + "403": *a15 "409": *a7 "422": *a4 "429": *a5 @@ -4812,11 +4933,13 @@ paths: schema: $ref: "#/components/schemas/SleepNightResponse" "401": *a1 - "403": &a13 + "403": &a17 description: >- - Module disabled for this account: the user (or operator) has the module turned off. `meta.errorCode` = - `module.disabled` and `meta.module` carries the disabled module key (e.g. "sleep"). Returned even for a - valid Bearer token. Clients hide the whole module surface end-to-end rather than retry. + `meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: Module disabled for this account: + the user (or operator) has the module turned off. `meta.errorCode` = `module.disabled` and `meta.module` + carries the disabled module key (e.g. "sleep"). Returned even for a valid Bearer token. Clients hide the + whole module surface end-to-end rather than retry. Refused: this request named a record the caller may not act on (`meta.errorCode` = `sharing.access.denied`). @@ -4853,7 +4976,7 @@ paths: schema: $ref: "#/components/schemas/SleepRhythmResponse" "401": *a1 - "403": *a13 + "403": *a17 "422": *a4 "429": *a5 /api/workouts: @@ -4862,9 +4985,11 @@ paths: - Measurements summary: List workouts (v1.4.32) description: "Paginated workout list with cross-source canonical-row dedup. The picker reads the per-user - source-priority ladder and buckets by `(startedAt 5 min slot, sportType)` — twin workouts (Apple Watch + - Withings ScanWatch) collapse to a single canonical row per cluster. Field names mirror the iOS handoff contract: - `distanceM`, `activeEnergyKcal`, `avgHr`, `maxHr`." + source-priority ladder and clusters workouts of one sport whose starts lie less than five minutes after the + cluster's first start — twin workouts (Apple Watch + Withings ScanWatch) collapse to a single canonical row per + cluster. A twin just outside `since` / `until` still decides which row inside the range is canonical, so the + list agrees with `canonicalId` on the detail. Field names mirror the iOS handoff contract: `distanceM`, + `activeEnergyKcal`, `avgHr`, `maxHr`." parameters: - in: query name: limit @@ -4902,7 +5027,7 @@ paths: schema: $ref: "#/components/schemas/ListWorkoutsResponse" "401": *a1 - "403": *a13 + "403": *a17 "422": *a4 "429": *a5 /api/workouts/{id}: @@ -4938,14 +5063,7 @@ paths: schema: $ref: "#/components/schemas/GetWorkoutResponse" "401": *a1 - "403": &a19 - description: 'Module disabled for this account: the user (or operator) has the module turned off. `meta.errorCode` = - `module.disabled` and `meta.module` carries the disabled module key (e.g. "sleep"). Returned even for a - valid Bearer token. Clients hide the whole module surface end-to-end rather than retry.' - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorEnvelope" + "403": *a11 "404": description: Workout not found (or owned by another user). content: @@ -4976,6 +5094,7 @@ paths: schema: $ref: "#/components/schemas/DeleteWorkoutResponse" "401": *a1 + "403": *a9 "404": description: Workout not found (or owned by another user). content: @@ -5038,6 +5157,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 + "403": *a9 "409": *a7 "413": description: Request body exceeds the 5 MB ceiling. @@ -5095,7 +5215,7 @@ paths: schema: $ref: "#/components/schemas/ActivityCalendarEnvelope" "401": *a1 - "403": *a13 + "403": *a17 "422": *a4 "429": *a5 /api/activity/goals: @@ -5113,6 +5233,7 @@ paths: schema: $ref: "#/components/schemas/ActivityGoalsEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 patch: @@ -5135,8 +5256,9 @@ paths: schema: $ref: "#/components/schemas/ActivityGoalsPatchEnvelope" "401": *a1 + "403": *a9 "422": *a4 - "429": *a12 + "429": *a16 /api/measurements/provenance: get: tags: @@ -5261,7 +5383,7 @@ paths: schema: $ref: "#/components/schemas/DayProvenanceEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 /api/measurements/provenance/compare: @@ -5439,7 +5561,7 @@ paths: schema: $ref: "#/components/schemas/SourceComparisonEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 /api/devices: @@ -5463,6 +5585,7 @@ paths: schema: $ref: "#/components/schemas/DeviceRegisterResponse" "401": *a1 + "403": *a9 "409": description: Device or APNs token already registered to another user. content: @@ -5497,6 +5620,7 @@ paths: schema: $ref: "#/components/schemas/DeviceRevokeEnvelope" "401": *a1 + "403": *a9 "404": description: No such device for this caller — also the answer for one already revoked, so a retry after a successful revoke is a 404 rather than an idempotent 200. @@ -5549,7 +5673,7 @@ paths: schema: $ref: "#/components/schemas/MedicationIntakeAggregateEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "422": description: The query failed validation — a missing or unknown `scope`, or a `days` outside 1..365. Multi-issue envelope; an audit breadcrumb is filed under the RECORD as `medications.intake.list.validation-failed` with @@ -5639,9 +5763,11 @@ paths: "401": *a1 "403": description: >- - Refused: a delegate tried to snooze, or to change a dose the owner had already recorded as taken or skipped - (`meta.errorCode` = `sharing.not_permitted`). Both stay open to the owner, and the attempt is filed on the - owner's activity trail. + `meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: Refused: a delegate tried to + snooze, or to change a dose the owner had already recorded as taken or skipped (`meta.errorCode` = + `sharing.not_permitted`). Both stay open to the owner, and the attempt is filed on the owner's activity + trail. Refused: this request named a record the caller may not act on (`meta.errorCode` = `sharing.access.denied`). @@ -5677,7 +5803,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": *a12 + "429": *a16 /api/medications/{id}/glp1: get: tags: @@ -5702,7 +5828,7 @@ paths: schema: $ref: "#/components/schemas/Glp1DetailResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "404": description: Medication not found (or owned by another account). content: @@ -5786,7 +5912,7 @@ paths: schema: $ref: "#/components/schemas/Glp1PostResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "404": description: Medication not found (or owned by another account). content: @@ -5821,7 +5947,9 @@ paths: $ref: "#/components/schemas/MedicationApiEndpointStateEnvelope" "401": *a1 "403": - description: The operator has disabled the API globally. + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: The operator has disabled the API + globally." content: application/json: schema: @@ -5875,7 +6003,9 @@ paths: $ref: "#/components/schemas/MedicationApiEndpointCreatedEnvelope" "401": *a1 "403": - description: The operator has disabled the API globally. + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: The operator has disabled the API + globally." content: application/json: schema: @@ -5921,7 +6051,7 @@ paths: schema: $ref: "#/components/schemas/ReminderPhaseConfigEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "404": description: Medication not found (or owned by another account). content: @@ -5959,6 +6089,7 @@ paths: schema: $ref: "#/components/schemas/ReminderPhaseConfigSavedEnvelope" "401": *a1 + "403": *a9 "404": description: Medication not found (or owned by another account). content: @@ -5987,6 +6118,7 @@ paths: schema: $ref: "#/components/schemas/ReminderPhaseConfigResetEnvelope" "401": *a1 + "403": *a9 "404": description: Medication not found (or owned by another account). content: @@ -6037,7 +6169,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationSideEffectsEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "404": description: Medication not found (or owned by another account). content: @@ -6076,7 +6208,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationSideEffectEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "404": description: Medication not found (or owned by another account). content: @@ -6121,7 +6253,7 @@ paths: schema: $ref: "#/components/schemas/DeleteMedicationSideEffectEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "404": description: No such entry for this medication and this record. An unknown id, another medication's entry and another record's entry are indistinguishable. @@ -6169,7 +6301,7 @@ paths: schema: $ref: "#/components/schemas/UpdateMedicationIntakeEventEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "404": description: No live event with that id under this medication and record. An unknown id and an already-tombstoned one are indistinguishable. @@ -6217,7 +6349,7 @@ paths: schema: $ref: "#/components/schemas/DeleteMedicationIntakeEventEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "404": description: No event with that id under this medication and record. content: @@ -6260,7 +6392,7 @@ paths: schema: $ref: "#/components/schemas/BulkDeleteMedicationIntakeEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "404": description: Medication not found (or owned by another account). content: @@ -6302,7 +6434,7 @@ paths: schema: $ref: "#/components/schemas/PurgeMedicationIntakeEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "404": description: Medication not found (or owned by another account). content: @@ -6347,7 +6479,7 @@ paths: schema: $ref: "#/components/schemas/MedicationIntakeImportAcceptedEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "404": description: Medication not found (or owned by another account). content: @@ -6407,6 +6539,7 @@ paths: schema: $ref: "#/components/schemas/BulkMedicationIntakeResponseEnvelope" "401": *a1 + "403": *a9 "409": *a7 "422": *a4 "429": *a5 @@ -6427,7 +6560,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationsResponse" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 post: @@ -6459,7 +6592,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationResponse" "401": *a1 - "403": *a11 + "403": *a15 "409": *a7 "422": *a4 "429": *a5 @@ -6479,7 +6612,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationCategoriesResponse" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 post: @@ -6506,7 +6639,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationCategoryResponse" "401": *a1 - "403": *a11 + "403": *a15 "409": *a7 "422": *a4 "429": *a5 @@ -6539,6 +6672,7 @@ paths: schema: $ref: "#/components/schemas/UpdateMedicationCategoryResponse" "401": *a1 + "403": *a9 "404": description: Not one of the record's own categories. content: @@ -6570,6 +6704,7 @@ paths: schema: $ref: "#/components/schemas/DeleteMedicationCategoryResponse" "401": *a1 + "403": *a9 "404": description: Not one of the record's own categories. content: @@ -6598,6 +6733,7 @@ paths: schema: $ref: "#/components/schemas/MedicationCoursesResponse" "401": *a1 + "403": *a9 "404": description: Medication not found (or another user's). content: @@ -6635,6 +6771,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationCourseResponse" "401": *a1 + "403": *a9 "404": description: Medication not found (or another user's). content: @@ -6676,6 +6813,7 @@ paths: schema: $ref: "#/components/schemas/UpdateMedicationCourseResponse" "401": *a1 + "403": *a9 "404": description: Course not found (`medications.course.notFound`), or another user's. content: @@ -6709,6 +6847,7 @@ paths: schema: $ref: "#/components/schemas/DeleteMedicationCourseResponse" "401": *a1 + "403": *a9 "404": description: Course not found (`medications.course.notFound`), or another user's. content: @@ -6733,7 +6872,7 @@ paths: schema: $ref: "#/components/schemas/MedicationListLayoutResponse" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 put: @@ -6759,6 +6898,7 @@ paths: schema: $ref: "#/components/schemas/MedicationListLayoutSaved" "401": *a1 + "403": *a9 "409": description: Medications layout changed since it was loaded (optimistic-concurrency conflict). No write happened. Re-read the resource, re-apply the user's change against the fresh state, and resend with the new token. @@ -6767,7 +6907,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": &a15 + "422": &a19 description: Request validation failed. When the body carried a `baseUpdatedAt` that could not be parsed as an ISO-8601 timestamp — including an explicit `null` — `meta.errorCode` = `invalid_base_updated_at` and nothing was written. Omit the key entirely for the unconditional write; do not send `null` for it. @@ -6789,6 +6929,7 @@ paths: schema: $ref: "#/components/schemas/MedicationListLayoutReset" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/medications/{id}: @@ -6812,7 +6953,7 @@ paths: schema: $ref: "#/components/schemas/GetMedicationResponse" "401": *a1 - "403": *a11 + "403": *a15 "404": description: Medication not found (or owned by another user). content: @@ -6853,7 +6994,7 @@ paths: schema: $ref: "#/components/schemas/UpdateMedicationResponse" "401": *a1 - "403": *a11 + "403": *a15 "404": description: Medication not found (or owned by another user). content: @@ -6882,6 +7023,7 @@ paths: schema: $ref: "#/components/schemas/DeleteMedicationResponse" "401": *a1 + "403": *a9 "404": description: Medication not found (or owned by another user). content: @@ -6926,7 +7068,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationIntakeResponse" "401": *a1 - "403": *a11 + "403": *a15 "404": description: Medication not found (or owned by another user). content: @@ -6944,7 +7086,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": *a12 + "429": *a16 get: tags: - Medications @@ -7009,7 +7151,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationIntakeEventsResponse" "401": *a1 - "403": *a11 + "403": *a15 "404": description: Medication not found (or owned by another user). content: @@ -7042,7 +7184,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationInventoryResponse" "401": *a1 - "403": *a11 + "403": *a15 "404": description: Medication not found (or owned by another user). content: @@ -7078,7 +7220,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationInventoryItemResponse" "401": *a1 - "403": *a11 + "403": *a15 "404": description: Medication not found (or owned by another user). content: @@ -7122,7 +7264,7 @@ paths: schema: $ref: "#/components/schemas/UpdateMedicationInventoryItemResponse" "401": *a1 - "403": *a11 + "403": *a15 "404": description: Inventory item not found (or owned by another user / medication). content: @@ -7157,7 +7299,7 @@ paths: schema: $ref: "#/components/schemas/DeleteMedicationInventoryItemResponse" "401": *a1 - "403": *a11 + "403": *a15 "404": description: Inventory item not found (or owned by another user / medication). content: @@ -7193,14 +7335,16 @@ paths: $ref: "#/components/schemas/MedicationExtractResponse" "401": *a1 "403": - description: "AI refusal: the `medicationExtract` capability is unavailable for this record. The envelope carries - `meta.errorCode`, `meta.capability` and `meta.reason` (one of `AiUnavailableReason`). - `assistant.disabled.documentAi` — the operator turned off reading documents, or every AI feature (the master - switch). `ai.record.notPermitted` — AI work is not admitted inside this record (a delegate acting in - somebody else's record). `consent.ai.required` — the provider this request would reach leaves the machine - and the record holds no active `ai_extraction` or `ai_full` receipt; only a self-hosted model is exempt. The - question is answered for the provider actually picked, so it can differ from the published capability when a - text-mode read takes a different provider than a vision read would." + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: AI refusal: the + `medicationExtract` capability is unavailable for this record. The envelope carries `meta.errorCode`, + `meta.capability` and `meta.reason` (one of `AiUnavailableReason`). `assistant.disabled.documentAi` — the + operator turned off reading documents, or every AI feature (the master switch). `ai.record.notPermitted` — + AI work is not admitted inside this record (a delegate acting in somebody else's record). + `consent.ai.required` — the provider this request would reach leaves the machine and the record holds no + active `ai_extraction` or `ai_full` receipt; only a self-hosted model is exempt. The question is answered + for the provider actually picked, so it can differ from the published capability when a text-mode read takes + a different provider than a vision read would." content: application/json: schema: @@ -7253,7 +7397,7 @@ paths: schema: $ref: "#/components/schemas/GetMedicationCadenceResponse" "401": *a1 - "403": *a11 + "403": *a15 "404": description: Medication not found (or owned by another user). content: @@ -7302,7 +7446,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationComplianceResponse" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 /api/medications/{id}/compliance: @@ -7329,6 +7473,7 @@ paths: schema: $ref: "#/components/schemas/GetMedicationComplianceResponse" "401": *a1 + "403": *a9 "404": description: Medication not found (or owned by another user). content: @@ -7361,6 +7506,7 @@ paths: schema: $ref: "#/components/schemas/GetMedicationEfficacyResponse" "401": *a1 + "403": *a9 "404": description: Medication not found (or owned by another user). content: @@ -7397,6 +7543,7 @@ paths: schema: $ref: "#/components/schemas/SetMedicationEfficacyTargetResponse" "401": *a1 + "403": *a9 "404": description: Medication or biomarker not found. content: @@ -7453,6 +7600,7 @@ paths: schema: $ref: "#/components/schemas/GetDoseHistoryResponse" "401": *a1 + "403": *a9 "404": description: Medication not found (or owned by another user). content: @@ -7483,7 +7631,7 @@ paths: schema: $ref: "#/components/schemas/ListScheduleRevisionsResponse" "401": *a1 - "403": *a11 + "403": *a15 "404": description: Medication not found (or owned by another user). content: @@ -7521,7 +7669,7 @@ paths: schema: $ref: "#/components/schemas/CreateScheduleRevisionResponse" "401": *a1 - "403": *a11 + "403": *a15 "404": description: Medication not found (or owned by another user). content: @@ -7565,7 +7713,7 @@ paths: schema: $ref: "#/components/schemas/UpdateScheduleRevisionResponse" "401": *a1 - "403": *a11 + "403": *a15 "404": description: Medication or revision not found (or owned by another user). content: @@ -7606,7 +7754,7 @@ paths: schema: $ref: "#/components/schemas/DeleteScheduleRevisionResponse" "401": *a1 - "403": *a11 + "403": *a15 "404": description: Medication or revision not found (or owned by another user). content: @@ -7669,7 +7817,7 @@ paths: schema: $ref: "#/components/schemas/MedicationDoseHistoryImportQueuedEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "409": description: A dose-history import is already in progress. content: @@ -7731,7 +7879,7 @@ paths: schema: $ref: "#/components/schemas/MedicationIntakeImportJobStatusEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "404": description: No such job for this medication (or owned by another user). content: @@ -7765,7 +7913,7 @@ paths: schema: $ref: "#/components/schemas/MedicationIntakeImportJobStatusEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "404": description: No such job for this account (or owned by another user). content: @@ -7795,7 +7943,7 @@ paths: schema: $ref: "#/components/schemas/ProfileSummaryEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 /api/ai/test: @@ -7844,8 +7992,10 @@ paths: $ref: "#/components/schemas/AiTestEnvelope" "401": *a1 "403": - description: The operator's master AI switch is off (`meta.errorCode = "assistant.disabled.enabled"`), or the switches - could not be read (`ai.unavailable`). Nothing was sent. + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: The operator's master AI switch + is off (`meta.errorCode = \"assistant.disabled.enabled\"`), or the switches could not be read + (`ai.unavailable`). Nothing was sent." content: application/json: schema: @@ -7896,6 +8046,7 @@ paths: schema: $ref: "#/components/schemas/GetAiProviderResponse" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 patch: @@ -7939,6 +8090,7 @@ paths: schema: $ref: "#/components/schemas/PatchAiProviderResponse" "401": *a1 + "403": *a9 "413": description: Body exceeds 64 KiB. content: @@ -7978,6 +8130,7 @@ paths: schema: $ref: "#/components/schemas/GetCoachPrefsResponse" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 put: @@ -8000,8 +8153,9 @@ paths: application/json: schema: $ref: "#/components/schemas/PutCoachPrefsResponse" - "400": *a14 + "400": *a18 "401": *a1 + "403": *a9 "409": description: Coach preferences changed since it was loaded (optimistic-concurrency conflict). No write happened. Re-read the resource, re-apply the user's change against the fresh state, and resend with the new token. @@ -8010,7 +8164,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a15 + "422": *a19 "429": *a5 /api/auth/me/disable-coach: get: @@ -8027,6 +8181,7 @@ paths: schema: $ref: "#/components/schemas/GetDisableCoachResponse" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 patch: @@ -8048,8 +8203,9 @@ paths: application/json: schema: $ref: "#/components/schemas/PatchDisableCoachResponse" - "400": *a14 + "400": *a18 "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/auth/me/diabetes: @@ -8069,6 +8225,7 @@ paths: schema: $ref: "#/components/schemas/GetDiabetesResponse" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 patch: @@ -8092,6 +8249,7 @@ paths: schema: $ref: "#/components/schemas/PatchDiabetesResponse" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/auth/me/health-score-config: @@ -8111,6 +8269,7 @@ paths: schema: $ref: "#/components/schemas/HealthScoreConfigEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 patch: @@ -8139,6 +8298,7 @@ paths: schema: $ref: "#/components/schemas/PatchHealthScoreConfigEnvelope" "401": *a1 + "403": *a9 "409": description: Score selection changed since it was loaded (optimistic-concurrency conflict). No write happened. Re-read the resource, re-apply the user's change against the fresh state, and resend with the new token. @@ -8179,6 +8339,7 @@ paths: schema: $ref: "#/components/schemas/GetModulesResponse" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 patch: @@ -8203,8 +8364,9 @@ paths: application/json: schema: $ref: "#/components/schemas/PatchModulesResponse" - "400": *a14 + "400": *a18 "401": *a1 + "403": *a9 "409": description: Module preferences changed since it was loaded (optimistic-concurrency conflict). No write happened. Re-read the resource, re-apply the user's change against the fresh state, and resend with the new token. @@ -8213,7 +8375,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a15 + "422": *a19 "429": *a5 /api/auth/me/source-priority: get: @@ -8230,6 +8392,7 @@ paths: schema: $ref: "#/components/schemas/GetSourcePriorityResponse" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 put: @@ -9164,8 +9327,9 @@ paths: application/json: schema: $ref: "#/components/schemas/PutSourcePriorityResponse" - "400": *a14 + "400": *a18 "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/user/avatar: @@ -9198,6 +9362,7 @@ paths: schema: $ref: "#/components/schemas/UploadAvatarResponse" "401": *a1 + "403": *a9 "413": description: Upload exceeds the 2 MiB byte limit or the 2048×2048 dimension limit. content: @@ -9222,6 +9387,7 @@ paths: "204": description: Avatar cleared (or already empty). "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/user/avatar/{id}: @@ -9258,7 +9424,9 @@ paths: description: Raw WebP bytes. "401": *a1 "403": - description: Caller is not the avatar's owner. + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: Caller is not the avatar's + owner." content: application/json: schema: @@ -9286,6 +9454,7 @@ paths: schema: $ref: "#/components/schemas/GetProfileResponse" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 patch: @@ -9309,6 +9478,7 @@ paths: schema: $ref: "#/components/schemas/PatchProfileResponse" "401": *a1 + "403": *a9 "409": description: The requested email belongs to another account (`meta.errorCode` = `profile.update.emailInUse`). Nothing was written. Shared with PUT /api/auth/profile, which runs the same handler. @@ -9317,7 +9487,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 - "429": *a16 + "429": *a20 /api/auth/me/notification-prefs: get: tags: @@ -9334,6 +9504,7 @@ paths: schema: $ref: "#/components/schemas/GetNotificationPrefsResponse" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 patch: @@ -9359,8 +9530,9 @@ paths: application/json: schema: $ref: "#/components/schemas/PatchNotificationPrefsResponse" - "400": *a14 + "400": *a18 "401": *a1 + "403": *a9 "409": description: Notification preferences changed since it was loaded (optimistic-concurrency conflict). No write happened. Re-read the resource, re-apply the user's change against the fresh state, and resend with the new token. @@ -9369,7 +9541,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a15 + "422": *a19 "429": *a5 /api/auth/me/report-selection: get: @@ -9387,6 +9559,7 @@ paths: schema: $ref: "#/components/schemas/GetReportSelectionResponseEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 put: @@ -9411,8 +9584,9 @@ paths: application/json: schema: $ref: "#/components/schemas/PutReportSelectionResponseEnvelope" - "400": *a14 + "400": *a18 "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/auth/me/unit-preference: @@ -9432,6 +9606,7 @@ paths: schema: $ref: "#/components/schemas/GetUnitPreferenceEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 patch: @@ -9458,13 +9633,26 @@ paths: application/json: schema: $ref: "#/components/schemas/PatchUnitPreferenceEnvelope" + "400": &a21 + description: The request body is not parseable JSON. Nothing was written. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "413": + "403": *a9 + "413": &a22 description: Request body exceeds 1024 bytes. Nothing was written. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" + "415": &a23 + description: "The request did not carry `Content-Type: application/json`. Nothing was read and nothing was written." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/auth/me/glucose-unit: @@ -9484,6 +9672,7 @@ paths: schema: $ref: "#/components/schemas/GetGlucoseUnitEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 patch: @@ -9514,13 +9703,11 @@ paths: application/json: schema: $ref: "#/components/schemas/PatchGlucoseUnitEnvelope" + "400": *a21 "401": *a1 - "413": - description: Request body exceeds 1024 bytes. Nothing was written. - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorEnvelope" + "403": *a9 + "413": *a22 + "415": *a23 "422": *a4 "429": *a5 /api/auth/me/documents-auto-ai-read: @@ -9539,6 +9726,7 @@ paths: schema: $ref: "#/components/schemas/GetDocumentsAutoAiReadEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 patch: @@ -9575,13 +9763,11 @@ paths: application/json: schema: $ref: "#/components/schemas/PatchDocumentsAutoAiReadEnvelope" + "400": *a21 "401": *a1 - "413": - description: Request body exceeds 1024 bytes. Nothing was written. - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorEnvelope" + "403": *a9 + "413": *a22 + "415": *a23 "422": *a4 "429": *a5 /api/auth/me/injection-site-prefs: @@ -9600,6 +9786,7 @@ paths: schema: $ref: "#/components/schemas/GetInjectionSitePrefsEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 patch: @@ -9628,6 +9815,7 @@ paths: schema: $ref: "#/components/schemas/PatchInjectionSitePrefsEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/user/thresholds: @@ -9652,6 +9840,7 @@ paths: schema: $ref: "#/components/schemas/GetThresholdsEnvelope" "401": *a1 + "403": *a9 "404": description: The authenticated user row could not be loaded. In practice unreachable — the session that resolved the caller was validated against that row. @@ -9689,6 +9878,7 @@ paths: schema: $ref: "#/components/schemas/PutThresholdsEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 delete: @@ -9732,6 +9922,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 + "403": *a9 "422": description: '`metric` was omitted and the body did not carry `{ "confirm": "RESET_THRESHOLDS" }`. Nothing was reset.' content: @@ -9760,6 +9951,7 @@ paths: schema: $ref: "#/components/schemas/DeviceListEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/auth/me/devices/{id}: @@ -9790,6 +9982,7 @@ paths: schema: $ref: "#/components/schemas/DevicePatchEnvelope" "401": *a1 + "403": *a9 "404": description: No such device for this account. content: @@ -9828,6 +10021,7 @@ paths: schema: $ref: "#/components/schemas/AccountDeviceRevokeEnvelope" "401": *a1 + "403": *a9 "404": description: No such device for this account. content: @@ -9854,6 +10048,7 @@ paths: schema: $ref: "#/components/schemas/GetLabsLocalOcrEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 patch: @@ -9874,13 +10069,11 @@ paths: application/json: schema: $ref: "#/components/schemas/PatchLabsLocalOcrEnvelope" + "400": *a21 "401": *a1 - "413": - description: Request body exceeds 1024 bytes. Nothing was written. - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorEnvelope" + "403": *a9 + "413": *a22 + "415": *a23 "422": *a4 "429": *a5 /api/auth/me/workout-routes: @@ -9899,6 +10092,7 @@ paths: schema: $ref: "#/components/schemas/GetWorkoutRoutesEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 patch: @@ -9920,13 +10114,11 @@ paths: application/json: schema: $ref: "#/components/schemas/PatchWorkoutRoutesEnvelope" + "400": *a21 "401": *a1 - "413": - description: Request body exceeds 1024 bytes. Nothing was written. - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorEnvelope" + "403": *a9 + "413": *a22 + "415": *a23 "422": *a4 "429": *a5 /api/auth/me/use-central-codex: @@ -9944,6 +10136,7 @@ paths: schema: $ref: "#/components/schemas/GetUseCentralCodexEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 patch: @@ -9971,13 +10164,11 @@ paths: application/json: schema: $ref: "#/components/schemas/PatchUseCentralCodexEnvelope" + "400": *a21 "401": *a1 - "413": - description: Request body exceeds 1024 bytes. Nothing was written. - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorEnvelope" + "403": *a9 + "413": *a22 + "415": *a23 "422": *a4 "429": *a5 /api/auth/me/locale: @@ -10017,7 +10208,9 @@ paths: $ref: "#/components/schemas/LocalePutEnvelope" "401": *a1 "403": - description: A selector header was attached to an actor surface (`meta.errorCode` = `sharing.not_permitted`). + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: A selector header was attached to + an actor surface (`meta.errorCode` = `sharing.not_permitted`)." content: application/json: schema: @@ -10053,6 +10246,7 @@ paths: schema: $ref: "#/components/schemas/TimezonePutEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/auth/me/passkey-nudge/dismiss: @@ -10070,6 +10264,7 @@ paths: schema: $ref: "#/components/schemas/PasskeyNudgeDismissEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/insights/chat: @@ -10116,6 +10311,7 @@ paths: schema: $ref: "#/components/schemas/CoachConversationsPageResponse" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 post: @@ -10160,11 +10356,13 @@ paths: $ref: "#/components/schemas/CoachStreamEvent" "401": *a1 "403": - description: "The `coach` AI capability is unavailable for this record. `meta.capability` = `coach` and `meta.reason` - says which layer refused: the operator's switch (`assistant.disabled.coach`, or `assistant.disabled.enabled` - for the master switch), an owning module or the person's own AI opt-out (`module.disabled` with - `meta.module`), a delegate inside somebody else's record (`ai.record.notPermitted`), or a missing consent - receipt (`consent.ai.required`). Nothing was generated, read from a stored model answer, or enqueued." + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: The `coach` AI capability is + unavailable for this record. `meta.capability` = `coach` and `meta.reason` says which layer refused: the + operator's switch (`assistant.disabled.coach`, or `assistant.disabled.enabled` for the master switch), an + owning module or the person's own AI opt-out (`module.disabled` with `meta.module`), a delegate inside + somebody else's record (`ai.record.notPermitted`), or a missing consent receipt (`consent.ai.required`). + Nothing was generated, read from a stored model answer, or enqueued." content: application/json: schema: @@ -10177,7 +10375,7 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "503": &a18 + "503": &a26 description: "The AI capability could not be resolved (its inputs could not be read) and the answer failed closed: `meta.errorCode` = `ai.unavailable`, `meta.reason` = `check_failed`. Retry later; no data depends on it." content: @@ -10217,6 +10415,7 @@ paths: schema: $ref: "#/components/schemas/CoachMessageResultsResponse" "401": *a1 + "403": *a9 "404": description: Conversation or message not found, or not owned by the caller. content: @@ -10260,6 +10459,7 @@ paths: schema: $ref: "#/components/schemas/CoachMessageTrailResponse" "401": *a1 + "403": *a9 "404": description: Conversation or message not found, or not owned by the caller. content: @@ -10292,6 +10492,7 @@ paths: schema: $ref: "#/components/schemas/CoachConversationDetailResponse" "401": *a1 + "403": *a9 "404": description: Conversation not found or not owned by the caller. content: @@ -10331,6 +10532,7 @@ paths: schema: $ref: "#/components/schemas/CoachConversationRenamed" "401": *a1 + "403": *a9 "404": description: Conversation not found or not owned by the caller. content: @@ -10366,6 +10568,7 @@ paths: schema: $ref: "#/components/schemas/CoachConversationDeleted" "401": *a1 + "403": *a9 "404": description: Conversation not found or not owned by the caller. content: @@ -10408,20 +10611,21 @@ paths: description: "SSE frames: `data: \\n\\n`. See the operation description for the per-`type` frame shapes." "401": *a1 "403": - description: "AI refusal: the `documentAi` capability is unavailable for this record. The envelope carries - `meta.errorCode`, `meta.capability` and `meta.reason` (one of `AiUnavailableReason`). - `assistant.disabled.documentAi` — the operator turned off reading documents, or every AI feature (the master - switch). `module.disabled` with `meta.module = \"inboundDocuments\"` — the module is off for this record. - `ai.record.notPermitted` — AI work is not admitted inside this record (a delegate acting in somebody else's - record). `consent.ai.required` — the provider this request would reach leaves the machine and the record - holds no active `ai_extraction` or `ai_full` receipt; only a self-hosted model is exempt. The question is - answered for the provider actually picked, so it can differ from the published capability when a text-mode - read takes a different provider than a vision read would. The route also answers under `coach`, with the - same envelope and that capability's own codes (`assistant.disabled.coach`, `module.disabled` with - `meta.module = \"coach\"` when the Coach is off for the record). A 422 (`stdResponses`) covers an un-indexed - / unavailable attachment (`coach.fenced.attachmentUnavailable`), the attachment cap - (`coach.fenced.attachmentLimit`), or `attachmentIds` sent with a `conversationId` - (`coach.fenced.attachmentConflict`)." + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: AI refusal: the `documentAi` + capability is unavailable for this record. The envelope carries `meta.errorCode`, `meta.capability` and + `meta.reason` (one of `AiUnavailableReason`). `assistant.disabled.documentAi` — the operator turned off + reading documents, or every AI feature (the master switch). `module.disabled` with `meta.module = + \"inboundDocuments\"` — the module is off for this record. `ai.record.notPermitted` — AI work is not + admitted inside this record (a delegate acting in somebody else's record). `consent.ai.required` — the + provider this request would reach leaves the machine and the record holds no active `ai_extraction` or + `ai_full` receipt; only a self-hosted model is exempt. The question is answered for the provider actually + picked, so it can differ from the published capability when a text-mode read takes a different provider than + a vision read would. The route also answers under `coach`, with the same envelope and that capability's own + codes (`assistant.disabled.coach`, `module.disabled` with `meta.module = \"coach\"` when the Coach is off + for the record). A 422 (`stdResponses`) covers an un-indexed / unavailable attachment + (`coach.fenced.attachmentUnavailable`), the attachment cap (`coach.fenced.attachmentLimit`), or + `attachmentIds` sent with a `conversationId` (`coach.fenced.attachmentConflict`)." content: application/json: schema: @@ -10469,17 +10673,19 @@ paths: $ref: "#/components/schemas/CoachAttachmentsResponse" "401": *a1 "403": - description: "AI refusal: the `documentAi` capability is unavailable for this record. The envelope carries - `meta.errorCode`, `meta.capability` and `meta.reason` (one of `AiUnavailableReason`). - `assistant.disabled.documentAi` — the operator turned off reading documents, or every AI feature (the master - switch). `module.disabled` with `meta.module = \"inboundDocuments\"` — the module is off for this record. - `ai.record.notPermitted` — AI work is not admitted inside this record (a delegate acting in somebody else's - record). `consent.ai.required` — the provider this request would reach leaves the machine and the record - holds no active `ai_extraction` or `ai_full` receipt; only a self-hosted model is exempt. The question is - answered for the provider actually picked, so it can differ from the published capability when a text-mode - read takes a different provider than a vision read would. The route also answers under `coach`, with the - same envelope and that capability's own codes (`assistant.disabled.coach`, `module.disabled` with - `meta.module = \"coach\"` when the Coach is off for the record)." + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: AI refusal: the `documentAi` + capability is unavailable for this record. The envelope carries `meta.errorCode`, `meta.capability` and + `meta.reason` (one of `AiUnavailableReason`). `assistant.disabled.documentAi` — the operator turned off + reading documents, or every AI feature (the master switch). `module.disabled` with `meta.module = + \"inboundDocuments\"` — the module is off for this record. `ai.record.notPermitted` — AI work is not + admitted inside this record (a delegate acting in somebody else's record). `consent.ai.required` — the + provider this request would reach leaves the machine and the record holds no active `ai_extraction` or + `ai_full` receipt; only a self-hosted model is exempt. The question is answered for the provider actually + picked, so it can differ from the published capability when a text-mode read takes a different provider than + a vision read would. The route also answers under `coach`, with the same envelope and that capability's own + codes (`assistant.disabled.coach`, `module.disabled` with `meta.module = \"coach\"` when the Coach is off + for the record)." content: application/json: schema: @@ -10529,6 +10735,7 @@ paths: schema: $ref: "#/components/schemas/CoachAttachmentsResponseDetach" "401": *a1 + "403": *a9 "404": description: Attachment or conversation not found / not owned. content: @@ -10553,6 +10760,7 @@ paths: schema: $ref: "#/components/schemas/InsightsLayout" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 put: @@ -10577,6 +10785,7 @@ paths: schema: $ref: "#/components/schemas/InsightsLayoutSaved" "401": *a1 + "403": *a9 "409": description: Insights layout changed since it was loaded (optimistic-concurrency conflict). No write happened. Re-read the resource, re-apply the user's change against the fresh state, and resend with the new token. @@ -10585,7 +10794,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a15 + "422": *a19 "429": *a5 delete: tags: @@ -10600,6 +10809,7 @@ paths: schema: $ref: "#/components/schemas/InsightsLayoutReset" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/insights/coach/facts: @@ -10621,6 +10831,7 @@ paths: schema: $ref: "#/components/schemas/CoachFactsList" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 post: @@ -10653,9 +10864,11 @@ paths: $ref: "#/components/schemas/CoachFactCreated" "401": *a1 "403": - description: 'Module disabled for this account: the user (or operator) has the module turned off. `meta.errorCode` = - `module.disabled` and `meta.module` carries the disabled module key (e.g. "sleep"). Returned even for a - valid Bearer token. Clients hide the whole module surface end-to-end rather than retry.' + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: Module disabled for this account: + the user (or operator) has the module turned off. `meta.errorCode` = `module.disabled` and `meta.module` + carries the disabled module key (e.g. \"sleep\"). Returned even for a valid Bearer token. Clients hide the + whole module surface end-to-end rather than retry." content: application/json: schema: @@ -10682,6 +10895,7 @@ paths: schema: $ref: "#/components/schemas/CoachFactsCleared" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/insights/coach/facts/{id}: @@ -10715,6 +10929,7 @@ paths: schema: $ref: "#/components/schemas/CoachFactUpdated" "401": *a1 + "403": *a9 "404": description: Fact not found or not owned by the caller. content: @@ -10738,6 +10953,7 @@ paths: schema: $ref: "#/components/schemas/CoachFactDeleted" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/insights/coach/nudge-status: @@ -10760,7 +10976,7 @@ paths: schema: $ref: "#/components/schemas/CoachNudgeStatus" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 /api/insights/coach/seen: @@ -10781,6 +10997,7 @@ paths: schema: $ref: "#/components/schemas/CoachSeenResponse" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/coach/about-me: @@ -10803,6 +11020,7 @@ paths: schema: $ref: "#/components/schemas/GetCoachAboutMeResponse" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 put: @@ -10870,6 +11088,7 @@ paths: schema: $ref: "#/components/schemas/PutCoachAboutMeResponse" "401": *a1 + "403": *a9 "409": description: Self-context changed since it was loaded (optimistic-concurrency conflict). No write happened. Re-read the resource, re-apply the user's change against the fresh state, and resend with the new token. @@ -10878,7 +11097,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a15 + "422": *a19 "429": *a5 /api/anamnesis/facts: get: @@ -10897,7 +11116,7 @@ paths: schema: $ref: "#/components/schemas/GetAnamnesisFactsResponse" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 post: @@ -11315,6 +11534,7 @@ paths: schema: $ref: "#/components/schemas/CreateAnamnesisFactResponse" "401": *a1 + "403": *a9 "409": description: A current revision already exists for this fact kind. `meta.errorCode` = `anamnesis.fact.currentExists`. content: @@ -11370,7 +11590,8 @@ paths: schema: $ref: "#/components/schemas/CorrectAnamnesisFactResponse" "401": *a1 - "404": &a17 + "403": *a9 + "404": &a24 description: No current revision with this id exists for the authenticated account. content: application/json: @@ -11406,7 +11627,8 @@ paths: schema: $ref: "#/components/schemas/RemoveAnamnesisFactResponse" "401": *a1 - "404": *a17 + "403": *a9 + "404": *a24 "409": description: Anamnesis fact changed since it was loaded (optimistic-concurrency conflict). No write happened. Re-read the resource, re-apply the user's change against the fresh state, and resend with the new token. @@ -11433,6 +11655,7 @@ paths: schema: $ref: "#/components/schemas/GetEmergencyProfileResponse" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 patch: @@ -11455,6 +11678,7 @@ paths: schema: $ref: "#/components/schemas/UpdateEmergencyProfileResponse" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/coach/about-me/questions: @@ -11473,6 +11697,7 @@ paths: schema: $ref: "#/components/schemas/GetCoachAboutMeQuestionsResponse" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 delete: @@ -11499,6 +11724,7 @@ paths: schema: $ref: "#/components/schemas/DeleteCoachAboutMeQuestionsResponse" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/coach/about-me/adopt: @@ -11548,9 +11774,11 @@ paths: $ref: "#/components/schemas/CoachAboutMeAdoptResponse" "401": *a1 "403": - description: 'Module disabled for this account: the user (or operator) has the module turned off. `meta.errorCode` = - `module.disabled` and `meta.module` carries the disabled module key (e.g. "sleep"). Returned even for a - valid Bearer token. Clients hide the whole module surface end-to-end rather than retry.' + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: Module disabled for this account: + the user (or operator) has the module turned off. `meta.errorCode` = `module.disabled` and `meta.module` + carries the disabled module key (e.g. \"sleep\"). Returned even for a valid Bearer token. Clients hide the + whole module surface end-to-end rather than retry." content: application/json: schema: @@ -11623,6 +11851,7 @@ paths: schema: $ref: "#/components/schemas/CoachPlansList" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/coach/plans/{id}: @@ -11657,7 +11886,8 @@ paths: $ref: "#/components/schemas/CoachPlanUpdated" "401": *a1 "403": - description: Coach surface disabled. + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: Coach surface disabled." content: application/json: schema: @@ -11694,7 +11924,8 @@ paths: $ref: "#/components/schemas/CoachPlanDeleted" "401": *a1 "403": - description: Coach surface disabled. + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: Coach surface disabled." content: application/json: schema: @@ -11733,7 +11964,9 @@ paths: $ref: "#/components/schemas/CoachReminderSuggestionResultCreated" "401": *a1 "403": - description: Coach surface (or the cadence's module) disabled. + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: Coach surface (or the cadence's + module) disabled." content: application/json: schema: @@ -11766,7 +11999,7 @@ paths: schema: $ref: "#/components/schemas/CoachRemindersList" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 post: @@ -11792,7 +12025,8 @@ paths: $ref: "#/components/schemas/CoachReminderCreated" "401": *a1 "403": - description: Coach surface disabled. + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: Coach surface disabled." content: application/json: schema: @@ -11836,7 +12070,8 @@ paths: $ref: "#/components/schemas/CoachReminderUpdated" "401": *a1 "403": - description: Coach surface disabled. + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: Coach surface disabled." content: application/json: schema: @@ -11872,6 +12107,7 @@ paths: schema: $ref: "#/components/schemas/CoachReminderDeleted" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/coach/suggested-actions: @@ -11900,7 +12136,8 @@ paths: $ref: "#/components/schemas/CoachSuggestedActionResultCreated" "401": *a1 "403": - description: Coach surface disabled. + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: Coach surface disabled." content: application/json: schema: @@ -11930,6 +12167,12 @@ paths: schema: $ref: "#/components/schemas/AdminInviteList" "401": *a1 + "403": &a25 + description: The session is valid but its account is not an administrator. `meta.errorCode` = `auth.admin.required`. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 post: @@ -11965,6 +12208,7 @@ paths: schema: $ref: "#/components/schemas/AdminInviteCreated" "401": *a1 + "403": *a25 "422": *a4 "429": *a5 /api/admin/invites/{id}: @@ -11989,6 +12233,7 @@ paths: schema: $ref: "#/components/schemas/AdminInviteRevoked" "401": *a1 + "403": *a25 "422": *a4 "429": *a5 /api/insights/chat/messages/{id}/feedback: @@ -12013,6 +12258,7 @@ paths: schema: $ref: "#/components/schemas/CoachMessageFeedbackResponse" "401": *a1 + "403": *a9 "404": description: Message not found or not owned by the caller. content: @@ -12043,6 +12289,7 @@ paths: schema: $ref: "#/components/schemas/AdminMeasurementMaintenanceStatusResponse" "401": *a1 + "403": *a25 "422": *a4 "429": *a5 post: @@ -12068,7 +12315,8 @@ paths: $ref: "#/components/schemas/AdminMeasurementMaintenanceResponse" "401": *a1 "403": - description: Caller is not an admin. + description: "`meta.errorCode` = `auth.admin.required` when the session's account is not an administrator; the admin + gate answers it before the route's own checks run. Otherwise: Caller is not an admin." content: application/json: schema: @@ -12118,7 +12366,8 @@ paths: $ref: "#/components/schemas/AdminReminderCheckResponse" "401": *a1 "403": - description: Caller is not an admin. + description: "`meta.errorCode` = `auth.admin.required` when the session's account is not an administrator; the admin + gate answers it before the route's own checks run. Otherwise: Caller is not an admin." content: application/json: schema: @@ -12142,7 +12391,8 @@ paths: $ref: "#/components/schemas/AdminDiagnosticResponse" "401": *a1 "403": - description: Caller is not an admin. + description: "`meta.errorCode` = `auth.admin.required` when the session's account is not an administrator; the admin + gate answers it before the route's own checks run. Otherwise: Caller is not an admin." content: application/json: schema: @@ -12169,7 +12419,7 @@ paths: schema: $ref: "#/components/schemas/DashboardSnapshotResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 /api/insights/comprehensive: @@ -12191,7 +12441,7 @@ paths: schema: $ref: "#/components/schemas/InsightsComprehensiveResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 /api/insights/cards: @@ -12212,7 +12462,7 @@ paths: schema: $ref: "#/components/schemas/InsightsCardsEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 /api/insights/pregenerate: @@ -12244,6 +12494,7 @@ paths: schema: $ref: "#/components/schemas/InsightsPregenerateResponseEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/analytics: @@ -12294,6 +12545,7 @@ paths: schema: $ref: "#/components/schemas/AnalyticsEnvelope" "401": *a1 + "403": *a9 "422": description: "`slice` carried a value outside the closed set. `meta.errorCode` = `analytics.invalid_query`, with the offending path under `details.issues`." @@ -12329,12 +12581,13 @@ paths: $ref: "#/components/schemas/InsightsGenerateReadEnvelope" "401": *a1 "403": - description: "The `briefing` AI capability is unavailable for this record. `meta.capability` = `briefing` and - `meta.reason` says which layer refused: the operator's switch (`assistant.disabled.briefing`, or - `assistant.disabled.enabled` for the master switch), an owning module or the person's own AI opt-out - (`module.disabled` with `meta.module`), a delegate inside somebody else's record (`ai.record.notPermitted`), - or a missing consent receipt (`consent.ai.required`). Nothing was generated, read from a stored model - answer, or enqueued." + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: The `briefing` AI capability is + unavailable for this record. `meta.capability` = `briefing` and `meta.reason` says which layer refused: the + operator's switch (`assistant.disabled.briefing`, or `assistant.disabled.enabled` for the master switch), an + owning module or the person's own AI opt-out (`module.disabled` with `meta.module`), a delegate inside + somebody else's record (`ai.record.notPermitted`), or a missing consent receipt (`consent.ai.required`). + Nothing was generated, read from a stored model answer, or enqueued." content: application/json: schema: @@ -12347,7 +12600,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 - "503": *a18 + "503": *a26 post: tags: - Insights @@ -12382,12 +12635,13 @@ paths: $ref: "#/components/schemas/InsightsGeneratePostEnvelope" "401": *a1 "403": - description: "The `briefing` AI capability is unavailable for this record. `meta.capability` = `briefing` and - `meta.reason` says which layer refused: the operator's switch (`assistant.disabled.briefing`, or - `assistant.disabled.enabled` for the master switch), an owning module or the person's own AI opt-out - (`module.disabled` with `meta.module`), a delegate inside somebody else's record (`ai.record.notPermitted`), - or a missing consent receipt (`consent.ai.required`). Nothing was generated, read from a stored model - answer, or enqueued." + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: The `briefing` AI capability is + unavailable for this record. `meta.capability` = `briefing` and `meta.reason` says which layer refused: the + operator's switch (`assistant.disabled.briefing`, or `assistant.disabled.enabled` for the master switch), an + owning module or the person's own AI opt-out (`module.disabled` with `meta.module`), a delegate inside + somebody else's record (`ai.record.notPermitted`), or a missing consent receipt (`consent.ai.required`). + Nothing was generated, read from a stored model answer, or enqueued." content: application/json: schema: @@ -12445,6 +12699,7 @@ paths: schema: $ref: "#/components/schemas/ProviderChainEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 put: @@ -12471,6 +12726,7 @@ paths: schema: $ref: "#/components/schemas/ProviderChainSavedEnvelope" "401": *a1 + "403": *a9 "422": description: The body was not a valid chain — an empty array, more entries than there are provider types, an unknown provider type, or the same type twice. A single-message envelope with no issue list. @@ -12506,6 +12762,7 @@ paths: schema: $ref: "#/components/schemas/InsightsFeedbackCreatedEnvelope" "401": *a1 + "403": *a9 "409": *a7 "422": description: The body failed validation. A single-message envelope (`Invalid feedback payload`) with no issue list — @@ -12549,7 +12806,7 @@ paths: schema: $ref: "#/components/schemas/CoachReadStripEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 /api/insights/narrative: @@ -12598,7 +12855,7 @@ paths: schema: $ref: "#/components/schemas/NarrativeEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 /api/insights/glp1-timeline: @@ -12635,7 +12892,7 @@ paths: schema: $ref: "#/components/schemas/Glp1TimelineEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 /api/insights/pulse/intraday: @@ -12671,7 +12928,7 @@ paths: schema: $ref: "#/components/schemas/IntradayPulseEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 /api/insights/settings: @@ -12690,6 +12947,7 @@ paths: schema: $ref: "#/components/schemas/InsightsSettingsEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 put: @@ -12715,6 +12973,7 @@ paths: schema: $ref: "#/components/schemas/InsightsSettingsUpdatedEnvelope" "401": *a1 + "403": *a9 "422": description: "`Invalid privacy mode` when `privacyMode` was a string outside the two accepted values, or `No changes` when the body carried nothing this endpoint writes. Single-message envelope with no issue list — this route @@ -12745,7 +13004,7 @@ paths: schema: $ref: "#/components/schemas/TargetsEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 /api/analytics/range: @@ -12787,6 +13046,7 @@ paths: schema: $ref: "#/components/schemas/AnalyticsRangeResponseEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/insights/blood-pressure-status: @@ -12818,7 +13078,7 @@ paths: schema: $ref: "#/components/schemas/BloodPressureStatusResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 /api/insights/pulse-status: @@ -12850,7 +13110,7 @@ paths: schema: $ref: "#/components/schemas/PulseStatusResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 /api/insights/weight-status: @@ -12882,7 +13142,7 @@ paths: schema: $ref: "#/components/schemas/WeightStatusResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 /api/insights/bmi-status: @@ -12914,7 +13174,7 @@ paths: schema: $ref: "#/components/schemas/BmiStatusResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 /api/insights/mood-status: @@ -12946,7 +13206,7 @@ paths: schema: $ref: "#/components/schemas/MoodStatusResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 /api/insights/medication-compliance-status: @@ -12979,7 +13239,7 @@ paths: schema: $ref: "#/components/schemas/MedicationComplianceStatusResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 /api/insights/metric-status: @@ -13074,7 +13334,7 @@ paths: schema: $ref: "#/components/schemas/MetricStatusResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 /api/insights/biomarker-assessment: @@ -13119,7 +13379,7 @@ paths: schema: $ref: "#/components/schemas/BiomarkerAssessmentResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 /api/insights/derived: @@ -13231,7 +13491,7 @@ paths: schema: $ref: "#/components/schemas/DerivedMetricResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 /api/insights/derived/batch: @@ -13271,7 +13531,7 @@ paths: schema: $ref: "#/components/schemas/DerivedBatchResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 /api/insights/score-history: @@ -13321,7 +13581,7 @@ paths: schema: $ref: "#/components/schemas/ScoreHistoryResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 /api/insights/glp1-plateau: @@ -13341,7 +13601,7 @@ paths: schema: $ref: "#/components/schemas/InsightsGlp1PlateauResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 /api/insights/ecg: @@ -13363,7 +13623,7 @@ paths: schema: $ref: "#/components/schemas/EcgListResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 post: @@ -13401,6 +13661,7 @@ paths: schema: $ref: "#/components/schemas/EcgIngestCreatedEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/insights/ecg/{id}: @@ -13440,7 +13701,7 @@ paths: schema: $ref: "#/components/schemas/EcgDetailResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "404": description: No ECG recording with that id for the authenticated user (existence sealed — a foreign id is indistinguishable from a missing one). @@ -13472,7 +13733,7 @@ paths: schema: $ref: "#/components/schemas/RhythmEventsResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 /api/insights/correlations: @@ -13494,7 +13755,7 @@ paths: schema: $ref: "#/components/schemas/CorrelationDiscoveryResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 /api/insights/patterns: @@ -13512,7 +13773,7 @@ paths: schema: $ref: "#/components/schemas/CorrelationPatternListResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 /api/insights/patterns/{id}: @@ -13542,7 +13803,7 @@ paths: schema: $ref: "#/components/schemas/UpdateCorrelationPatternResponseEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "404": description: No current pattern with that id for this account. content: @@ -13567,7 +13828,7 @@ paths: schema: $ref: "#/components/schemas/InsightsHealthStatusEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 /api/insights/breathing-screening: @@ -13586,7 +13847,7 @@ paths: schema: $ref: "#/components/schemas/InsightsBreathingScreeningEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 /api/insights/labs-changes: @@ -13605,7 +13866,7 @@ paths: schema: $ref: "#/components/schemas/InsightsLabsChangesEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 /api/mood/analytics: @@ -13628,7 +13889,7 @@ paths: schema: $ref: "#/components/schemas/MoodDailySeriesEnvelope" "401": *a1 - "403": *a19 + "403": *a11 "422": *a4 "429": *a5 /api/mood/insights: @@ -13663,7 +13924,7 @@ paths: schema: $ref: "#/components/schemas/MoodAggregatesEnvelope" "401": *a1 - "403": *a19 + "403": *a11 "422": *a4 "429": *a5 /api/mood/prognosis: @@ -13686,7 +13947,7 @@ paths: schema: $ref: "#/components/schemas/MoodPrognosisEnvelope" "401": *a1 - "403": *a13 + "403": *a17 "422": *a4 "429": *a5 /api/mood-entries/bulk: @@ -13715,6 +13976,7 @@ paths: schema: $ref: "#/components/schemas/BulkMoodResponseEnvelope" "401": *a1 + "403": *a9 "409": *a7 "422": *a4 "429": *a5 @@ -13743,6 +14005,7 @@ paths: schema: $ref: "#/components/schemas/MoodTagTreeEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/mood/tags/custom: @@ -13767,6 +14030,7 @@ paths: schema: $ref: "#/components/schemas/MoodCustomTagCreatedEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/mood/tags/custom/{key}: @@ -13796,7 +14060,8 @@ paths: schema: $ref: "#/components/schemas/MoodCustomTagPatchEnvelope" "401": *a1 - "404": &a20 + "403": *a9 + "404": &a27 description: Not found / not owned by the caller. content: application/json: @@ -13831,7 +14096,8 @@ paths: schema: $ref: "#/components/schemas/MoodCustomTagDeleteEnvelope" "401": *a1 - "404": *a20 + "403": *a9 + "404": *a27 "422": *a4 "429": *a5 /api/mood/tags/{key}/hidden: @@ -13867,7 +14133,8 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "404": *a20 + "403": *a9 + "404": *a27 "422": *a4 "429": *a5 /api/mood/tags/groups: @@ -13891,6 +14158,7 @@ paths: schema: $ref: "#/components/schemas/MoodTagGroupCreatedEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/mood/tags/groups/{key}: @@ -13920,7 +14188,8 @@ paths: schema: $ref: "#/components/schemas/MoodTagGroupPatchEnvelope" "401": *a1 - "404": *a20 + "403": *a9 + "404": *a27 "422": *a4 "429": *a5 delete: @@ -13951,7 +14220,8 @@ paths: schema: $ref: "#/components/schemas/MoodTagGroupDeleteEnvelope" "401": *a1 - "404": *a20 + "403": *a9 + "404": *a27 "422": *a4 "429": *a5 /api/mood/tags/layout: @@ -13969,6 +14239,7 @@ paths: schema: $ref: "#/components/schemas/MoodTagLayoutGetEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 put: @@ -13991,6 +14262,7 @@ paths: schema: $ref: "#/components/schemas/MoodTagLayoutPutEnvelope" "401": *a1 + "403": *a9 "409": description: Mood-tag layout changed since it was loaded (optimistic-concurrency conflict). No write happened. Re-read the resource, re-apply the user's change against the fresh state, and resend with the new token. @@ -13999,7 +14271,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a15 + "422": *a19 "429": *a5 /api/mood-entries: get: @@ -14083,7 +14355,7 @@ paths: schema: $ref: "#/components/schemas/MoodEntryListEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 post: @@ -14116,8 +14388,8 @@ paths: schema: $ref: "#/components/schemas/MoodEntryEnvelope" "401": *a1 - "403": *a11 - "409": &a21 + "403": *a15 + "409": &a28 description: A mood entry with the same `(date, moodLoggedAt)` instant already exists. `meta.errorCode` = `mood.duplicate_timestamp`. Only a request without `externalId` can answer this — with one, the write upserts in place. @@ -14126,7 +14398,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 - "429": *a12 + "429": *a16 /api/mood-entries/{id}: get: tags: @@ -14148,8 +14420,8 @@ paths: schema: $ref: "#/components/schemas/MoodEntryEnvelope" "401": *a1 - "403": *a11 - "404": *a20 + "403": *a15 + "404": *a27 "422": *a4 "429": *a5 put: @@ -14183,9 +14455,9 @@ paths: schema: $ref: "#/components/schemas/MoodEntryEnvelope" "401": *a1 - "403": *a11 - "404": *a20 - "409": *a21 + "403": *a15 + "404": *a27 + "409": *a28 "422": *a4 "429": *a5 delete: @@ -14210,8 +14482,8 @@ paths: schema: $ref: "#/components/schemas/MoodEntryDeleteEnvelope" "401": *a1 - "403": *a11 - "404": *a20 + "403": *a15 + "404": *a27 "422": *a4 "429": *a5 /api/mood-entries/restore: @@ -14240,7 +14512,7 @@ paths: schema: $ref: "#/components/schemas/MoodEntriesRestoreEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "409": *a7 "422": *a4 "429": *a5 @@ -14269,7 +14541,7 @@ paths: schema: $ref: "#/components/schemas/MoodEntriesBulkDeleteEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "409": *a7 "422": *a4 "429": *a5 @@ -14313,7 +14585,7 @@ paths: schema: $ref: "#/components/schemas/MoodLinkedContextEnvelope" "401": *a1 - "403": *a13 + "403": *a17 "422": *a4 "429": *a5 /api/settings/ntfy: @@ -14332,6 +14604,7 @@ paths: schema: $ref: "#/components/schemas/GetNtfySettings" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 put: @@ -14361,7 +14634,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PutNtfySettingsResponse" - "400": &a22 + "400": &a29 description: Body is not parseable JSON. content: application/json: @@ -14369,18 +14642,20 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 "403": - description: The operator switched ntfy off for this instance and the body asked to enable it. Nothing was stored. + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: The operator switched ntfy off + for this instance and the body asked to enable it. Nothing was stored." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "413": &a23 + "413": &a30 description: Body exceeds 64 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "415": &a24 + "415": &a31 description: Content-Type is not `application/json`. content: application/json: @@ -14411,6 +14686,7 @@ paths: schema: $ref: "#/components/schemas/GetTelegramSettings" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 put: @@ -14440,11 +14716,12 @@ paths: application/json: schema: $ref: "#/components/schemas/PutTelegramSettingsResponse" - "400": *a22 + "400": *a29 "401": *a1 "403": - description: The operator switched Telegram off for this instance and the body asked to enable it. No webhook was - registered and nothing was stored. + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: The operator switched Telegram + off for this instance and the body asked to enable it. No webhook was registered and nothing was stored." content: application/json: schema: @@ -14455,8 +14732,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "413": *a23 - "415": *a24 + "413": *a30 + "415": *a31 "422": description: Body matched neither accepted shape; or `enabled` is true without both a bot token and a chat id; or the deployment's `APP_URL` is not a public HTTPS host on port 80/88/443/8443; or Telegram refused the webhook @@ -14489,6 +14766,7 @@ paths: schema: $ref: "#/components/schemas/GetWebhookSettings" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 put: @@ -14510,10 +14788,11 @@ paths: application/json: schema: $ref: "#/components/schemas/PutWebhookSettingsResponse" - "400": *a22 + "400": *a29 "401": *a1 - "413": *a23 - "415": *a24 + "403": *a9 + "413": *a30 + "415": *a31 "422": description: Body matched neither accepted shape, or `enabled` is true while no webhook URL is stored; or the URL is on a private network the operator has not listed in `NOTIFICATION_PRIVATE_ORIGINS` (`meta.errorCode` = @@ -14540,7 +14819,7 @@ paths: schema: $ref: "#/components/schemas/GetReminderThresholdsResponse" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 /api/settings/global-services: @@ -14564,6 +14843,7 @@ paths: schema: $ref: "#/components/schemas/GlobalServiceAvailabilityEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/settings/account: @@ -14628,8 +14908,10 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "403": description: >- - The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal - happens before any second-factor proof is looked at. + `meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: The Bearer token carries a narrow + scope. Erasure needs a cookie session or a full-access token; the refusal happens before any second-factor + proof is looked at. Refused: the request was made while the session is acting on another account (`meta.errorCode` = @@ -14677,6 +14959,7 @@ paths: schema: $ref: "#/components/schemas/GetEmailChannelEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 put: @@ -14711,6 +14994,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/settings/email/test: @@ -14734,6 +15018,7 @@ paths: schema: $ref: "#/components/schemas/EmailTestEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 "500": @@ -14771,6 +15056,7 @@ paths: schema: $ref: "#/components/schemas/NtfyTestEnvelope" "401": *a1 + "403": *a9 "422": description: The saved server is on a private network the operator has not approved (`meta.errorCode` = `private_origin_not_approved`), or is a link-local, metadata or unspecified address that no grant can open @@ -14820,6 +15106,7 @@ paths: schema: $ref: "#/components/schemas/TelegramTestEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/settings/webhook/test: @@ -14842,6 +15129,7 @@ paths: schema: $ref: "#/components/schemas/WebhookTestEnvelope" "401": *a1 + "403": *a9 "422": description: The saved URL is on a private network the operator has not approved (`meta.errorCode` = `private_origin_not_approved`), or is a link-local, metadata or unspecified address that no grant can open @@ -14933,8 +15221,10 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "403": description: >- - The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal - happens before any second-factor proof is looked at. + `meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: The Bearer token carries a narrow + scope. Erasure needs a cookie session or a full-access token; the refusal happens before any second-factor + proof is looked at. Refused: the request was made while the session is acting on another account (`meta.errorCode` = @@ -14976,6 +15266,7 @@ paths: schema: $ref: "#/components/schemas/ModuleListLayoutEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 put: @@ -14998,6 +15289,7 @@ paths: schema: $ref: "#/components/schemas/ModuleListLayoutSavedEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/settings/preview-features: @@ -15014,6 +15306,7 @@ paths: schema: $ref: "#/components/schemas/PreviewFeaturesEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 patch: @@ -15035,6 +15328,7 @@ paths: schema: $ref: "#/components/schemas/PreviewFeaturesSavedEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/settings/privacy-summary: @@ -15053,6 +15347,7 @@ paths: schema: $ref: "#/components/schemas/PrivacySummaryEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/consent/ai: @@ -15091,6 +15386,7 @@ paths: schema: $ref: "#/components/schemas/ConsentValidationErrorEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/consent/ai/web: @@ -15123,6 +15419,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/consent/ai/latest: @@ -15160,7 +15457,7 @@ paths: anyOf: - $ref: "#/components/schemas/ConsentLatestForKindEnvelope" - $ref: "#/components/schemas/ConsentLatestByKindEnvelope" - "400": &a25 + "400": &a32 description: "`kind` was present but is not one of `ai_full`, `ai_insights_only`, `ai_coach`, `ai_extraction`. Multi-issue envelope; nothing was read or revoked. The consent family answers 400 here where the rest of the API answers 422." @@ -15169,6 +15466,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 delete: @@ -15220,8 +15518,9 @@ paths: anyOf: - $ref: "#/components/schemas/ConsentRevokeForKindEnvelope" - $ref: "#/components/schemas/ConsentRevokeAllEnvelope" - "400": *a25 + "400": *a32 "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/import/health-connect-export: @@ -15260,6 +15559,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 + "403": *a9 "409": description: An import for this account (Health Connect or Apple Health) is already queued or running. The upload was discarded. `errorCode` is `import.apple_health.busy` for either kind, `jobId` names the running job. @@ -15302,6 +15602,7 @@ paths: schema: $ref: "#/components/schemas/HealthConnectImportStatusEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/import/csv: @@ -15354,6 +15655,7 @@ paths: schema: $ref: "#/components/schemas/CsvImportResponseEnvelope" "401": *a1 + "403": *a9 "413": description: CSV exceeds the 16 MB limit. content: @@ -15549,7 +15851,9 @@ paths: $ref: "#/components/schemas/LabsCsvImportEnvelope" "401": *a1 "403": - description: "`module.disabled`: the module the import writes to is switched off." + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: `module.disabled`: the module the + import writes to is switched off." content: application/json: schema: @@ -15739,7 +16043,9 @@ paths: $ref: "#/components/schemas/MedicationsCsvImportEnvelope" "401": *a1 "403": - description: "`module.disabled`: the module the import writes to is switched off." + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: `module.disabled`: the module the + import writes to is switched off." content: application/json: schema: @@ -15787,6 +16093,7 @@ paths: schema: $ref: "#/components/schemas/JsonImportStatsEnvelope" "401": *a1 + "403": *a9 "413": description: Body exceeds the 16 MB limit. content: @@ -15856,6 +16163,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 + "403": *a9 "409": description: An import for this account is already queued or running, and these bytes are not the ones it carries (the same bytes resolve to that job as a 202). `meta.errorCode` is `import.apple_health.busy` and `meta.jobId` @@ -15924,6 +16232,7 @@ paths: schema: $ref: "#/components/schemas/AppleHealthImportStatusEnvelope" "401": *a1 + "403": *a9 "404": description: No such job, or one the caller may not see. The two are indistinguishable on purpose. content: @@ -15952,7 +16261,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderListEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 post: @@ -15977,7 +16286,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderCreatedEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "409": *a7 "422": *a4 "429": *a5 @@ -16001,8 +16310,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderEnvelope" "401": *a1 - "403": *a11 - "404": &a26 + "403": *a15 + "404": &a33 description: Measurement reminder not found / not owned. content: application/json: @@ -16039,8 +16348,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderPatchEnvelope" "401": *a1 - "403": *a11 - "404": *a26 + "403": *a15 + "404": *a33 "422": *a4 "429": *a5 delete: @@ -16062,8 +16371,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderDeleteEnvelope" "401": *a1 - "403": *a11 - "404": *a26 + "403": *a15 + "404": *a33 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/satisfy: @@ -16087,8 +16396,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderSatisfyEnvelope" "401": *a1 - "403": *a11 - "404": *a26 + "403": *a15 + "404": *a33 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/complete: @@ -16115,8 +16424,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderCompleteEnvelope" "401": *a1 - "403": *a11 - "404": *a26 + "403": *a15 + "404": *a33 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/skip: @@ -16145,8 +16454,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderSkipEnvelope" "401": *a1 - "403": *a11 - "404": *a26 + "403": *a15 + "404": *a33 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/snooze: @@ -16179,8 +16488,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderSnoozeEnvelope" "401": *a1 - "403": *a11 - "404": *a26 + "403": *a15 + "404": *a33 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/history: @@ -16220,8 +16529,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderHistoryEnvelope" "401": *a1 - "403": *a11 - "404": *a26 + "403": *a15 + "404": *a33 "422": *a4 "429": *a5 /api/labs: @@ -16299,7 +16608,7 @@ paths: schema: $ref: "#/components/schemas/ListLabResultsEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 post: @@ -16326,7 +16635,7 @@ paths: schema: $ref: "#/components/schemas/CreateLabResultResponse" "401": *a1 - "403": *a11 + "403": *a15 "409": *a7 "422": description: "Nothing was written. `meta.errorCode` = `labs.create.invalid` when the body failed validation (every issue @@ -16341,7 +16650,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": *a12 + "429": *a16 /api/labs/overview: get: tags: @@ -16359,7 +16668,7 @@ paths: schema: $ref: "#/components/schemas/LabOverviewEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 /api/labs/{id}: @@ -16382,8 +16691,8 @@ paths: schema: $ref: "#/components/schemas/GetLabResultResponse" "401": *a1 - "403": *a11 - "404": &a27 + "403": *a15 + "404": &a34 description: Lab result not found (or owned by another user). content: application/json: @@ -16417,8 +16726,8 @@ paths: schema: $ref: "#/components/schemas/UpdateLabResultResponse" "401": *a1 - "403": *a11 - "404": *a27 + "403": *a15 + "404": *a34 "422": *a4 "429": *a5 delete: @@ -16440,8 +16749,8 @@ paths: schema: $ref: "#/components/schemas/DeleteLabResultResponse" "401": *a1 - "403": *a11 - "404": *a27 + "403": *a15 + "404": *a34 "422": *a4 "429": *a5 /api/labs/restore: @@ -16467,7 +16776,7 @@ paths: schema: $ref: "#/components/schemas/RestoreLabResultsResponse" "401": *a1 - "403": *a11 + "403": *a15 "409": *a7 "422": *a4 "429": *a5 @@ -16486,6 +16795,7 @@ paths: schema: $ref: "#/components/schemas/OcrCapabilityEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/labs/ocr/extract: @@ -16528,7 +16838,7 @@ paths: schema: $ref: "#/components/schemas/OcrTextExtractRequest" responses: - "202": &a37 + "202": &a44 description: Queued for the background worker. Every refusal the route can answer quickly (capability, provider, rate limit, budget, validation) has already been answered; anything that goes wrong from here is reported on the run. @@ -16552,15 +16862,16 @@ paths: $ref: "#/components/schemas/AiRunAcceptedEnvelope" "401": *a1 "403": - description: "AI refusal: the `labsOcr` capability is unavailable for this record. The envelope carries - `meta.errorCode`, `meta.capability` and `meta.reason` (one of `AiUnavailableReason`). - `assistant.disabled.documentAi` — the operator turned off reading documents, or every AI feature (the master - switch). `module.disabled` with `meta.module = \"labs\"` — the module is off for this record. - `ai.record.notPermitted` — AI work is not admitted inside this record (a delegate acting in somebody else's - record). `consent.ai.required` — the provider this request would reach leaves the machine and the record - holds no active `ai_extraction` or `ai_full` receipt; only a self-hosted model is exempt. The question is - answered for the provider actually picked, so it can differ from the published capability when a text-mode - read takes a different provider than a vision read would." + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: AI refusal: the `labsOcr` + capability is unavailable for this record. The envelope carries `meta.errorCode`, `meta.capability` and + `meta.reason` (one of `AiUnavailableReason`). `assistant.disabled.documentAi` — the operator turned off + reading documents, or every AI feature (the master switch). `module.disabled` with `meta.module = \"labs\"` + — the module is off for this record. `ai.record.notPermitted` — AI work is not admitted inside this record + (a delegate acting in somebody else's record). `consent.ai.required` — the provider this request would reach + leaves the machine and the record holds no active `ai_extraction` or `ai_full` receipt; only a self-hosted + model is exempt. The question is answered for the provider actually picked, so it can differ from the + published capability when a text-mode read takes a different provider than a vision read would." content: application/json: schema: @@ -16599,6 +16910,7 @@ paths: schema: $ref: "#/components/schemas/OcrCommitEnvelope" "401": *a1 + "403": *a9 "409": *a7 "422": *a4 "429": *a5 @@ -16626,6 +16938,7 @@ paths: schema: $ref: "#/components/schemas/AiRunEnvelope" "401": *a1 + "403": *a9 "404": description: "`aiRuns.notFound`." content: @@ -16649,7 +16962,7 @@ paths: schema: $ref: "#/components/schemas/ListBiomarkersEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 post: @@ -16674,7 +16987,7 @@ paths: schema: $ref: "#/components/schemas/CreateBiomarkerResponse" "401": *a1 - "403": *a11 + "403": *a15 "409": description: A biomarker with this name already exists for the caller. content: @@ -16682,7 +16995,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 - "429": *a12 + "429": *a16 /api/biomarkers/{id}: get: tags: @@ -16705,8 +17018,8 @@ paths: schema: $ref: "#/components/schemas/GetBiomarkerResponse" "401": *a1 - "403": *a11 - "404": &a28 + "403": *a15 + "404": &a35 description: "Biomarker not found (or owned by another user). `meta.errorCode` = `labs.biomarker.notFound`. Since v1.43 a marker that was merged into another answers with `meta.mergedInto` naming the marker it went into: drop the cached marker and open that one." @@ -16747,8 +17060,8 @@ paths: schema: $ref: "#/components/schemas/UpdateBiomarkerResponse" "401": *a1 - "403": *a11 - "404": *a28 + "403": *a15 + "404": *a35 "409": description: "`labs.biomarker.duplicate`: a rename collides with another of the caller's markers. `labs.biomarker.alias.taken` (v1.43): one of `aliases` is already the name or an alias of another marker; @@ -16781,8 +17094,8 @@ paths: schema: $ref: "#/components/schemas/DeleteBiomarkerResponse" "401": *a1 - "403": *a11 - "404": *a28 + "403": *a15 + "404": *a35 "422": *a4 "429": *a5 /api/biomarkers/{id}/merge: @@ -16819,8 +17132,8 @@ paths: schema: $ref: "#/components/schemas/MergeBiomarkersResponse" "401": *a1 - "403": *a11 - "404": *a28 + "403": *a15 + "404": *a35 "409": *a7 "422": description: "`labs.biomarker.merge.invalid`: a malformed body, or the target among the sources (Zod issues in @@ -16831,7 +17144,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": *a12 + "429": *a16 /api/encounters: get: tags: @@ -16894,7 +17207,7 @@ paths: schema: $ref: "#/components/schemas/ListEncountersEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 post: @@ -16920,10 +17233,10 @@ paths: schema: $ref: "#/components/schemas/CreateEncounterEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "409": *a7 "422": *a4 - "429": *a12 + "429": *a16 /api/encounters/procedures: get: tags: @@ -16954,7 +17267,7 @@ paths: schema: $ref: "#/components/schemas/ListProceduresEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 /api/body-sites: @@ -16991,7 +17304,7 @@ paths: schema: $ref: "#/components/schemas/ListBodySitesEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 /api/encounters/suggest: @@ -17021,7 +17334,7 @@ paths: schema: $ref: "#/components/schemas/SuggestEncounterEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 /api/encounters/{id}: @@ -17044,8 +17357,8 @@ paths: schema: $ref: "#/components/schemas/GetEncounterEnvelope" "401": *a1 - "403": *a11 - "404": &a29 + "403": *a15 + "404": &a36 description: Visit not found (or owned by another user). content: application/json: @@ -17078,8 +17391,8 @@ paths: schema: $ref: "#/components/schemas/UpdateEncounterEnvelope" "401": *a1 - "403": *a11 - "404": *a29 + "403": *a15 + "404": *a36 "422": *a4 "429": *a5 delete: @@ -17103,8 +17416,8 @@ paths: schema: $ref: "#/components/schemas/DeleteEncounterEnvelope" "401": *a1 - "403": *a11 - "404": *a29 + "403": *a15 + "404": *a36 "422": *a4 "429": *a5 /api/encounters/{id}/restore: @@ -17129,8 +17442,8 @@ paths: schema: $ref: "#/components/schemas/RestoreEncounterEnvelope" "401": *a1 - "403": *a11 - "404": *a29 + "403": *a15 + "404": *a36 "422": *a4 "429": *a5 /api/encounters/{id}/links: @@ -17161,7 +17474,8 @@ paths: schema: $ref: "#/components/schemas/LinkEncounterEnvelope" "401": *a1 - "404": *a29 + "403": *a9 + "404": *a36 "422": *a4 "429": *a5 delete: @@ -17190,7 +17504,8 @@ paths: schema: $ref: "#/components/schemas/UnlinkEncounterEnvelope" "401": *a1 - "404": *a29 + "403": *a9 + "404": *a36 "422": *a4 "429": *a5 /api/practitioners: @@ -17230,7 +17545,7 @@ paths: schema: $ref: "#/components/schemas/ListPractitionersEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 post: @@ -17254,7 +17569,7 @@ paths: schema: $ref: "#/components/schemas/CreatePractitionerEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "409": *a7 "422": *a4 "429": *a5 @@ -17278,8 +17593,8 @@ paths: schema: $ref: "#/components/schemas/GetPractitionerEnvelope" "401": *a1 - "403": *a11 - "404": &a30 + "403": *a15 + "404": &a37 description: Practitioner not found (or owned by another user). content: application/json: @@ -17312,8 +17627,8 @@ paths: schema: $ref: "#/components/schemas/UpdatePractitionerEnvelope" "401": *a1 - "403": *a11 - "404": *a30 + "403": *a15 + "404": *a37 "422": *a4 "429": *a5 delete: @@ -17337,8 +17652,8 @@ paths: schema: $ref: "#/components/schemas/DeletePractitionerEnvelope" "401": *a1 - "403": *a11 - "404": *a30 + "403": *a15 + "404": *a37 "422": *a4 "429": *a5 /api/practitioners/{id}/restore: @@ -17363,8 +17678,8 @@ paths: schema: $ref: "#/components/schemas/RestorePractitionerEnvelope" "401": *a1 - "403": *a11 - "404": *a30 + "403": *a15 + "404": *a37 "422": *a4 "429": *a5 /api/vaccinations/custom: @@ -17382,7 +17697,7 @@ paths: schema: $ref: "#/components/schemas/ListCustomVaccinesEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 post: @@ -17407,8 +17722,8 @@ paths: schema: $ref: "#/components/schemas/CreateCustomVaccineEnvelope" "401": *a1 - "403": *a11 - "409": &a31 + "403": *a15 + "409": &a38 description: A live definition on the record already carries this name, compared without case (`vaccination.custom.name-taken`). content: @@ -17416,7 +17731,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 - "429": *a12 + "429": *a16 /api/vaccinations/custom/{id}: patch: tags: @@ -17444,14 +17759,14 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomVaccineEnvelope" "401": *a1 - "403": *a11 - "404": &a32 + "403": *a15 + "404": &a39 description: No live definition with this id on the record (`vaccination.custom.not-found`). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "409": *a31 + "409": *a38 "422": *a4 "429": *a5 delete: @@ -17475,8 +17790,8 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomVaccineEnvelope" "401": *a1 - "403": *a11 - "404": *a32 + "403": *a15 + "404": *a39 "422": *a4 "429": *a5 /api/vaccinations: @@ -17529,7 +17844,7 @@ paths: schema: $ref: "#/components/schemas/ListVaccinationsEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 post: @@ -17556,7 +17871,7 @@ paths: schema: $ref: "#/components/schemas/CreateVaccinationEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "404": description: A practice, visit or own vaccine the body names is not the record's (`vaccination.practitioner-not-found`, `vaccination.encounter-not-found`, `vaccination.custom-vaccine-not-found`). @@ -17566,7 +17881,7 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "409": *a7 "422": *a4 - "429": *a12 + "429": *a16 /api/vaccinations/{id}: get: tags: @@ -17587,8 +17902,8 @@ paths: schema: $ref: "#/components/schemas/GetVaccinationEnvelope" "401": *a1 - "403": *a11 - "404": &a33 + "403": *a15 + "404": &a40 description: Vaccination not found (or owned by another user). content: application/json: @@ -17622,8 +17937,8 @@ paths: schema: $ref: "#/components/schemas/UpdateVaccinationEnvelope" "401": *a1 - "403": *a11 - "404": *a33 + "403": *a15 + "404": *a40 "422": *a4 "429": *a5 delete: @@ -17647,8 +17962,8 @@ paths: schema: $ref: "#/components/schemas/DeleteVaccinationEnvelope" "401": *a1 - "403": *a11 - "404": *a33 + "403": *a15 + "404": *a40 "422": *a4 "429": *a5 /api/vaccinations/{id}/restore: @@ -17672,8 +17987,8 @@ paths: schema: $ref: "#/components/schemas/RestoreVaccinationEnvelope" "401": *a1 - "403": *a11 - "404": *a33 + "403": *a15 + "404": *a40 "422": *a4 "429": *a5 /api/vaccinations/{id}/booster: @@ -17722,8 +18037,10 @@ paths: "401": *a1 "403": description: >- - Refused: the caller's grant does not open this record's measurements, and the booster reminder lives there - (`meta.errorCode` = `vaccination.booster-out-of-scope`). Nothing was written. + `meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: Refused: the caller's grant does + not open this record's measurements, and the booster reminder lives there (`meta.errorCode` = + `vaccination.booster-out-of-scope`). Nothing was written. Refused: this request named a record the caller may not act on (`meta.errorCode` = `sharing.access.denied`). @@ -17739,7 +18056,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "404": *a33 + "404": *a40 "422": *a4 "429": *a5 /api/vaccinations/suggest: @@ -17767,7 +18084,7 @@ paths: schema: $ref: "#/components/schemas/VaccinationSuggestEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 /api/vaccinations/{id}/links: @@ -17799,7 +18116,8 @@ paths: schema: $ref: "#/components/schemas/LinkVaccinationEnvelope" "401": *a1 - "404": *a33 + "403": *a9 + "404": *a40 "422": *a4 "429": *a5 delete: @@ -17828,7 +18146,8 @@ paths: schema: $ref: "#/components/schemas/UnlinkVaccinationEnvelope" "401": *a1 - "404": *a33 + "403": *a9 + "404": *a40 "422": *a4 "429": *a5 /api/illness/episodes: @@ -17892,7 +18211,7 @@ paths: schema: $ref: "#/components/schemas/ListIllnessEpisodesEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 post: @@ -17918,7 +18237,7 @@ paths: schema: $ref: "#/components/schemas/CreateIllnessEpisodeEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "409": *a7 "422": *a4 "429": *a5 @@ -17942,8 +18261,8 @@ paths: schema: $ref: "#/components/schemas/GetIllnessEpisodeEnvelope" "401": *a1 - "403": *a11 - "404": &a34 + "403": *a15 + "404": &a41 description: Illness episode not found (or owned by another user). content: application/json: @@ -17977,8 +18296,8 @@ paths: schema: $ref: "#/components/schemas/UpdateIllnessEpisodeEnvelope" "401": *a1 - "403": *a11 - "404": *a34 + "403": *a15 + "404": *a41 "422": *a4 "429": *a5 delete: @@ -18001,8 +18320,8 @@ paths: schema: $ref: "#/components/schemas/DeleteIllnessEpisodeEnvelope" "401": *a1 - "403": *a11 - "404": *a34 + "403": *a15 + "404": *a41 "422": *a4 "429": *a5 /api/illness/episodes/{id}/restore: @@ -18029,8 +18348,8 @@ paths: schema: $ref: "#/components/schemas/RestoreIllnessEpisodeEnvelope" "401": *a1 - "403": *a11 - "404": *a34 + "403": *a15 + "404": *a41 "409": *a7 "422": *a4 "429": *a5 @@ -18062,8 +18381,8 @@ paths: schema: $ref: "#/components/schemas/ResolveIllnessEpisodeEnvelope" "401": *a1 - "403": *a11 - "404": *a34 + "403": *a15 + "404": *a41 "422": *a4 "429": *a5 /api/illness/episodes/{id}/day-logs: @@ -18113,8 +18432,8 @@ paths: schema: $ref: "#/components/schemas/GetIllnessDayLogEnvelope" "401": *a1 - "403": *a11 - "404": *a34 + "403": *a15 + "404": *a41 "422": *a4 "429": *a5 post: @@ -18150,8 +18469,8 @@ paths: schema: $ref: "#/components/schemas/UpsertIllnessDayLogCreatedEnvelope" "401": *a1 - "403": *a11 - "404": *a34 + "403": *a15 + "404": *a41 "422": *a4 "429": *a5 /api/illness/episodes/{id}/correlation: @@ -18178,7 +18497,8 @@ paths: schema: $ref: "#/components/schemas/IllnessCorrelationEnvelope" "401": *a1 - "404": *a34 + "403": *a9 + "404": *a41 "422": *a4 "429": *a5 /api/illness/insights: @@ -18212,6 +18532,7 @@ paths: schema: $ref: "#/components/schemas/IllnessInsightsEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/mental-health/assessments: @@ -18251,7 +18572,7 @@ paths: schema: $ref: "#/components/schemas/ListMentalHealthAssessmentsEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 post: @@ -18284,7 +18605,7 @@ paths: schema: $ref: "#/components/schemas/CreateMentalHealthAssessmentEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "409": *a7 "422": *a4 "429": *a5 @@ -18311,7 +18632,7 @@ paths: schema: $ref: "#/components/schemas/GetMentalHealthAssessmentEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 /api/allergies: @@ -18344,7 +18665,7 @@ paths: schema: $ref: "#/components/schemas/ListAllergiesEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 post: @@ -18369,10 +18690,10 @@ paths: schema: $ref: "#/components/schemas/CreateAllergyEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "409": *a7 "422": *a4 - "429": *a12 + "429": *a16 /api/allergies/{id}: get: tags: @@ -18393,8 +18714,8 @@ paths: schema: $ref: "#/components/schemas/GetAllergyEnvelope" "401": *a1 - "403": *a11 - "404": &a35 + "403": *a15 + "404": &a42 description: Allergy not found (or owned by another user). content: application/json: @@ -18427,8 +18748,8 @@ paths: schema: $ref: "#/components/schemas/UpdateAllergyEnvelope" "401": *a1 - "403": *a11 - "404": *a35 + "403": *a15 + "404": *a42 "422": *a4 "429": *a5 delete: @@ -18451,8 +18772,8 @@ paths: schema: $ref: "#/components/schemas/DeleteAllergyEnvelope" "401": *a1 - "403": *a11 - "404": *a35 + "403": *a15 + "404": *a42 "422": *a4 "429": *a5 /api/family-history: @@ -18476,7 +18797,7 @@ paths: schema: $ref: "#/components/schemas/ListFamilyHistoryEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 post: @@ -18501,7 +18822,7 @@ paths: schema: $ref: "#/components/schemas/CreateFamilyHistoryEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "409": *a7 "422": *a4 "429": *a5 @@ -18525,8 +18846,8 @@ paths: schema: $ref: "#/components/schemas/GetFamilyHistoryEnvelope" "401": *a1 - "403": *a11 - "404": &a36 + "403": *a15 + "404": &a43 description: Family history entry not found (or owned by another user). content: application/json: @@ -18559,8 +18880,8 @@ paths: schema: $ref: "#/components/schemas/UpdateFamilyHistoryEnvelope" "401": *a1 - "403": *a11 - "404": *a36 + "403": *a15 + "404": *a43 "422": *a4 "429": *a5 delete: @@ -18583,8 +18904,8 @@ paths: schema: $ref: "#/components/schemas/DeleteFamilyHistoryEnvelope" "401": *a1 - "403": *a11 - "404": *a36 + "403": *a15 + "404": *a43 "422": *a4 "429": *a5 /api/documents/inbound: @@ -18699,7 +19020,7 @@ paths: schema: $ref: "#/components/schemas/InboundDocumentListEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 post: @@ -18866,6 +19187,7 @@ paths: schema: $ref: "#/components/schemas/DocumentUploadEnvelope" "401": *a1 + "403": *a9 "409": description: The same bytes are already stored under 20 other import source ids (`documents.inbound.sourceAliasLimit`). Nothing was stored or remembered. @@ -18935,6 +19257,7 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceLookupEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/documents/inbound/capability: @@ -18960,6 +19283,7 @@ paths: schema: $ref: "#/components/schemas/DocumentAiCapabilityEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/documents/inbound/usage: @@ -18982,7 +19306,7 @@ paths: schema: $ref: "#/components/schemas/DocumentUsageEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 /api/documents/inbound/layout: @@ -19000,7 +19324,7 @@ paths: schema: $ref: "#/components/schemas/DocumentsLayoutEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 put: @@ -19023,6 +19347,7 @@ paths: schema: $ref: "#/components/schemas/DocumentsLayoutSavedEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/documents/inbound/bulk: @@ -19047,6 +19372,7 @@ paths: schema: $ref: "#/components/schemas/DocumentBulkEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/documents/inbound/{id}: @@ -19069,7 +19395,7 @@ paths: schema: $ref: "#/components/schemas/InboundDocumentDetailEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 patch: @@ -19098,6 +19424,7 @@ paths: schema: $ref: "#/components/schemas/InboundDocumentUpdateEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 delete: @@ -19120,6 +19447,7 @@ paths: schema: $ref: "#/components/schemas/InboundDiscardEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/documents/inbound/{id}/restore: @@ -19144,6 +19472,7 @@ paths: schema: $ref: "#/components/schemas/InboundRestoreEnvelope" "401": *a1 + "403": *a9 "409": description: "Not restorable: purged past the grace window, or an identical live document exists." content: @@ -19188,7 +19517,7 @@ paths: type: string format: binary "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 /api/documents/inbound/{id}/thumbnail: @@ -19217,7 +19546,7 @@ paths: type: string format: binary "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 /api/documents/inbound/{id}/extract: @@ -19249,7 +19578,7 @@ paths: required: true schema: type: string - - &a38 + - &a45 name: Prefer in: header required: false @@ -19274,18 +19603,20 @@ paths: application/json: schema: $ref: "#/components/schemas/InboundExtractEnvelope" - "202": *a37 + "202": *a44 "401": *a1 - "403": &a39 - description: "AI refusal: the `documentAi` capability is unavailable for this record. The envelope carries - `meta.errorCode`, `meta.capability` and `meta.reason` (one of `AiUnavailableReason`). - `assistant.disabled.documentAi` — the operator turned off reading documents, or every AI feature (the master - switch). `module.disabled` with `meta.module = \"inboundDocuments\"` — the module is off for this record. - `ai.record.notPermitted` — AI work is not admitted inside this record (a delegate acting in somebody else's - record). `consent.ai.required` — the provider this request would reach leaves the machine and the record - holds no active `ai_extraction` or `ai_full` receipt; only a self-hosted model is exempt. The question is - answered for the provider actually picked, so it can differ from the published capability when a text-mode - read takes a different provider than a vision read would." + "403": &a46 + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: AI refusal: the `documentAi` + capability is unavailable for this record. The envelope carries `meta.errorCode`, `meta.capability` and + `meta.reason` (one of `AiUnavailableReason`). `assistant.disabled.documentAi` — the operator turned off + reading documents, or every AI feature (the master switch). `module.disabled` with `meta.module = + \"inboundDocuments\"` — the module is off for this record. `ai.record.notPermitted` — AI work is not + admitted inside this record (a delegate acting in somebody else's record). `consent.ai.required` — the + provider this request would reach leaves the machine and the record holds no active `ai_extraction` or + `ai_full` receipt; only a self-hosted model is exempt. The question is answered for the provider actually + picked, so it can differ from the published capability when a text-mode read takes a different provider than + a vision read would." content: application/json: schema: @@ -19300,7 +19631,7 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "503": &a40 + "503": &a47 description: "`ai.unavailable` — the inputs behind the AI capability could not be read, and the answer failed closed. Nothing was sent. Retry later." content: @@ -19330,7 +19661,7 @@ paths: required: true schema: type: string - - *a38 + - *a45 requestBody: required: false content: @@ -19366,12 +19697,12 @@ paths: application/json: schema: $ref: "#/components/schemas/DocumentSuggestEnvelope" - "202": *a37 + "202": *a44 "401": *a1 - "403": *a39 + "403": *a46 "422": *a4 "429": *a5 - "503": *a40 + "503": *a47 /api/documents/inbound/{id}/summary: post: tags: @@ -19421,7 +19752,7 @@ paths: type: boolean default: false description: With `persist=true`, explicitly replace an existing stored summary. - - *a38 + - *a45 requestBody: required: false content: @@ -19457,12 +19788,12 @@ paths: application/json: schema: $ref: "#/components/schemas/DocumentSummaryEnvelope" - "202": *a37 + "202": *a44 "401": *a1 - "403": *a39 + "403": *a46 "422": *a4 "429": *a5 - "503": *a40 + "503": *a47 /api/documents/inbound/{id}/index: post: tags: @@ -19488,7 +19819,7 @@ paths: required: true schema: type: string - - *a38 + - *a45 requestBody: required: false content: @@ -19524,12 +19855,12 @@ paths: application/json: schema: $ref: "#/components/schemas/DocumentIndexEnvelope" - "202": *a37 + "202": *a44 "401": *a1 - "403": *a39 + "403": *a46 "422": *a4 "429": *a5 - "503": *a40 + "503": *a47 /api/documents/inbound/{id}/chat: post: tags: @@ -19569,10 +19900,10 @@ paths: type: string description: "SSE frames: `data: \\n\\n`. See the operation description for the per-`type` frame shapes." "401": *a1 - "403": *a39 + "403": *a46 "422": *a4 "429": *a5 - "503": *a40 + "503": *a47 get: tags: - Documents @@ -19613,6 +19944,7 @@ paths: schema: $ref: "#/components/schemas/DocumentChatHistoryEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/documents/inbound/reindex: @@ -19635,10 +19967,10 @@ paths: schema: $ref: "#/components/schemas/DocumentReindexEnvelope" "401": *a1 - "403": *a39 + "403": *a46 "422": *a4 "429": *a5 - "503": *a40 + "503": *a47 /api/documents/inbound/{id}/facts/{factId}: patch: tags: @@ -19671,6 +20003,7 @@ paths: schema: $ref: "#/components/schemas/ExtractedFactEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/documents/inbound/{id}/confirm: @@ -19702,6 +20035,7 @@ paths: schema: $ref: "#/components/schemas/InboundConfirmEnvelope" "401": *a1 + "403": *a9 "409": *a7 "422": *a4 "429": *a5 @@ -19720,7 +20054,7 @@ paths: schema: $ref: "#/components/schemas/ListCustomMetricsEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 post: @@ -19745,7 +20079,8 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricResponse" "401": *a1 - "409": &a42 + "403": *a9 + "409": &a49 description: A custom metric with this name already exists for the caller. content: application/json: @@ -19773,8 +20108,8 @@ paths: schema: $ref: "#/components/schemas/GetCustomMetricResponse" "401": *a1 - "403": *a11 - "404": &a41 + "403": *a15 + "404": &a48 description: Custom metric not found (or owned by another user). content: application/json: @@ -19808,8 +20143,9 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricResponse" "401": *a1 - "404": *a41 - "409": *a42 + "403": *a9 + "404": *a48 + "409": *a49 "422": *a4 "429": *a5 delete: @@ -19832,7 +20168,8 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricResponse" "401": *a1 - "404": *a41 + "403": *a9 + "404": *a48 "422": *a4 "429": *a5 /api/custom-metrics/{id}/entries: @@ -19875,8 +20212,8 @@ paths: schema: $ref: "#/components/schemas/ListCustomMetricEntriesEnvelope" "401": *a1 - "403": *a11 - "404": *a41 + "403": *a15 + "404": *a48 "422": *a4 "429": *a5 post: @@ -19905,10 +20242,11 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricEntryResponse" "401": *a1 - "404": *a41 + "403": *a9 + "404": *a48 "409": *a7 "422": *a4 - "429": *a12 + "429": *a16 /api/custom-metrics/{id}/entries/{entryId}: patch: tags: @@ -19941,7 +20279,8 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricEntryResponse" "401": *a1 - "404": &a43 + "403": *a9 + "404": &a50 description: Custom metric entry not found (or owned by another user). content: application/json: @@ -19974,7 +20313,8 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricEntryResponse" "401": *a1 - "404": *a43 + "403": *a9 + "404": *a50 "422": *a4 "429": *a5 /api/custom-metrics/{id}/entries/restore: @@ -20005,6 +20345,7 @@ paths: schema: $ref: "#/components/schemas/RestoreCustomMetricEntriesResponse" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/onboarding/answers: @@ -20045,6 +20386,7 @@ paths: schema: $ref: "#/components/schemas/OnboardingStateEnvelope" "401": *a1 + "403": *a9 "413": description: Body exceeds 16 KiB. content: @@ -20102,6 +20444,7 @@ paths: schema: $ref: "#/components/schemas/OnboardingStateEnvelope" "401": *a1 + "403": *a9 "413": description: Body exceeds 4 KiB. content: @@ -20183,6 +20526,7 @@ paths: schema: $ref: "#/components/schemas/OnboardingCompleteEnvelope" "401": *a1 + "403": *a9 "404": description: "`managedRecordId` names no managed profile the caller actively guards — missing, not a managed record, or not theirs; one answer for all three. `meta.errorCode` = `onboarding.complete.recordNotFound`. Nothing was @@ -20235,6 +20579,7 @@ paths: schema: $ref: "#/components/schemas/DisclaimerAckEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/onboarding/tour: @@ -20259,6 +20604,7 @@ paths: schema: $ref: "#/components/schemas/TourUpdateEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/nutrients/catalog: @@ -20276,11 +20622,12 @@ paths: schema: $ref: "#/components/schemas/NutrientCatalogEnvelope" "401": *a1 - "403": &a44 + "403": &a51 description: >- - The opt-in `nutrients` module is off for this account (errorCode `module.disabled`). Stop syncing and do not - retry; offer the enable-in-settings hint. Only run the HealthKit read-authorization flow while the module is - on. + `meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: The opt-in `nutrients` module is + off for this account (errorCode `module.disabled`). Stop syncing and do not retry; offer the + enable-in-settings hint. Only run the HealthKit read-authorization flow while the module is on. Refused: this request named a record the caller may not act on (`meta.errorCode` = `sharing.access.denied`). @@ -20389,9 +20736,10 @@ paths: $ref: "#/components/schemas/NutrientBatchEnvelope" "401": *a1 "403": - description: The opt-in `nutrients` module is off for this account (errorCode `module.disabled`). Stop syncing and do - not retry; offer the enable-in-settings hint. Only run the HealthKit read-authorization flow while the - module is on. + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: The opt-in `nutrients` module is + off for this account (errorCode `module.disabled`). Stop syncing and do not retry; offer the + enable-in-settings hint. Only run the HealthKit read-authorization flow while the module is on." content: application/json: schema: @@ -20423,7 +20771,7 @@ paths: schema: $ref: "#/components/schemas/NutrientOverviewEnvelope" "401": *a1 - "403": *a44 + "403": *a51 "422": *a4 "429": *a5 /api/nutrients/daily: @@ -20460,7 +20808,7 @@ paths: schema: $ref: "#/components/schemas/NutrientDailySeriesEnvelope" "401": *a1 - "403": *a44 + "403": *a51 "422": *a4 "429": *a5 /api/nutrients/water: @@ -20489,7 +20837,7 @@ paths: schema: $ref: "#/components/schemas/NutrientWaterWriteEnvelope" "401": *a1 - "403": *a44 + "403": *a51 "409": *a7 "422": *a4 "429": *a5 @@ -20508,6 +20856,7 @@ paths: schema: $ref: "#/components/schemas/HealthKitConfigEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 patch: @@ -20531,6 +20880,7 @@ paths: schema: $ref: "#/components/schemas/HealthKitConfigPatchEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/oura/sync: @@ -20548,6 +20898,7 @@ paths: schema: $ref: "#/components/schemas/OuraSyncEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 "502": @@ -20573,6 +20924,7 @@ paths: schema: $ref: "#/components/schemas/PolarSyncEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 "502": @@ -20600,6 +20952,7 @@ paths: schema: $ref: "#/components/schemas/StravaSyncEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 "502": @@ -20626,6 +20979,7 @@ paths: schema: $ref: "#/components/schemas/NightscoutSyncEnvelope" "401": *a1 + "403": *a9 "422": description: The configured Nightscout origin is private (RFC 1918 / loopback / link-local) and the server operator has not approved private-origin access for this deployment. @@ -20659,6 +21013,7 @@ paths: schema: $ref: "#/components/schemas/GoogleHealthSyncStatusEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/nightscout/connect: @@ -20691,6 +21046,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 + "403": *a9 "413": description: Body exceeds 16 KiB. content: @@ -20728,6 +21084,7 @@ paths: schema: $ref: "#/components/schemas/NightscoutStatusEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/nightscout/test: @@ -20746,6 +21103,7 @@ paths: schema: $ref: "#/components/schemas/NightscoutTestEnvelope" "401": *a1 + "403": *a9 "422": description: No Nightscout instance is configured for this user. `meta.errorCode` = `not_configured`. content: @@ -20786,6 +21144,7 @@ paths: schema: $ref: "#/components/schemas/NightscoutDisconnectEnvelope" "401": *a1 + "403": *a9 "404": description: "No Nightscout connection to clear. Note that this is NOT an idempotent no-op: a second disconnect answers 404 rather than 200." @@ -20810,6 +21169,7 @@ paths: schema: $ref: "#/components/schemas/WhoopCredentialsStatusEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 put: @@ -20838,6 +21198,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 + "403": *a9 "413": description: Body exceeds 64 KiB. content: @@ -20867,6 +21228,7 @@ paths: schema: $ref: "#/components/schemas/WhoopCredentialsDeleteEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/whoop/status: @@ -20885,6 +21247,7 @@ paths: schema: $ref: "#/components/schemas/WhoopStatusEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/whoop/disconnect: @@ -20905,6 +21268,7 @@ paths: schema: $ref: "#/components/schemas/WhoopDisconnectEnvelope" "401": *a1 + "403": *a9 "404": description: "No WHOOP connection to remove. NOT an idempotent no-op: a second disconnect answers 404 rather than 200." content: @@ -20930,6 +21294,7 @@ paths: schema: $ref: "#/components/schemas/WithingsStatusEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/withings/disconnect: @@ -20948,6 +21313,7 @@ paths: schema: $ref: "#/components/schemas/WithingsDisconnectEnvelope" "401": *a1 + "403": *a9 "404": description: "No Withings connection to remove. Note that this is NOT an idempotent no-op: a second disconnect answers 404 rather than 200." @@ -20971,6 +21337,7 @@ paths: schema: $ref: "#/components/schemas/WithingsCredentialsStatusEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 put: @@ -20991,26 +21358,27 @@ paths: application/json: schema: $ref: "#/components/schemas/WithingsCredentialsUpdateEnvelope" - "400": &a45 + "400": &a52 description: Body is not parseable JSON. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "413": &a46 + "403": *a9 + "413": &a53 description: Body exceeds 64 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "415": &a47 + "415": &a54 description: Content-Type is not `application/json`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": &a48 + "422": &a55 description: "`clientId` and `clientSecret` are both required and each must be 1–200 characters. The envelope carries a single fixed message, not per-field issues." content: @@ -21033,6 +21401,7 @@ paths: schema: $ref: "#/components/schemas/WithingsCredentialsDeleteEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/withings/sync: @@ -21063,6 +21432,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 + "403": *a9 "413": description: Body exceeds 64 KiB. content: @@ -21111,6 +21481,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 + "403": *a9 "413": description: Body exceeds 64 KiB. content: @@ -21155,6 +21526,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": description: More than 10 ticket mints in 60 s from this user. @@ -21176,6 +21548,7 @@ paths: schema: $ref: "#/components/schemas/FitbitCredentialsStatusEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 put: @@ -21196,11 +21569,12 @@ paths: application/json: schema: $ref: "#/components/schemas/FitbitCredentialsUpdateEnvelope" - "400": *a45 + "400": *a52 "401": *a1 - "413": *a46 - "415": *a47 - "422": *a48 + "403": *a9 + "413": *a53 + "415": *a54 + "422": *a55 "429": *a5 delete: tags: @@ -21217,6 +21591,7 @@ paths: schema: $ref: "#/components/schemas/FitbitCredentialsDeleteEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/fitbit/status: @@ -21234,6 +21609,7 @@ paths: schema: $ref: "#/components/schemas/FitbitStatusEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/fitbit/disconnect: @@ -21253,6 +21629,7 @@ paths: schema: $ref: "#/components/schemas/FitbitDisconnectEnvelope" "401": *a1 + "403": *a9 "404": description: "No Fitbit connection to remove. NOT an idempotent no-op: a second disconnect answers 404 rather than 200." content: @@ -21289,6 +21666,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 + "403": *a9 "410": description: Google turned the legacy Fitbit Web API off on 2026-10-30; nothing is fetched. `meta.errorCode` = `provider_retired`. The data already imported stays; Google Health reads the same devices. @@ -21330,6 +21708,7 @@ paths: schema: $ref: "#/components/schemas/GoogleHealthCredentialsStatusEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 put: @@ -21350,11 +21729,12 @@ paths: application/json: schema: $ref: "#/components/schemas/GoogleHealthCredentialsUpdateEnvelope" - "400": *a45 + "400": *a52 "401": *a1 - "413": *a46 - "415": *a47 - "422": *a48 + "403": *a9 + "413": *a53 + "415": *a54 + "422": *a55 "429": *a5 delete: tags: @@ -21371,6 +21751,7 @@ paths: schema: $ref: "#/components/schemas/GoogleHealthCredentialsDeleteEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/google-health/status: @@ -21389,6 +21770,7 @@ paths: schema: $ref: "#/components/schemas/GoogleHealthStatusEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/google-health/disconnect: @@ -21407,6 +21789,7 @@ paths: schema: $ref: "#/components/schemas/GoogleHealthDisconnectEnvelope" "401": *a1 + "403": *a9 "404": description: "No Google Health connection to remove. NOT an idempotent no-op: a second disconnect answers 404 rather than 200." @@ -21446,6 +21829,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 + "403": *a9 "409": description: Another sync of this account is already running (the history backfill after a reconnect, or the hourly poll), so none was started. `meta.errorCode` = `sync_in_progress`. `GET /api/google-health/sync/status` @@ -21495,6 +21879,7 @@ paths: schema: $ref: "#/components/schemas/OuraCredentialsStatusEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 put: @@ -21515,11 +21900,12 @@ paths: application/json: schema: $ref: "#/components/schemas/OuraCredentialsUpdateEnvelope" - "400": *a45 + "400": *a52 "401": *a1 - "413": *a46 - "415": *a47 - "422": *a48 + "403": *a9 + "413": *a53 + "415": *a54 + "422": *a55 "429": *a5 delete: tags: @@ -21536,6 +21922,7 @@ paths: schema: $ref: "#/components/schemas/OuraCredentialsDeleteEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/oura/status: @@ -21554,6 +21941,7 @@ paths: schema: $ref: "#/components/schemas/OuraStatusEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/oura/disconnect: @@ -21572,6 +21960,7 @@ paths: schema: $ref: "#/components/schemas/OuraDisconnectEnvelope" "401": *a1 + "403": *a9 "404": description: "No Oura token stored. NOT an idempotent no-op: a second disconnect answers 404 rather than 200." content: @@ -21595,21 +21984,22 @@ paths: schema: $ref: "#/components/schemas/OuraTestEnvelope" "401": *a1 - "422": &a49 + "403": *a9 + "422": &a56 description: No usable connection for this provider — nothing stored, or the stored grant could no longer be resolved into an access token. `meta.errorCode` = `not_configured`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": &a50 + "429": &a57 description: More than 5 requests in 60 s from this user. `meta.errorCode` = `rate_limited_self` — this is the local limiter, distinct from a `rate_limited` 502 which is the provider's. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "502": &a51 + "502": &a58 description: "The probe did not complete. `meta.errorCode` names the class: `credentials_rejected` (the provider answered 401/403 — the stored grant is gone), `rate_limited` (the PROVIDER answered 429, which is not this endpoint's own limiter), `upstream_error` (5xx), `timeout` (no answer within 5 s), `connection_failed` @@ -21633,6 +22023,7 @@ paths: schema: $ref: "#/components/schemas/PolarCredentialsStatusEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 put: @@ -21654,11 +22045,12 @@ paths: application/json: schema: $ref: "#/components/schemas/PolarCredentialsUpdateEnvelope" - "400": *a45 + "400": *a52 "401": *a1 - "413": *a46 - "415": *a47 - "422": *a48 + "403": *a9 + "413": *a53 + "415": *a54 + "422": *a55 "429": *a5 delete: tags: @@ -21675,6 +22067,7 @@ paths: schema: $ref: "#/components/schemas/PolarCredentialsDeleteEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/polar/status: @@ -21693,6 +22086,7 @@ paths: schema: $ref: "#/components/schemas/PolarStatusEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/polar/disconnect: @@ -21711,6 +22105,7 @@ paths: schema: $ref: "#/components/schemas/PolarDisconnectEnvelope" "401": *a1 + "403": *a9 "404": description: "No Polar token stored. NOT an idempotent no-op: a second disconnect answers 404 rather than 200." content: @@ -21734,9 +22129,10 @@ paths: schema: $ref: "#/components/schemas/PolarTestEnvelope" "401": *a1 - "422": *a49 - "429": *a50 - "502": *a51 + "403": *a9 + "422": *a56 + "429": *a57 + "502": *a58 /api/strava/credentials: get: tags: @@ -21752,6 +22148,7 @@ paths: schema: $ref: "#/components/schemas/StravaCredentialsStatusEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 put: @@ -21772,11 +22169,12 @@ paths: application/json: schema: $ref: "#/components/schemas/StravaCredentialsUpdateEnvelope" - "400": *a45 + "400": *a52 "401": *a1 - "413": *a46 - "415": *a47 - "422": *a48 + "403": *a9 + "413": *a53 + "415": *a54 + "422": *a55 "429": *a5 delete: tags: @@ -21794,6 +22192,7 @@ paths: schema: $ref: "#/components/schemas/StravaCredentialsDeleteEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/strava/status: @@ -21812,6 +22211,7 @@ paths: schema: $ref: "#/components/schemas/StravaStatusEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/strava/disconnect: @@ -21831,6 +22231,7 @@ paths: schema: $ref: "#/components/schemas/StravaDisconnectEnvelope" "401": *a1 + "403": *a9 "404": description: "No Strava connection. NOT an idempotent no-op: a second disconnect answers 404 rather than 200." content: @@ -21854,9 +22255,10 @@ paths: schema: $ref: "#/components/schemas/StravaTestEnvelope" "401": *a1 - "422": *a49 - "429": *a50 - "502": *a51 + "403": *a9 + "422": *a56 + "429": *a57 + "502": *a58 /api/integrations/status: get: tags: @@ -21873,6 +22275,7 @@ paths: schema: $ref: "#/components/schemas/IntegrationStatusEnvelopeResponse" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/integrations/fitbit/resume: @@ -21891,8 +22294,9 @@ paths: schema: $ref: "#/components/schemas/FitbitResumeEnvelope" "401": *a1 + "403": *a9 "422": *a4 - "429": *a50 + "429": *a57 /api/integrations/fitbit/test: post: tags: @@ -21910,6 +22314,7 @@ paths: schema: $ref: "#/components/schemas/FitbitIntegrationTestEnvelope" "401": *a1 + "403": *a9 "410": description: Google turned the legacy Fitbit Web API off on 2026-10-30; nothing is probed. `meta.errorCode` = `provider_retired`. @@ -21917,9 +22322,9 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a49 - "429": *a50 - "502": *a51 + "422": *a56 + "429": *a57 + "502": *a58 /api/integrations/google-health/resume: post: tags: @@ -21935,8 +22340,9 @@ paths: schema: $ref: "#/components/schemas/GoogleHealthResumeEnvelope" "401": *a1 + "403": *a9 "422": *a4 - "429": *a50 + "429": *a57 /api/integrations/google-health/test: post: tags: @@ -21963,15 +22369,16 @@ paths: schema: $ref: "#/components/schemas/GoogleHealthIntegrationTestEnvelope" "401": *a1 + "403": *a9 "413": description: Body exceeds 64 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a49 - "429": *a50 - "502": *a51 + "422": *a56 + "429": *a57 + "502": *a58 /api/integrations/whoop/resume: post: tags: @@ -21987,8 +22394,9 @@ paths: schema: $ref: "#/components/schemas/WhoopResumeEnvelope" "401": *a1 + "403": *a9 "422": *a4 - "429": *a50 + "429": *a57 /api/integrations/whoop/test: post: tags: @@ -22005,9 +22413,10 @@ paths: schema: $ref: "#/components/schemas/WhoopIntegrationTestEnvelope" "401": *a1 - "422": *a49 - "429": *a50 - "502": *a51 + "403": *a9 + "422": *a56 + "429": *a57 + "502": *a58 /api/integrations/withings/resume: post: tags: @@ -22024,8 +22433,9 @@ paths: schema: $ref: "#/components/schemas/WithingsResumeEnvelope" "401": *a1 + "403": *a9 "422": *a4 - "429": *a50 + "429": *a57 /api/integrations/withings/test: post: tags: @@ -22043,8 +22453,9 @@ paths: schema: $ref: "#/components/schemas/WithingsIntegrationTestEnvelope" "401": *a1 - "422": *a49 - "429": *a50 + "403": *a9 + "422": *a56 + "429": *a57 "502": description: "The probe did not complete. The shared codes apply (`credentials_rejected`, `rate_limited`, `upstream_error`, `timeout`, `connection_failed`) and this path adds one of its own: @@ -22077,7 +22488,7 @@ paths: schema: $ref: "#/components/schemas/DailyDigestEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 /api/daily/digest/dismiss: @@ -22112,6 +22523,7 @@ paths: schema: $ref: "#/components/schemas/DismissPriorityItemEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/dashboard/chart-overlay-prefs: @@ -22146,6 +22558,7 @@ paths: schema: $ref: "#/components/schemas/ChartOverlayPrefsSavedEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/dashboard/summary: @@ -22168,7 +22581,7 @@ paths: schema: $ref: "#/components/schemas/DashboardSummaryEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 /api/dashboard/widgets: @@ -22187,7 +22600,7 @@ paths: schema: $ref: "#/components/schemas/DashboardLayout" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 put: @@ -22213,6 +22626,7 @@ paths: schema: $ref: "#/components/schemas/DashboardLayoutSaved" "401": *a1 + "403": *a9 "409": description: Dashboard layout changed since it was loaded (optimistic-concurrency conflict). No write happened. Re-read the resource, re-apply the user's change against the fresh state, and resend with the new token. @@ -22221,7 +22635,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a15 + "422": *a19 "429": *a5 delete: tags: @@ -22237,6 +22651,7 @@ paths: schema: $ref: "#/components/schemas/DashboardLayoutReset" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/managed-profiles/{id}/handover: @@ -22260,6 +22675,7 @@ paths: schema: $ref: "#/components/schemas/ManagedProfileHandoverStatusEnvelope" "401": *a1 + "403": *a9 "404": description: "No such managed profile, or the caller is not one of its Guardians (`meta.errorCode: managed_profile.not_found`)." @@ -22300,9 +22716,11 @@ paths: $ref: "#/components/schemas/ManagedProfileHandoverEnvelope" "401": *a1 "403": - description: "Neither a password nor single sign-on could redeem a link on this instance (`meta.errorCode: - profile_claim.oidc_only_unsupported`). Since v1.43 a single-sign-on-only instance hands over through the - identity provider, so a working configuration never answers this." + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: Neither a password nor single + sign-on could redeem a link on this instance (`meta.errorCode: profile_claim.oidc_only_unsupported`). Since + v1.43 a single-sign-on-only instance hands over through the identity provider, so a working configuration + never answers this." content: application/json: schema: @@ -22342,6 +22760,7 @@ paths: schema: $ref: "#/components/schemas/RevokeManagedProfileHandoverEnvelope" "401": *a1 + "403": *a9 "404": description: "No such managed profile, or the caller is not one of its Guardians (`meta.errorCode: managed_profile.not_found`)." @@ -22366,6 +22785,7 @@ paths: schema: $ref: "#/components/schemas/PendingHandoverDecisionEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 post: @@ -22389,6 +22809,7 @@ paths: schema: $ref: "#/components/schemas/HandoverDecisionEnvelope" "401": *a1 + "403": *a9 "409": description: "No decision is waiting (`meta.errorCode: managed_profile.handover.no_pending`)." content: @@ -22423,7 +22844,9 @@ paths: $ref: "#/components/schemas/AccountPayloadEnvelope" "401": *a1 "403": - description: "A selector header was attached to an actor surface (`meta.errorCode: sharing.not_permitted`)." + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: A selector header was attached to + an actor surface (`meta.errorCode: sharing.not_permitted`)." content: application/json: schema: @@ -22450,11 +22873,13 @@ paths: schema: $ref: "#/components/schemas/RecordActivityEnvelope" "401": *a1 - "403": &a52 - description: Refused. `meta.errorCode` is `sharing.not_permitted` when the request was made while acting on another - account (grant management is never delegable), or `sharing.access.denied` when the caller may not act as the - account they named. The latter is byte-identical for an account that does not exist and one that granted - nothing — the refusal is not an enumeration oracle. + "403": &a59 + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: Refused. `meta.errorCode` is + `sharing.not_permitted` when the request was made while acting on another account (grant management is never + delegable), or `sharing.access.denied` when the caller may not act as the account they named. The latter is + byte-identical for an account that does not exist and one that granted nothing — the refusal is not an + enumeration oracle." content: application/json: schema: @@ -22475,7 +22900,7 @@ paths: schema: $ref: "#/components/schemas/AccountGrantListEnvelope" "401": *a1 - "403": *a52 + "403": *a59 "422": *a4 "429": *a5 post: @@ -22519,10 +22944,12 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "403": - description: Refused. `meta.errorCode` is `sharing.not_permitted` when the request was made while acting on another - account (grant management is never delegable), or `sharing.invite.manage_browser_only` when a Bearer caller - tried to offer MANAGE — the step-up that gates it is cookie-only by construction, so the native client - cannot mint it and should route the person to a browser rather than surface an error. + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: Refused. `meta.errorCode` is + `sharing.not_permitted` when the request was made while acting on another account (grant management is never + delegable), or `sharing.invite.manage_browser_only` when a Bearer caller tried to offer MANAGE — the step-up + that gates it is cookie-only by construction, so the native client cannot mint it and should route the + person to a browser rather than surface an error." content: application/json: schema: @@ -22577,7 +23004,7 @@ paths: schema: $ref: "#/components/schemas/AcceptedAccountGrantEnvelope" "401": *a1 - "403": *a52 + "403": *a59 "404": description: No such invitation for this account. content: @@ -22621,7 +23048,7 @@ paths: schema: $ref: "#/components/schemas/RevokedAccountGrantEnvelope" "401": *a1 - "403": *a52 + "403": *a59 "404": description: No such grant on this account's record. content: @@ -22662,7 +23089,7 @@ paths: schema: $ref: "#/components/schemas/RenouncedAccountGrantEnvelope" "401": *a1 - "403": *a52 + "403": *a59 "404": description: No such grant held by this account. content: @@ -22717,6 +23144,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" + "403": *a9 "422": *a4 "429": description: "More than ten profiles from one caller within the hour (`meta.errorCode: http.rate_limited`, the status @@ -22750,6 +23178,7 @@ paths: schema: $ref: "#/components/schemas/ManagedProfileEnvelope" "401": *a1 + "403": *a9 "404": description: "No such managed profile, or the caller is not one of its Guardians (`meta.errorCode: managed_profile.not_found`). The two are byte-identical, so the refusal is not an enumeration oracle." @@ -22794,6 +23223,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" + "403": *a9 "404": description: "No such managed profile, or the caller is not one of its Guardians (`meta.errorCode: managed_profile.not_found`). The two are byte-identical." @@ -22837,6 +23267,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" + "403": *a9 "404": description: "No such managed profile, or the caller is not one of its Guardians (`meta.errorCode: managed_profile.not_found`). The two are byte-identical, so the refusal is not an enumeration oracle — and @@ -22875,6 +23306,7 @@ paths: schema: $ref: "#/components/schemas/ManagedProfileGuardianListEnvelope" "401": *a1 + "403": *a9 "404": description: "No such managed profile, or the caller is not one of its Guardians (`meta.errorCode: managed_profile.not_found`). The two are byte-identical." @@ -22919,6 +23351,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" + "403": *a9 "404": description: "Two refusals share this status, each with its own code. `meta.errorCode: sharing.invite.no_account`: no account on this instance carries that identifier — the same deliberate disclosure to an authenticated caller @@ -22989,6 +23422,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" + "403": *a9 "404": description: "`meta.errorCode: managed_profile.not_found`, for four situations the caller cannot tell apart: no such profile, no such grant on it, the caller is not one of its Guardians, or the target grant is the caller's @@ -23047,7 +23481,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a52 + "403": *a59 "409": description: "The record this browser session is on is not the record the request asserted. `meta.errorCode` is `sharing.session.changed`. It is not a refusal of access — the caller may well be entitled to both records — @@ -23127,7 +23561,9 @@ paths: $ref: "#/components/schemas/WebPushSubscribeEnvelope" "401": *a1 "403": - description: The operator switched Web Push off for this instance. Nothing was stored. + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: The operator switched Web Push + off for this instance. Nothing was stored." content: application/json: schema: @@ -23166,6 +23602,7 @@ paths: schema: $ref: "#/components/schemas/WebPushUnsubscribeEnvelope" "401": *a1 + "403": *a9 "413": description: Request body exceeds 65536 bytes. content: @@ -23197,6 +23634,7 @@ paths: schema: $ref: "#/components/schemas/WebPushTestEnvelope" "401": *a1 + "403": *a9 "422": description: "Nothing to test against: the account has no Push subscription (`meta.errorCode` = `not_configured`), or the operator has configured no VAPID keys (`vapid_not_configured`)." @@ -23238,6 +23676,7 @@ paths: schema: $ref: "#/components/schemas/ApnsTestEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": description: More than 5 tests in a minute (`meta.errorCode` = `rate_limited_self`). @@ -23312,6 +23751,7 @@ paths: schema: $ref: "#/components/schemas/GetNotificationPreferences" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 put: @@ -23334,6 +23774,7 @@ paths: schema: $ref: "#/components/schemas/PutNotificationPreferenceResponse" "401": *a1 + "403": *a9 "404": description: No such channel for this caller. content: @@ -23369,6 +23810,7 @@ paths: schema: $ref: "#/components/schemas/GetNotificationStatus" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 post: @@ -23393,6 +23835,7 @@ paths: schema: $ref: "#/components/schemas/ReEnableNotificationChannelResponse" "401": *a1 + "403": *a9 "404": description: No such channel for this caller. content: @@ -23427,10 +23870,11 @@ paths: schema: $ref: "#/components/schemas/McpConnectionListEnvelope" "401": *a1 - "403": &a53 - description: The operator has disabled the API instance-wide. Every route in this module refuses while that setting is - off, for a cookie session as well as for a Bearer token; it is not a per-user permission and retrying will - not help. + "403": &a60 + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: The operator has disabled the API + instance-wide. Every route in this module refuses while that setting is off, for a cookie session as well as + for a Bearer token; it is not a per-user permission and retrying will not help." content: application/json: schema: @@ -23460,7 +23904,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectionRevokeEnvelope" "401": *a1 - "403": *a53 + "403": *a60 "404": description: No live connection with this id for this caller — also the answer for an already-revoked one, so a repeat revoke is 404 rather than an idempotent 200. @@ -23487,7 +23931,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectorTokenListEnvelope" "401": *a1 - "403": *a53 + "403": *a60 "422": *a4 "429": *a5 post: @@ -23520,7 +23964,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a53 + "403": *a60 "413": description: Body exceeds 16 KiB. content: @@ -23563,7 +24007,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectorTokenRevokeEnvelope" "401": *a1 - "403": *a53 + "403": *a60 "404": description: No `health:read` token with this id for this caller. An already-revoked token is still found, so a repeat revoke answers 200. @@ -23590,7 +24034,7 @@ paths: schema: $ref: "#/components/schemas/RecordSettingsEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 /api/record-settings/integrations: @@ -23609,7 +24053,7 @@ paths: schema: $ref: "#/components/schemas/RecordSettingsIntegrationsEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 /api/record-settings/integrations/{provider}/connect: @@ -23625,7 +24069,7 @@ paths: record (audited). Fitbit is not offered: its classic API is retired. Cookie session, while acting on a managed record the caller guards." parameters: - - &a54 + - &a61 name: provider in: path required: true @@ -23647,7 +24091,7 @@ paths: schema: $ref: "#/components/schemas/ManagedConnectStartEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "404": description: The path named a provider outside the closed set. Answered before the caller is authenticated. content: @@ -23672,7 +24116,7 @@ paths: Audited against the record with the Guardian as the actor. Cookie session or Bearer, while acting on a managed record the caller guards." parameters: - - *a54 + - *a61 responses: "200": description: The provider is disconnected. @@ -23681,7 +24125,7 @@ paths: schema: $ref: "#/components/schemas/ManagedDisconnectEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "404": description: The path named a provider outside the closed set, or the record has no connection to it. content: @@ -23698,7 +24142,7 @@ paths: description: Returns the named family's resolved settings for the record the session is acting on. Cookie session or Bearer, while acting on a managed record the caller guards. parameters: - - &a55 + - &a62 name: family in: path required: true @@ -23722,7 +24166,7 @@ paths: schema: $ref: "#/components/schemas/GetRecordSettingsFamilyEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "404": description: Either the path named a family outside the closed set, or the record row itself is gone. The two are not distinguished, and the first is answered before the caller is authenticated at all. @@ -23742,7 +24186,7 @@ paths: ride the audit row. Body capped at 64 KiB. Cookie session or Bearer, while acting on a managed record the caller guards. parameters: - - *a55 + - *a62 requestBody: required: true content: @@ -23763,7 +24207,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "404": description: The path named a family outside the closed set. Answered before authentication. content: @@ -23877,7 +24321,7 @@ paths: schema: $ref: "#/components/schemas/ListPersonalRecordsEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "422": description: "`metricType` or `limit` carried an unacceptable value. `meta.errorCode` = `personal_records.invalid_query`, with the offending path under `details.issues`." @@ -23930,7 +24374,7 @@ paths: schema: $ref: "#/components/schemas/AchievementsEnvelope" "401": *a1 - "403": *a13 + "403": *a17 "422": *a4 "429": *a5 /api/environment/preferences: @@ -23956,7 +24400,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentPreferencesEnvelope" "401": *a1 - "403": *a19 + "403": *a11 "422": *a4 "429": *a5 /api/environment: @@ -23974,7 +24418,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentOverviewEnvelope" "401": *a1 - "403": *a19 + "403": *a11 "422": *a4 "429": *a5 /api/environment/home: @@ -24011,7 +24455,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentHomeEnvelope" "401": *a1 - "403": *a19 + "403": *a11 "413": description: Body exceeds 4 KiB. content: @@ -24071,7 +24515,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentBackfillEnvelope" "401": *a1 - "403": *a19 + "403": *a11 "409": description: No home location is set, so there is nothing to resolve the days against (`meta.errorCode` = `environment.no_home`), or a backfill for this account is already queued in the current twenty-minute slot @@ -24131,7 +24575,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentGeocodeEnvelope" "401": *a1 - "403": *a19 + "403": *a11 "422": description: "`q` was missing, empty after trimming, or longer than 120 characters. `meta.errorCode` = `environment.invalid`." @@ -24170,7 +24614,7 @@ paths: schema: $ref: "#/components/schemas/CreateEnvironmentTravelEnvelope" "401": *a1 - "403": *a19 + "403": *a11 "413": description: Body exceeds 4 KiB. content: @@ -24223,7 +24667,7 @@ paths: schema: $ref: "#/components/schemas/DeleteEnvironmentTravelEnvelope" "401": *a1 - "403": *a19 + "403": *a11 "404": description: No such override for this account. `meta.errorCode` = `environment.travel.not_found`. Also the answer for an id owned by somebody else — existence is not leaked. @@ -24343,6 +24787,93 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" + /api/admin/backups/upload: + post: + tags: + - Admin + summary: Store an uploaded backup file as a new copy + description: "Reads a backup file, checks it, and stores it encrypted as a new copy beside the weekly one; it does NOT + restore anything. Restoring is the separate `POST /api/admin/backups/{id}/restore`, so an operator can review + what was uploaded first. The file goes up as the raw request body, plain JSON or gzip-compressed JSON (sniffed + from the bytes, not the content type), up to 512 MB as sent; or, for a file up to 10 MB, as + `multipart/form-data` with one `file` field. It is read as a stream, checked as it arrives, and stored in the + same pass; a file that fails any check leaves nothing behind. It is refused when it is not valid JSON, fails the + backup schema, was written by a newer schema version than this server reads, needs an encryption key this server + does not hold (`meta.errorCode` = `backup.key.missing`, `meta.keyIds` naming them), or belongs to an account + that does not exist here. Three uploads a minute per administrator. Admin session cookie required; Bearer tokens + cannot reach admin endpoints. A browser session needs a fresh proof as well as a live one: a sign-in or `POST + /api/auth/reproof` within the last five minutes (with a second factor, on an account that has one). Without it + the answer is 401 `auth.reproof.required`, with `meta.methods` naming the proofs the account can give." + requestBody: + required: true + content: + application/json: + schema: + type: object + propertyNames: + type: string + additionalProperties: {} + description: The backup document, as `GET /api/admin/backups/{id}/download` or the account export wrote it. + application/gzip: + schema: + type: string + format: binary + multipart/form-data: + schema: + type: object + properties: + file: + type: string + format: binary + description: The backup file, plain or gzip-compressed, up to 10 MB. + required: + - file + responses: + "201": + description: The file was stored as a new copy. + content: + application/json: + schema: + $ref: "#/components/schemas/AdminBackupUploadEnvelope" + "400": + description: A `multipart/form-data` body that could not be parsed as a form. Nothing was stored. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "401": + description: "No session (`auth.missing`), or a session without a recent proof (`auth.reproof.required`, with + `meta.methods`). A Bearer token lands here too: the admin gate reads only the session cookie." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "403": *a25 + "413": + description: "Too large: over 512 MB as sent (10 MB in a form), a gzip file that expands past what any backup this + server wrote does, or larger than the stored-copy limit once encrypted (`meta.errorCode` = + `backup.upload.too_large`, the numbers and the setting in the message). Nothing was stored." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": + description: "The file was refused and nothing was stored: no file in the request, not valid JSON or not readable as + gzip, failed the backup schema (`meta.issues`), an unsupported schema version, a key this server does not + hold (`meta.errorCode` = `backup.key.missing`, `meta.keyIds`), or an owner account that does not exist + here." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "429": + description: More than three uploads in a minute by this administrator. Nothing was stored. The `Retry-After` and + `X-RateLimit-*` headers describe the bucket. + headers: *a8 + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" /api/admin/backups/{id}/download: get: tags: @@ -24369,13 +24900,13 @@ paths: application/json: schema: $ref: "#/components/schemas/AdminBackupDocument" - "403": &a56 + "403": &a63 description: Caller is not an admin. `requireAdmin()` is cookie-only, so a Bearer token lands here whatever its scope. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "404": &a57 + "404": &a64 description: No stored copy with this id. content: application/json: @@ -24449,8 +24980,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "403": *a56 - "404": *a57 + "403": *a63 + "404": *a64 "409": description: A restore of the same account is already queued or running (`meta.errorCode` = `backup.restore.active`, `meta.jobId` names it), the stored copy was replaced by a newer one while it was being checked @@ -24494,7 +25025,7 @@ paths: application/json: schema: $ref: "#/components/schemas/AdminBackupRestoreJobListResponse" - "403": *a56 + "403": *a63 /api/admin/backups/restores/{jobId}: get: tags: @@ -24517,7 +25048,7 @@ paths: application/json: schema: $ref: "#/components/schemas/AdminBackupRestoreJobResponse" - "403": *a56 + "403": *a63 "404": description: No restore job with this id. content: @@ -24536,7 +25067,7 @@ paths: request leaves the server. Needs neither the Documents module nor the list: with the list unset, `available` is false and any connection still stored is listed (with `originAllowed: false`) so it can be removed. Cookie session only; not delegable." - security: &a58 + security: &a65 - cookieAuth: [] responses: "200": @@ -24546,10 +25077,12 @@ paths: schema: $ref: "#/components/schemas/DocumentSourcesStatusEnvelope" "401": *a1 - "403": &a59 - description: "Refused before any request to the source: `documents.sources.browserOnly` for a Bearer caller, - `sharing.not_permitted` while acting on another person's record (a delegate or guardian never uses the - owner's archive), or `module.disabled` when the Documents module is off." + "403": &a66 + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: Refused before any request to the + source: `documents.sources.browserOnly` for a Bearer caller, `sharing.not_permitted` while acting on another + person's record (a delegate or guardian never uses the owner's archive), or `module.disabled` when the + Documents module is off." content: application/json: schema: @@ -24569,9 +25102,9 @@ paths: `documents.sources.tokenRequired`), so a stored token is never sent to an address it was not saved for. A Papra organization id is letters, digits, `_` and `-`. The token is stored encrypted (AES-256-GCM) and never returned. Ten saves and tests a minute per person. Cookie session only; not delegable." - security: *a58 + security: *a65 parameters: - - &a60 + - &a67 name: system in: path required: true @@ -24610,8 +25143,8 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceConnectionEnvelope" "401": *a1 - "403": *a59 - "404": &a61 + "403": *a66 + "404": &a68 description: "`documents.sources.unavailable`: the operator has not listed any origin in `DOCUMENT_SOURCE_ORIGINS`, so the picker is off. `documents.sources.notConnected`: no connection is saved for this system. `documents.sources.notFound`: the source has no such document, or (Papra) no such organization, including @@ -24630,7 +25163,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 - "502": &a62 + "502": &a69 description: "The source could not answer usefully, with the reason in `meta.errorCode` and, when the source answered at all, its HTTP status in `meta.upstreamStatus` (its body is never passed on): `documents.sources.unreachable` (no connection, timeout, or a destination refused at dial time), `documents.sources.redirected` (the source @@ -24649,9 +25182,9 @@ paths: description: Deletes the caller's connection for this system, and its token with it. Works even after the operator removed the origin from the list, and with the Documents module switched off. `disconnected` is false when there was nothing to delete. Cookie session only; not delegable. - security: *a58 + security: *a65 parameters: - - *a60 + - *a67 responses: "200": description: Removed, or there was nothing to remove. @@ -24660,7 +25193,7 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceDisconnectEnvelope" "401": *a1 - "403": *a59 + "403": *a66 "422": *a4 "429": *a5 /api/documents/sources/{system}/test: @@ -24671,9 +25204,9 @@ paths: description: One request to the source with the stored token. Answers `ok` with the round-trip time and stamps `lastVerifiedAt`, or the reason in `meta.errorCode`. Shares the save's allowance of ten a minute. Cookie session only; not delegable. - security: *a58 + security: *a65 parameters: - - *a60 + - *a67 responses: "200": description: The source answered. @@ -24682,9 +25215,9 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceTestEnvelope" "401": *a1 - "403": *a59 - "404": *a61 - "422": &a63 + "403": *a66 + "404": *a68 + "422": &a70 description: "`documents.sources.originNotAllowed`: the base address's origin is not on the operator's list (checked on every request, so a connection saved earlier is cut off once its origin is removed), or another validation failure." @@ -24693,7 +25226,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 - "502": *a62 + "502": *a69 /api/documents/sources/{system}/tags: get: tags: @@ -24701,9 +25234,9 @@ paths: summary: Tags of a connected archive description: The source's tags for the picker's filter, the first 250 by name, in the source's own ids. Shares the search allowance of sixty a minute. Cookie session only; not delegable. - security: *a58 + security: *a65 parameters: - - *a60 + - *a67 responses: "200": description: The tags. @@ -24712,11 +25245,11 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceTagsEnvelope" "401": *a1 - "403": *a59 - "404": *a61 - "422": *a63 + "403": *a66 + "404": *a68 + "422": *a70 "429": *a5 - "502": *a62 + "502": *a69 /api/documents/sources/{system}/search: get: tags: @@ -24730,9 +25263,9 @@ paths: under that source key: `new`, `imported` with the vault document's `documentId`, or `deleted` (deleted in HealthLog; importing it again stores nothing). Nothing is stored. Sixty searches a minute, shared with the tag read. Cookie session only; not delegable." - security: *a58 + security: *a65 parameters: - - *a60 + - *a67 - name: q in: query required: false @@ -24773,11 +25306,11 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceSearchEnvelope" "401": *a1 - "403": *a59 - "404": *a61 - "422": *a63 + "403": *a66 + "404": *a68 + "422": *a70 "429": *a5 - "502": *a62 + "502": *a69 /api/documents/sources/{system}/import: post: tags: @@ -24796,9 +25329,9 @@ paths: fetched), including a duplicate. `kind` sets the document type (default OTHER). Automatic AI reading follows the person's own setting. Counts against the person's upload allowance (60 an hour) and storage quota. 201 for a new document, 200 otherwise. Cookie session only; not delegable." - security: *a58 + security: *a65 parameters: - - *a60 + - *a67 requestBody: required: true content: @@ -24856,7 +25389,7 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceImportEnvelope" "401": *a1 - "403": *a59 + "403": *a66 "404": description: "`documents.sources.unavailable`: the operator has not listed any origin in `DOCUMENT_SOURCE_ORIGINS`, so the picker is off. `documents.sources.notConnected`: no connection is saved for this system. @@ -24887,7 +25420,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a63 + "422": *a70 "429": description: "`documents.inbound.rateLimited` (the person's hourly upload allowance, with the rate-limit headers) or `documents.inbound.uploadBusy` (too many uploads in progress; `Retry-After: 1`)." @@ -24895,7 +25428,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "502": *a62 + "502": *a69 /api/symptoms/definitions: get: tags: @@ -24919,10 +25452,11 @@ paths: schema: $ref: "#/components/schemas/ListSymptomDefinitionsEnvelope" "401": *a1 - "403": &a64 + "403": &a71 description: >- - `illness.disabled`: the record has the illness module switched off (or the operator turned it off - server-wide); symptoms ride that module. + `meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: `illness.disabled`: the record + has the illness module switched off (or the operator turned it off server-wide); symptoms ride that module. Refused: this request named a record the caller may not act on (`meta.errorCode` = `sharing.access.denied`). @@ -24962,7 +25496,7 @@ paths: schema: $ref: "#/components/schemas/CreateSymptomDefinitionEnvelope" "401": *a1 - "403": *a64 + "403": *a71 "409": *a7 "422": *a4 "429": *a5 @@ -24992,8 +25526,8 @@ paths: schema: $ref: "#/components/schemas/UpdateSymptomDefinitionEnvelope" "401": *a1 - "403": *a64 - "404": &a65 + "403": *a71 + "404": &a72 description: "`symptoms.definition.notFound`: no definition with this id on the record." content: application/json: @@ -25027,8 +25561,8 @@ paths: schema: $ref: "#/components/schemas/DeleteSymptomDefinitionEnvelope" "401": *a1 - "403": *a64 - "404": *a65 + "403": *a71 + "404": *a72 "422": *a4 "429": *a5 /api/symptoms/events: @@ -25077,7 +25611,7 @@ paths: schema: $ref: "#/components/schemas/ListSymptomEventsEnvelope" "401": *a1 - "403": *a64 + "403": *a71 "422": *a4 "429": *a5 post: @@ -25103,8 +25637,8 @@ paths: schema: $ref: "#/components/schemas/CreateSymptomEventEnvelope" "401": *a1 - "403": *a64 - "404": *a65 + "403": *a71 + "404": *a72 "409": *a7 "422": *a4 "429": *a5 @@ -25134,8 +25668,8 @@ paths: schema: $ref: "#/components/schemas/UpdateSymptomEventEnvelope" "401": *a1 - "403": *a64 - "404": &a66 + "403": *a71 + "404": &a73 description: "`symptoms.event.notFound`: no occurrence with this id on the record." content: application/json: @@ -25162,8 +25696,8 @@ paths: schema: $ref: "#/components/schemas/DeleteSymptomEventEnvelope" "401": *a1 - "403": *a64 - "404": *a66 + "403": *a71 + "404": *a73 "422": *a4 "429": *a5 /api/day/index: @@ -25196,7 +25730,7 @@ paths: schema: $ref: "#/components/schemas/DayIndexEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 /api/day/notable: @@ -25231,7 +25765,7 @@ paths: schema: $ref: "#/components/schemas/DayNotableWindowEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 /api/day/{date}: @@ -25261,7 +25795,7 @@ paths: schema: $ref: "#/components/schemas/DayEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 /api/timeline: @@ -25314,10 +25848,12 @@ paths: schema: $ref: "#/components/schemas/TimelineEnvelope" "401": *a1 - "403": &a67 + "403": &a74 description: >- - `module.disabled` with `meta.module = timeline`: the record has the timeline switched off (it is opt-in), or - the operator turned it off server-wide. + `meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: `module.disabled` with + `meta.module = timeline`: the record has the timeline switched off (it is opt-in), or the operator turned it + off server-wide. Refused: this request named a record the caller may not act on (`meta.errorCode` = `sharing.access.denied`). @@ -25351,7 +25887,7 @@ paths: schema: $ref: "#/components/schemas/TimelineReadinessEnvelope" "401": *a1 - "403": *a67 + "403": *a74 "422": *a4 "429": *a5 /api/life-events: @@ -25369,10 +25905,12 @@ paths: schema: $ref: "#/components/schemas/LifeEventListEnvelope" "401": *a1 - "403": &a68 + "403": &a75 description: >- - Owner-only in v1.42: no share reaches life events, not a `profile` share, a whole-record share or MANAGE, - and not a guardian acting for a managed profile. + `meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: Owner-only in v1.42: no share + reaches life events, not a `profile` share, a whole-record share or MANAGE, and not a guardian acting for a + managed profile. Refused: the request was made while the session is acting on another account (`meta.errorCode` = @@ -25409,10 +25947,10 @@ paths: schema: $ref: "#/components/schemas/CreateLifeEventEnvelope" "401": *a1 - "403": *a68 + "403": *a75 "409": *a7 "422": *a4 - "429": *a12 + "429": *a16 /api/life-events/{id}: patch: tags: @@ -25440,8 +25978,8 @@ paths: schema: $ref: "#/components/schemas/UpdateLifeEventEnvelope" "401": *a1 - "403": *a68 - "404": &a69 + "403": *a75 + "404": &a76 description: No such life event on this account. content: application/json: @@ -25468,8 +26006,8 @@ paths: schema: $ref: "#/components/schemas/DeleteLifeEventEnvelope" "401": *a1 - "403": *a68 - "404": *a69 + "403": *a75 + "404": *a76 "422": *a4 "429": *a5 /api/search: @@ -25515,7 +26053,7 @@ paths: schema: $ref: "#/components/schemas/SearchEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 /api/auth/me/ai-usage: @@ -25540,6 +26078,7 @@ paths: schema: $ref: "#/components/schemas/PutAiUsageResponse" "401": *a1 + "403": *a9 "422": description: "`ai.usage.invalid` (body) or `ai.usage.aboveCeiling` (`meta.maxLevel` names the ceiling)." content: @@ -25582,8 +26121,9 @@ paths: "401": *a1 "403": description: >- - The opt-in `nutrients` module is off for this account: 403 `module.disabled` with `meta.module: - "nutrients"`. + `meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: The opt-in `nutrients` module is + off for this account: 403 `module.disabled` with `meta.module: "nutrients"`. Refused: this request named a record the caller may not act on (`meta.errorCode` = `sharing.access.denied`). @@ -25642,16 +26182,17 @@ paths: required: - file responses: - "202": *a37 + "202": *a44 "401": *a1 "403": - description: "The opt-in `nutrients` module is off for this account: 403 `module.disabled` with `meta.module: - \"nutrients\"`. The `supplementLabel` AI capability is unavailable for this record. `meta.capability` = - `supplementLabel` and `meta.reason` says which layer refused: the operator's switch - (`assistant.disabled.documentAi`, or `assistant.disabled.enabled` for the master switch), an owning module - or the person's own AI opt-out (`module.disabled` with `meta.module`), a delegate inside somebody else's - record (`ai.record.notPermitted`), or a missing consent receipt (`consent.ai.required`). Nothing was - generated, read from a stored model answer, or enqueued." + description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: The opt-in `nutrients` module is + off for this account: 403 `module.disabled` with `meta.module: \"nutrients\"`. The `supplementLabel` AI + capability is unavailable for this record. `meta.capability` = `supplementLabel` and `meta.reason` says + which layer refused: the operator's switch (`assistant.disabled.documentAi`, or `assistant.disabled.enabled` + for the master switch), an owning module or the person's own AI opt-out (`module.disabled` with + `meta.module`), a delegate inside somebody else's record (`ai.record.notPermitted`), or a missing consent + receipt (`consent.ai.required`). Nothing was generated, read from a stored model answer, or enqueued." content: application/json: schema: @@ -25677,7 +26218,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 - "503": *a18 + "503": *a26 /api/anamnesis/life-circumstances: get: tags: @@ -25692,7 +26233,7 @@ paths: schema: $ref: "#/components/schemas/LifeCircumstancesEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "422": *a4 "429": *a5 /api/anamnesis/life-circumstances/ai-sections: @@ -25716,6 +26257,7 @@ paths: schema: $ref: "#/components/schemas/LifeAiSectionsEnvelope" "401": *a1 + "403": *a9 "409": description: "`about_me_conflict`." content: @@ -25745,6 +26287,7 @@ paths: schema: $ref: "#/components/schemas/LifeConfirmEnvelope" "401": *a1 + "403": *a9 "422": *a4 "429": *a5 /api/anamnesis/audit-c: @@ -25779,7 +26322,7 @@ paths: schema: $ref: "#/components/schemas/AuditCCreatedEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "409": *a7 "422": *a4 "429": *a5 @@ -25803,7 +26346,7 @@ paths: schema: $ref: "#/components/schemas/AuditCRemovedEnvelope" "401": *a1 - "403": *a11 + "403": *a15 "404": description: "`anamnesis.auditC.notFound`." content: @@ -25834,7 +26377,7 @@ paths: schema: $ref: "#/components/schemas/DawarichConnectEnvelope" "401": *a1 - "403": *a19 + "403": *a11 "422": *a4 "429": *a5 /api/dawarich/status: @@ -25850,7 +26393,7 @@ paths: schema: $ref: "#/components/schemas/DawarichStatusEnvelope" "401": *a1 - "403": *a19 + "403": *a11 "422": *a4 "429": *a5 /api/dawarich/places: @@ -25874,7 +26417,7 @@ paths: schema: $ref: "#/components/schemas/DawarichPlacesEnvelope" "401": *a1 - "403": *a19 + "403": *a11 "409": description: "`dawarich.not_connected`." content: @@ -25912,7 +26455,7 @@ paths: schema: $ref: "#/components/schemas/DawarichSettingsEnvelope" "401": *a1 - "403": *a19 + "403": *a11 "409": description: "`dawarich.not_connected`." content: @@ -25944,7 +26487,7 @@ paths: schema: $ref: "#/components/schemas/DawarichProbeEnvelope" "401": *a1 - "403": *a19 + "403": *a11 "422": *a4 "429": *a5 "502": @@ -25980,7 +26523,7 @@ paths: schema: $ref: "#/components/schemas/DawarichSyncEnvelope" "401": *a1 - "403": *a19 + "403": *a11 "409": description: "`dawarich.not_connected`." content: @@ -26015,7 +26558,7 @@ paths: schema: $ref: "#/components/schemas/DawarichDisconnectEnvelope" "401": *a1 - "403": *a19 + "403": *a11 "422": *a4 "429": *a5 /api/environment/travel/suggestions: @@ -26033,7 +26576,7 @@ paths: schema: $ref: "#/components/schemas/TravelSuggestionsEnvelope" "401": *a1 - "403": *a19 + "403": *a11 "422": *a4 "429": *a5 /api/environment/travel/suggestions/{id}/dismiss: @@ -26056,7 +26599,7 @@ paths: schema: $ref: "#/components/schemas/TravelSuggestionDismissEnvelope" "401": *a1 - "403": *a19 + "403": *a11 "404": description: "`environment.travel_suggestion.not_found`." content: @@ -26093,6 +26636,7 @@ paths: schema: $ref: "#/components/schemas/MedicationLinkResponse" "401": *a1 + "403": *a9 "404": description: The medication was not found. content: @@ -26128,6 +26672,7 @@ paths: schema: $ref: "#/components/schemas/MedicationUnlinkResponse" "401": *a1 + "403": *a9 "404": description: The medication was not found. content: @@ -26164,6 +26709,7 @@ paths: schema: $ref: "#/components/schemas/LabResultLinkResponse" "401": *a1 + "403": *a9 "404": description: The lab result was not found. content: @@ -26199,6 +26745,7 @@ paths: schema: $ref: "#/components/schemas/LabResultUnlinkResponse" "401": *a1 + "403": *a9 "404": description: The lab result was not found. content: @@ -26996,6 +27543,7 @@ components: - pl - ko - nl + - pt-BR practiceName: type: string maxLength: 120 @@ -27989,11 +28537,12 @@ components: /api/integrations/healthkit`, never `lastReceivedAt`, `lastTrigger`, `lastBackgroundAt`, `lastSyncTrigger`, `lastBackgroundSyncAt` or the sync checkpoint. Recorded on the ingest wide event and kept on the account as `lastSyncTrigger` — plus `lastBackgroundSyncAt` for a `background` or `push` trigger — both readable from - `GET /api/integrations/healthkit`. Since v1.42 it is also recorded per measurement type the batch carried - (Apple Health rows only), served as `lastReceivedAt` / `lastTrigger` / `lastNewSampleAt` on each - `metricFreshness` entry of that read, plus `lastBackgroundAt` (since v1.42.4) for a `background` trigger. It - does not affect dedup, attribution, or how any sample is stored. Optional and backward-compatible: pre-#66 - clients omit it.' + `GET /api/integrations/healthkit`. Since v1.42 it is also recorded per measurement type the batch brought + something new or changed for (Apple Health rows only), served as `lastReceivedAt` / `lastTrigger` / + `lastNewSampleAt` on each `metricFreshness` entry of that read, plus `lastBackgroundAt` (since v1.42.4) for + a `background` trigger. Since v1.45 a type the batch carried with duplicates only (an unchanged `stats:` + re-post included) moves only its `lastSeenDuplicateAt`. It does not affect dedup, attribution, or how any + sample is stored. Optional and backward-compatible: pre-#66 clients omit it.' type: string enum: - foreground @@ -28633,10 +29182,11 @@ components: description: "Diagnostic-only, the same field `POST /api/measurements/batch` takes (since v1.42.4): what woke the client for this sync. `backfill` (v1.43, only when `features.syncTriggerBackfill` is `true`) marks a history catch-up, which moves only the `MEDICATION_DOSE` entry's `lastBackfillAt` and leaves its live stamps and the - sync checkpoint alone. When the batch carries entries with `source: \"APPLE_HEALTH\"` that land (`inserted`, - `updated` or `duplicate`), it is recorded on the `MEDICATION_DOSE` entry of `metricFreshness` in `GET - /api/integrations/healthkit` as `lastTrigger`, and a `background` value also dates its `lastBackgroundAt`. - It changes no dose row. Optional; omitted records a null trigger." + sync checkpoint alone. When the batch brings at least one new or changed entry with `source: + \"APPLE_HEALTH\"` (`inserted` or `updated`), it is recorded on the `MEDICATION_DOSE` entry of + `metricFreshness` in `GET /api/integrations/healthkit` as `lastTrigger`, and a `background` value also dates + its `lastBackgroundAt`. Since v1.45 a batch whose Apple Health entries are all `duplicate` moves only that + entry's `lastSeenDuplicateAt`. It changes no dose row. Optional; omitted records a null trigger." type: string enum: - foreground @@ -29492,14 +30042,15 @@ components: description: Optional UI locale hint for the model. type: string enum: - - en - de - - es + - en - fr + - es - it - pl - ko - nl + - pt-BR today: description: "Optional override of the reference date used to resolve relative phrases (\"tomorrow\", \"next Monday\"). Format: `YYYY-MM-DD`. Defaults to the account's own day in its time zone." @@ -30468,6 +31019,7 @@ components: - pl - ko - nl + - pt-BR description: One of the interface locales this build ships. required: - locale @@ -33383,7 +33935,7 @@ components: maxLength: 100 components: minItems: 1 - maxItems: 26 + maxItems: 28 type: array items: type: string @@ -33414,6 +33966,8 @@ components: - yellow-fever - japanese-encephalitis - cholera + - dengue + - tuberculosis typicalSeriesDoses: anyOf: - type: integer @@ -33442,7 +33996,7 @@ components: maxLength: 100 components: minItems: 1 - maxItems: 26 + maxItems: 28 type: array items: type: string @@ -33473,6 +34027,8 @@ components: - yellow-fever - japanese-encephalitis - cholera + - dengue + - tuberculosis typicalSeriesDoses: anyOf: - type: integer @@ -35802,12 +36358,13 @@ components: enum: - de - en - - es - fr + - es - it - pl - ko - nl + - pt-BR timezone: type: string minLength: 1 @@ -35857,12 +36414,13 @@ components: enum: - de - en - - es - fr + - es - it - pl - ko - nl + - pt-BR timezone: type: string minLength: 1 @@ -36075,12 +36633,13 @@ components: enum: - de - en - - es - fr + - es - it - pl - ko - nl + - pt-BR - type: "null" timezone: type: string @@ -40303,6 +40862,11 @@ components: category: type: string maxLength: 64 + categoryUpdatedAt: + anyOf: + - type: string + minLength: 1 + - type: "null" deliveryForm: type: string enum: @@ -40579,6 +41143,9 @@ components: createdAt: type: string minLength: 1 + updatedAt: + type: string + minLength: 1 required: - nutrient - amountPerUnit @@ -40933,9 +41500,17 @@ components: - type: string maxLength: 280 - type: "null" + noteEncrypted: + anyOf: + - type: string + minLength: 1 + - type: "null" createdAt: type: string minLength: 1 + updatedAt: + type: string + minLength: 1 required: - startsOn - endsOn @@ -40963,9 +41538,14 @@ components: type: string pattern: ^custom:[0-9a-f-]{36}$ label: + anyOf: + - type: string + minLength: 1 + maxLength: 40 + - type: "null" + labelEncrypted: type: string minLength: 1 - maxLength: 40 sortOrder: default: 0 type: integer @@ -40977,9 +41557,11 @@ components: createdAt: type: string minLength: 1 + updatedAt: + type: string + minLength: 1 required: - key - - label - sortOrder - isActive additionalProperties: {} @@ -41190,6 +41772,21 @@ components: items: type: string minLength: 1 + linkCreatedAt: + type: array + items: + type: object + properties: + key: + type: string + minLength: 1 + createdAt: + type: string + minLength: 1 + required: + - key + - createdAt + additionalProperties: false context: anyOf: - type: object @@ -41292,6 +41889,12 @@ components: - type: string minLength: 1 - type: "null" + createdAt: + type: string + minLength: 1 + updatedAt: + type: string + minLength: 1 additionalProperties: {} - type: "null" required: @@ -41655,6 +42258,21 @@ components: required: - key additionalProperties: {} + symptomLinkCreatedAt: + type: array + items: + type: object + properties: + key: + type: string + minLength: 1 + createdAt: + type: string + minLength: 1 + required: + - key + - createdAt + additionalProperties: false required: - date - symptomKeys @@ -41956,6 +42574,11 @@ components: type: - string - "null" + contextEncrypted: + anyOf: + - type: string + minLength: 1 + - type: "null" aliases: type: array items: @@ -42146,6 +42769,9 @@ components: minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" + createdAt: + type: string + minLength: 1 required: - key additionalProperties: {} @@ -42449,12 +43075,22 @@ components: type: - string - "null" + notesEncrypted: + anyOf: + - type: string + minLength: 1 + - type: "null" createdAt: type: string minLength: 1 updatedAt: type: string minLength: 1 + deletedAt: + anyOf: + - type: string + minLength: 1 + - type: "null" required: - id - relationship @@ -42812,6 +43448,9 @@ components: createdAt: type: string minLength: 1 + updatedAt: + type: string + minLength: 1 required: - day - nutrient @@ -58626,6 +59265,7 @@ components: - pl - ko - nl + - pt-BR required: - locale additionalProperties: false @@ -80462,11 +81102,14 @@ components: description: This type has gone quiet while the source around it reads healthy — the dead-pipe signature (e.g. a single revoked HealthKit permission). Only ever true when the verdict is `fresh`. lastReceivedAt: - description: "When a live sync batch last carried this type (inserted, updated or duplicate entries all count). Null - when no live sync has carried it yet: a type that so far came only from the export import, or from before - v1.42. `lastSeenAt` is when the newest sample was TAKEN; this is when the server last RECEIVED the type. - Omitted on the `WORKOUTS` entry, which the ledger does not cover, and on every entry when the ledger read - failed." + description: "When a live sync batch last brought a new or changed row of this type. Since v1.45 a batch that carried + the type with duplicates only (an unchanged `stats:` re-post or an hour posted again with its stored value + included) does not move it, nor `lastTrigger` or `lastBackgroundAt`; it moves `lastSeenDuplicateAt`. Before + v1.45 duplicates counted too, so a stored value can still date from a duplicates-only batch. Null when no + live sync has brought anything for it yet: a type that so far came only from the export import, or from + before v1.42. `lastSeenAt` is when the newest sample was TAKEN; this is when the server last RECEIVED the + type. Omitted on the `WORKOUTS` entry, which the ledger does not cover, and on every entry when the ledger + read failed." anyOf: - type: string format: date-time @@ -80486,6 +81129,7 @@ components: - type: "null" lastNewSampleAt: description: When a live sync batch last brought a new or updated sample of this type, as opposed to duplicates only. + Since v1.45 this moves together with `lastReceivedAt`; the two differ only on a row last written before. Null when none has. Same omission rules as `lastReceivedAt`. anyOf: - type: string @@ -80504,6 +81148,16 @@ components: format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" + lastSeenDuplicateAt: + description: v1.45 — when a live sync batch last carried this type with duplicates only, nothing new or changed. Later + than `lastReceivedAt` means the phone keeps sending the type but nothing new has arrived since + `lastReceivedAt`. Null when no duplicates-only batch was recorded for an existing ledger row since v1.45. + Same omission rules as `lastReceivedAt`; absent on older servers. + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" lastBackfillAt: description: 'v1.43 — when a batch with `syncTrigger: "backfill"` last carried this type. A backfill moves only this and `syncProgress`, never `lastReceivedAt`, `lastTrigger`, `lastBackgroundAt` or the account-level sync stamps, @@ -86931,12 +87585,13 @@ components: enum: - de - en - - es - fr + - es - it - pl - ko - nl + - pt-BR - type: "null" timezone: type: string @@ -88227,6 +88882,61 @@ components: - data - error additionalProperties: false + AdminBackupUploadEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/AdminBackupUploadResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + AdminBackupUploadResponse: + type: object + properties: + id: + type: string + description: "`DataBackup.id` of the stored copy; pass it to `POST /api/admin/backups/{id}/restore`." + valid: + type: boolean + const: true + summary: + $ref: "#/components/schemas/AdminBackupUploadSummary" + required: + - id + - valid + - summary + additionalProperties: false + description: The file was read, checked and stored as a new copy. Nothing was restored. + AdminBackupUploadSummary: + type: object + properties: + schemaVersion: + type: string + userId: + type: string + exportedAt: + type: string + required: + - schemaVersion + - userId + - exportedAt + additionalProperties: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: "What the stored file holds: its `schemaVersion`, the account it was exported from (`userId`) and when + (`exportedAt`), and one count per serialised class (`measurements`, `medications`, `intakeEvents`, + `moodEntries`, …). The set of counts grows with the backup format; read the ones you know and show the rest + generically." AdminBackupDocument: type: object propertyNames: diff --git a/docs/contributing/benchmarks.md b/docs/contributing/benchmarks.md new file mode 100644 index 000000000..aa10a1d73 --- /dev/null +++ b/docs/contributing/benchmarks.md @@ -0,0 +1,119 @@ +# Benchmarks + +Two tools measure the server's read and write paths. Use them before and after any change to a hot path, and put both numbers in the PR. + +- **`scripts/bench/run.ts`** measures latency (p50, p95, max) and SQL statements per request against a seeded account of about 1.26 million measurement rows, through the production build. It is a local tool, not a CI gate: latency depends on the machine. +- **`tests/integration/query-budget-hot-routes.test.ts`** holds a statement budget per hot route on a small fixture. Statement counts are exact on a fixed fixture, so this one is a gate: a change that adds a query per row, or a second read of something already loaded, fails it. + +## The large-account benchmark + +### 1. A throwaway Postgres with pg_stat_statements + +Never point the seed or the benchmark at a database that holds real data. They write under a synthetic user, and the seed's `--reset` deletes that user with everything it owns. + +```sh +docker run -d --name hl-bench-db --shm-size=256m \ + -e POSTGRES_USER=bench -e POSTGRES_PASSWORD=bench -e POSTGRES_DB=healthlog_bench \ + -p 55432:5432 postgres:16.15-alpine \ + postgres -c shared_preload_libraries=pg_stat_statements -c pg_stat_statements.track=top +``` + +The image and the two settings are the ones `docker-compose.yml` runs in production. + +### 2. Environment, migrations, seed + +```sh +export DATABASE_URL="postgresql://bench:bench@127.0.0.1:55432/healthlog_bench?schema=public" +export ENCRYPTION_KEYS='{"v1":"<64 hex>"}' ENCRYPTION_ACTIVE_KEY_ID=v1 +export API_TOKEN_HMAC_KEY="<64 hex>" + +pnpm db:migrate:deploy +pnpm dlx tsx scripts/bench/seed-large-account.ts --reset --anchor 2026-10-22 +``` + +Any throwaway key values work, as long as the seed, the benchmark and the server see the same ones. + +The seed writes ten years for one user: Apple Health dense rows (heart rate about every ten minutes, HRV, SpO2, respiratory rate, gait, audio), Withings blood pressure, pulse and scale, a weekly manual weight, three years of Oura resting heart rate and RMSSD, 90 days of Nightscout CGM, `stats:` and per-sample cumulative rows, nightly sleep stages, about 3,000 workouts, three years of mood and of three medications with their intake events. It then folds the rollup tier the way the boot backfill does and runs `ANALYZE`. It takes about 80 seconds on the Mac mini. + +The seed is deterministic for a given `--anchor`: values and timestamps are functions of the row index and that day. Without `--anchor` it anchors on today, which is what you want when the benchmark should see recent data. + +### 3. A production build + +```sh +pnpm build +``` + +### 4. Run + +```sh +pnpm dlx tsx scripts/bench/run.ts --out scripts/bench/results/