A pillar can leave the set behind the number without anybody choosing it: a window rolls past its floor, a module goes off, a source goes quiet. The delta already refuses to narrate that as a health event, which stops a false "you dropped six points" and puts nothing in its place. The level moves, the panel shows a different number, and the cause is nowhere on the surface. Under the adaptive minimum this matters more rather than less, because compositions are smaller now and sit closer to their floors. The report carries `compositionNotice` when today's set differs from the last local day the account has a stored score for. Optional and nullable, the additive shape the score's wire has used six times, so a payload cached before it existed stays valid. It names what left and what joined as pillar ids; the surfaces already own the labels, and the reason a departed pillar stopped counting comes from that pillar's own row in the same report rather than from a second vocabulary. Two changes deliberately do not raise it. A method move and a recipe change each already have their own note, and telling someone twice about one event teaches them to ignore both. The key is `health-score:v<version>:composition:<hash of the sorted set>` and rides the existing dismissed-item ledger, so a dismissal holds until the set moves again rather than until tomorrow. The read excludes today on purpose: the row for today is written by the same request that computed the report, so comparing against the newest row would find today's own composition on the second load of the same page and the note would vanish between two views of it. This gives `HealthScoreRecord` its first production reader. Since it shipped it has been written on every scored read and consumed by nothing but backup and restore, which is the write-only shape the contributor notes call a liability and which nothing in the gate can notice, because every check proves one end. The round trip is proved against real Postgres: raise the note, wipe the table, restore it through the admin route, get the same note back, and hold the dismissal across it. Rendering is one muted line on the score card and a card of its own on the settings page, beside the recipe note rather than inside it. Deliberately not a push. Four fake Prisma clients gain the new read. They are doubles of a client the reader really does call now; leaving them short and guarding the call site instead would have been a shim for a caller that does not exist.
HealthLog API Spec
openapi.yaml is the OpenAPI 3.1 description for the HealthLog
Next.js API and the source of truth for the native iOS client's DTO
codegen. The file is generated: pnpm openapi:generate emits it
from the Zod registry under src/lib/openapi/ (registry.ts plus the
route modules in src/lib/openapi/routes/). Do not edit the YAML by
hand; the next generation overwrites it, and CI (openapi:check)
fails the build when the committed YAML drifts from the registry.
The MCP server is documented separately — it is a distinct contract over JSON-RPC, not part of the REST OpenAPI spec:
mcp-capabilities.md— the tool, resource, and prompt catalogue, the grounding contract, and the write model.mcp-skills.md— building a connector or skill on top of it.../self-hosting/mcp.md— enabling and connecting the surface (operator guide).
Preview locally
Open an interactive Redoc preview in your browser:
npx @redocly/cli preview docs/api/openapi.yaml
Generate static HTML for sharing:
npx @redocly/cli build-docs docs/api/openapi.yaml --output docs/api/index.html
Validate
npx @redocly/cli lint docs/api/openapi.yaml
The spec is expected to be lint-clean (errors = 0). Warnings such as
no-server-example.com for the localhost dev server are tolerated;
re-run the lint to see what it produces against the current file
before treating a warning as a regression.
Layout
info— title, description, andversionmirrored automatically frompackage.jsonat generation time. Never bump it by hand.servers—https://healthlog.example.comas the self-hosted placeholder plushttp://localhost:3000for local development.tags— domain groupings; the current set lives at the top of the generated file (Auth,Measurements,Medications,Mood,Analytics,Insights,Consent,Dashboard,Notifications,Devices,Export,Sync,Cycle,MeasurementReminders,Labs,Illness,Documents,Records,Admin,Meta,Retired).securitySchemes:bearerAuth— long-livedhlk_*API tokens issued viaPOST /api/tokens. Required on/api/ingest/medication.sessionCookie— HttpOnlyhealthlog_sessioncookie set by the password and passkey login flows. Used by the web app and any iOS client running inside a web view.
components.schemas— DTOs for the documented routes plus the underlying Prisma models (e.g.Measurement,Medication,MedicationIntakeEvent,MoodEntry,User,DashboardLayout,ApiEnvelope,ApiError).paths— the client-facing REST surface. Not everysrc/app/api/**/route.tsis in the contract: most/api/admin/*routes, the OAuth connect/callback legs, and the webhook surfaces are not documented here. TheRetiredtag lists removed paths that answer410 Gone.
Conventions
- Successful responses use the envelope
{ data, error: null }. - Error responses use the envelope
{ data: null, error: '<message>' }. - Pagination uses
limit+offsetquery parameters and ameta: { total, limit, offset }block on the response. - All timestamps are ISO 8601 / RFC 3339 (
format: date-time) in UTC. - IDs are CUIDs (opaque strings).
Updating the spec
When adding or changing routes:
- Update the relevant route handler under
src/app/api/**/route.ts. - Change the Zod schemas and their
.meta()registrations undersrc/lib/openapi/(registry entry plus the matching module insrc/lib/openapi/routes/). KeepoperationIdcamelCase and stable; the iOS codegen keys off it. - Run
pnpm openapi:generateand commit the regeneratedopenapi.yamlalongside the schema change. CI'sopenapi:checkfails on drift. - Never edit the YAML directly and never bump
info.versionby hand; the version followspackage.jsonat generation time.