Grip strength, pain (0–10 NRS), waist circumference, and waist-to-height
existed in the measurement enum, signal registry, and FHIR export but had
no web entry, no detail page, single non-sex-aware bands, and a split-brain
MCP exposure. This closes those gaps.
Web capture: the four signals join the manual measurement form, so a
web-only self-hoster can log them (the iOS client and the measurements API
already could).
Detail pages: flip detailPage on for the four signals and mount each on the
shared HealthKit metric template (chart, stat strip, Coach-read strip,
assessment card) via routed sub-pages and tab-strip pills, the same detail
spine every other metric carries.
Sex-aware bands: grip strength (EWGSOP2 men 27 / women 16 kg) and waist
(WHO men 94 / women 80 cm) now classify by the profile sex through the
age/sex norm resolver the assessment already threads; the resolver also
returns a sex-specific, age-independent band when no date of birth is set.
Fix the waist-to-height boundary so the NICE >= 0.5 rule is correct (the
last in-range value is 0.49).
MCP single source of truth: decouple the registry mcp facet from the Coach
snapshot — a signal can be MCP-readable without being on the Coach surface.
Mark the four physical signals mcp:true, derive the MCP rich-reads clinical
set from the registry flag instead of a duplicate allowlist, and relax the
invariant to coachSnapshot implies mcp (not the reverse). PHQ-9 / GAD-7 and
the environmental signals stay mcp:false and unreachable. compare_metric and
detect_changepoints now advertise clinical-signal support to match
get_metric_baseline.
Pin the highest-consequence numbers in the release: every PHQ-9/GAD-7 severity
band edge, the >=10 action threshold, and the item-9 ANY-non-zero safety flag
(true on item9 1/2/3, false on 0 or for GAD-7). Add a POST happy-path test
that would have caught the dead-on-arrival bug — a valid body now parses,
persists server-authoritative scoring, writes the derived score measurement,
and surfaces the crisis set on a positive item 9.
The PHQ-9/GAD-7 totals flowed into the doctor-report / FHIR export
unconditionally while the AI/MCP surfaces were correctly fenced off. Map the
two `*_SCORE` measurement types to the mental-health module so the totals
export only once the account opts the module in (default OFF — privacy by
default), consistent with how mood/sleep/glucose gate. Item-level answers are
never a measurement, so they can never leak through this path.
The mental-wellbeing screener surface shipped built but unreachable: no nav
entry, no module key, ungated. Add a `mentalHealth` module key (opt-in,
default OFF — a depression/anxiety self-assessment is at least as sensitive
as mood) to the registry, the module-hub icon map, and the exhaustive module
maps. Give the check-in a nav home at /insights/mental-wellbeing, born-gated
on the module so it appears only once the account opts in, and redirect the
page home on a direct hit when the module is off.
Item answers stay encrypted and off the AI Coach / MCP regardless of the
toggle; this only controls whether the surface is visible and reachable.
The PHQ-9/GAD-7 item wordings rendered in English for es/fr/it/pl users
(de was already validated). Replace them with the officially validated,
public-domain Pfizer/PRIME-MD localized screener wordings (distributed via
the MAPI Research Trust) rather than ad-hoc translations — re-wording a
validated instrument invalidates the score. Localize the crisis-resource
copy too: translate the descriptive parentheticals while keeping the org
proper-nouns (TelefonSeelsorge, Nummer gegen Kummer, krisenchat,
Find a Helpline) and the US brand lines (988, Crisis Text Line) verbatim.
Add the new mental-wellbeing module label/description and the nav label
across all six locales.
The inbound-document store encrypts the raw upload
(`inbound_documents.content_encrypted`) because it is sensitive clinical
data, but the per-fact staging columns persisted the same class of data in
the clear: `extracted_facts.data_json` held the transcribed clinical values
(diagnosis text, lab values, medication names, stated codes) and
`provenance_json` held the verbatim source span (up to 2000 chars copied
straight out of the report). The at-rest guarantee was defeated for staged
facts, which persist through PENDING and are retained after the decision.
Convert both columns to the AES-256-GCM `*_encrypted` BYTEA convention shared
with the note columns, going through the same Bytes codec. The upload and
fact-edit paths encrypt on write; the confirm path decrypts at commit time to
write into the normal structured stores, so the review-then-confirm flow is
unchanged. Reads are fail-closed (a bad key id throws).
Register both columns in the encrypted-columns registry and the key-rotation
script so a dropped legacy key cannot strand them (the guard test enforces
both). Migration 0220 swaps the plaintext JSONB columns for the encrypted
BYTEA columns; pre-confirm staged rows are ephemeral and cannot be
re-encrypted in SQL, so any left by a pre-release build are dropped rather
than retained as plaintext (their parent document keeps its encrypted
original).
Introduce one canonical "what this is / is not, not a medical device"
statement plus a layered, calm disclaimer set, and apply it on the
surfaces that earn a context note.
- New shared `MedicalDisclaimer` component renders the copy as plain
React text (no markdown renderer, per the XSS posture). Variants:
intendedPurpose, coach, insights, labs, medications, emergency,
dataPosture.
- New `selfDescription` i18n namespace across all six locales: the
intended-purpose line, the per-surface notes, the emergency note, and
an explicit data-posture statement (self-hosted, data stays on your
instance, encrypted at rest, BYOK / local model, no third-party
analytics).
- Apply once per surface, layered not plastered: a calm standing line on
the Coach new-chat hero, one framing line in the Insights layout shell
(covers the briefing and trends), a labs note (ranges vary, only a
clinician interprets), a neutral medications note, and the data-posture
line where the provider key is configured. Mental-wellbeing keeps its
existing crisis copy; records keep their existing note.
- Extend the Coach system prompt (EN + DE) with a self-description ground
rule so the Coach answers "what are you / where does my data go"
honestly and stays non-diagnostic.
App-authored copy was already observational; the few diagnose/treat
tokens are negations, idioms, or framed as the user's own self-reported
statement, so no softening was required.
Bring the new physical clinical signals onto the MCP read surface through
the existing rollup-backed reads. Grip strength, pain (0-10 NRS), waist
circumference, and waist-to-height ratio now resolve in get_metric_baseline,
compare_metric, and detect_changepoints, are discoverable through search
(present-only), and hydrate through fetch (metric:<KEY>).
These signals sit off the Coach snapshot by design, so the MCP layer owns
an explicit exposure allowlist and consumes the signal registry only for
grounding (backing measurement type, unit, population band). The PHQ-9 /
GAD-7 mental-health screeners and the environmental signals are absent from
the allowlist, so they never resolve through any read tool. The longevity
lab panel is already reachable through get_labs and the lab resource
template; no change needed there.
Rework the per-day location resolution so it can no longer fabricate weather for
days before the home was set. Precedence, first hit wins:
1. an explicit dated location period (a trip, or a corrected stretch of
history) covering the day;
2. DEVICE — a reserved slot for a future client-supplied coarse per-day
location (ranks between the period and home; not produced server-side yet);
3. the home location, but only for days on/after its effective-from date;
4. otherwise the day is skipped.
The home route stamps `homeSince` whenever the home is set/updated and returns
it. The overview exposes it. Backfill bounds are now optional and default to the
conservative `[homeSince .. today]` span instead of a fixed reach onto the
current home; days before `homeSince` still resolve to a skip in the worker, so
even a wider explicit start cannot back-fill weather for the pre-home past. To
reach further back a user adds an explicit location period for that range.
Add unit coverage for the precedence (including the effective-from boundary and
the skip-not-guess rule) and the default backfill range.
Add the opt-in Environment module (default off), its Settings surface (home
location search, travel overrides, backfill, with Open-Meteo attribution), the
gated API routes (overview, home, geocode, travel CRUD, backfill), the nightly
pg-boss fetch job with a lookback window plus on-demand backfill, the query
keys, six-locale strings, and the regenerated OpenAPI. The module performs an
outbound weather fetch tied to a coarse location, so it ships dark and the user
turns it on deliberately; off means no job, no row, no egress.
Register the seven environmental-exposure signals through the signal registry
as a new "environment" kind (correlation-eligible; off Coach/MCP/FHIR in v1),
and fold their daily series into the correlation engine as lagged behaviour
channels. Weather/daylight now pair against mood, sleep, and vitals through
the existing FDR / effect-size / shrinkage gates, so a thin series degrades to
absent rather than a spurious link. All env channels share one ENVIRONMENT
family so two same-day fields never lag-correlate against each other.
Add the keyless Open-Meteo client (historical archive + geocoding) routed
through safeFetch, the per-day location-resolution + fetch/upsert service
(travel override wins over home, then degrades silently), and the shared
environmental-exposure field table the registry and correlation engine both
read. The base URL is operator-overridable for a self-hosted instance; the
fetch stays server-side so a private/LAN host is not blocked.
Document the /api/documents/inbound/* surface in the OpenAPI registry and
regenerate the spec. Add the inbound-documents copy across all six locales,
including the non-diagnostic disclaimer and the module label/description.
Add the opt-in inboundDocuments module (off by default, like the MCP
endpoint): ingesting a clinical document egresses it to the OCR/vision
provider, so the surface ships dark and the user turns it on from the
Modules hub. The born-gated /documents page carries the prominent
non-diagnostic disclaimer (we read, we don't interpret; nothing is saved
until you approve it; only your clinician can interpret these results),
uploads a document, and walks each extracted fact: source provenance and
confidence are shown, low-confidence facts are blocked from approval until
edited, and the confirm step commits the approved facts.
Ingest an uploaded doctor report / discharge letter through the existing
dedicated OCR/vision provider rails (the v1.22 document-scan provider/key,
reused — no new provider) and transcribe structured facts into staging.
The safety line is extract, never interpret: the prompt frames the document
as untrusted data to reproduce verbatim. Diagnoses map to Condition,
measured values to Observation, medications to MedicationStatement — with
STATED status only. A code survives only when the document writes it and its
system matches the resource (SNOMED/ICD-10 for a diagnosis, LOINC for a
value, RxNorm/ATC for a medication); nothing is inferred, no value is
range-flagged, no condition is linked to a medication. Every fact carries
per-field provenance and a confidence score; a fact below the confidence
floor fails closed — it cannot be approved until the user edits it.
Routes (apiHandler + Zod + session-narrowed userId, opt-in module-gated):
- POST/GET /api/documents/inbound — upload+extract (vision or local-OCR
text), and list. The raw document is stored encrypted at rest.
- GET/DELETE /api/documents/inbound/[id] — review detail; soft-delete.
- PATCH .../facts/[factId] — correct a staged fact before approval.
- POST .../confirm — the only write path: approved facts flow into the
labs / conditions / medications stores through their normal create paths;
rejected facts are discarded; low-confidence facts are refused.
Store an uploaded doctor report / discharge letter and stage extracted
structured facts for review. Two models:
- InboundDocument: the uploaded document, raw bytes encrypted at rest via
the shared AES-256-GCM Bytes codec (content_encrypted), with queryable
metadata (kind, mime, size, status, report date).
- ExtractedFact: the review-then-confirm staging row — fact type, status,
per-field provenance, a confidence score, and a needs-review gate, plus
the FHIR-staged payload (stated codes/status only) as JSONB.
Wire content_encrypted into the encryption-key rotation registry and the
rotation script so a key drop never strands a stored document.
Rework the proactive Coach nudge so it reads like one human checking in on
another instead of a terse English alert.
Copy: the title is a name-led greeting that rotates day to day ("Guten
Morgen, …" / "Hey …"; the 05:15 frame makes a morning opener safe) and the
body is one calm observation plus a gentle invite — never an imperative.
Drop the verbatim quoting of the user's decrypted self-context sentence:
the focus is referenced only by presence, abstractly, and is no longer
decrypted at all. Remove the 80-char mid-word clamp; each channel composes
to a length budget instead.
Cadence: suppress the nudge when the user already talked to the Coach in
the last ~24 h. With the existing rolling frequency cap (never two in a
window) and the no-trigger-no-nudge rule, the surface stays anti-nag.
AI enrichment (opt-in via the coach prefs blob, default off): when a
provider is healthy the body is composed through the model under hard
guards — a per-user budget gate, a tight per-call timeout, a per-tick
ceiling so the sequential tick can't stall across the user base, and the
outbound safety screen on the output. Any error, timeout, budget refusal
or unsafe reply falls silently back to the deterministic template, which is
always the default and the fail-closed fallback.
Localized greetings, per-trigger bodies and the focus note across all six
locales; the prefs opt-in is surfaced in the notification settings.
The language switcher wrote the choice to localStorage, the
`healthlog-locale` cookie and `<html lang>` only — never to the user row.
Server-side background work (the proactive Coach nudge, the Telegram test
message) has no request cookie to read and falls back to `User.locale`;
with the column left null those messages went out in English regardless of
the language the user reads the app in.
Add `PUT /api/auth/me/locale` (idempotent, validated against the supported
locales) and have the i18n provider mirror the active locale onto the row —
on every switch and once on mount, so the column is backfilled for users
who set their language before this landed. Fire-and-forget: a 401 on a
public page or an offline blip never blocks the UI flip; the cookie stays
the first-paint source of truth.
DELETE /api/measurements/by-external-ids now only tombstones rows the iOS
client minted through its HealthKit ingestion path (source APPLE_HEALTH).
An externalId the app never minted — an integration- or manually-sourced
row that happens to collide on externalUUID — falls outside the predicate
and is a no-op rather than a delete. Defence-in-depth so a foreign
HealthKit sample with a colliding UUID cannot be tombstoned by the
periodic reconciliation. The delete stays user-scoped as before.
Add a route test proving a foreign-origin externalId is not deleted, and
regenerate the OpenAPI spec for the narrowed contract.
The structured lab-create path (POST /api/labs) now accepts an optional
`source` marker ("MANUAL" | "OCR"). The on-device-OCR ingestion path posts
"OCR" so a reading captured from a phone-camera lab photo records its
provenance; the raw image never touches the server on this path. Omitting
the field reads as "MANUAL", so the existing hand-entry contract is
unchanged. The value is narrowed by a closed Zod enum and assigned
field-by-field, never spread from the body. Mirrors the "OCR" source the
server-side /api/labs/ocr/commit path already stamps.
Regenerate the OpenAPI spec for the widened request body.
The PHQ-9 / GAD-7 screener totals and the physical clinical measures
(grip strength, pain NRS, waist circumference, waist-to-height) are
continuous numeric series. Register their metric:<TYPE> tokens so the
chart-token drift guard passes and the AI insight prose can reference
them through the generic chart renderer.
Add the opt-in PHQ-9 / GAD-7 questionnaire surface at /insights/mental-wellbeing:
instrument picker, 0–3 item scale, server-scored result with the validated
severity band, the verbatim Pfizer/PHQ attribution, the screening-not-diagnosis
disclaimer, and a calm crisis-resource card on a positive item-9. The surface
deliberately does NOT mount the shared Coach-launch card — mental-health item
content stays out of the AI.
- i18n: full mentalHealth block plus the six new measurement-type labels and the
longevity-marker catalogue names/descriptions across all six locales
(de/en/es/fr/it/pl). Instrument item wording uses the validated German text and
the canonical English elsewhere (re-wording invalidates the score).
- OpenAPI: regenerate the contract for /api/mental-health/assessments (response
excludes raw item answers by construction).
- Exclude the new manual / instrument types from the HealthKit-counterpart guard.
Generalise sleep debt onto the existing per-metric source-priority picker
(a new sleepDebt ladder, native-above-computed, default WHOOP > Oura > Polar >
computed) instead of assuming one provider. The rhythm read resolves the active
source off the ladder and carries it on the DTO; when the active source is our
own COMPUTED engine — every user today, since no provider ships a native
sleep-debt balance — the Sleep page explains what the figure means and that it
differs from a wearable's native number. The computed model itself is unchanged
and correct (a short rolling balance of need minus sleep, surplus-credited);
only stale comments that still referenced the old 14-night window are corrected.
Add a per-user toggle that turns off the proactive ambient example suggestions
(the daily seeded opener on the Coach hero). The flag rides the existing coach
notification-prefs blob (coach.ambientSuggestions, default on) — no schema
change — and a shared hook makes it the single source every ambient suggestion
surface consults, so the seeded opener and its fetch both stand down when off.
Emit `AllergyIntolerance` and `FamilyMemberHistory` resources in the
health-record document bundle. Vendor-blind and non-diagnostic, matching the
illness `Condition` stance: the user's own label rides `code.text` (never a
machine-guessed clinical code); only the FHIR-fixed value sets — allergy
category, clinical/verification status, the v3 RoleCode family-member concept —
carry codings. Every resource is marked patient-reported (allergy
`verificationStatus: unconfirmed` plus an explicit "not a clinical diagnosis"
note) so a clinician's viewer never reads it as an adjudicated finding.
The records are loaded owner-scoped in the export route and folded into the
fhir + package bundle formats with their own Composition sections.
Add the `/api/allergies` and `/api/family-history` endpoints (list / create /
read / update / soft-delete), each wrapped in `apiHandler` with Zod
`safeParse` + `returnAllZodIssues`. `userId` is always narrowed from the
session/Bearer — never a body field — and the Prisma `data` is built
field-by-field (no mass assignment). Creates wrap in `withIdempotency`. The
free-text reaction/note are encrypted before write and decrypted fail-soft on
read via the server-authoritative DTOs.
Register the query-key factory entries and the OpenAPI route tables (new
`Records` tag); regenerate `docs/api/openapi.yaml`.
Introduce two structured health-record tables, kept as reference records
(not time-series signals):
- `allergies` — AllergyIntolerance-style: substance + category + type +
severity + status + optional onset. The substance label stays queryable
plaintext; the free-text reaction and note are AES-256-GCM encrypted
(`reaction_encrypted` / `notes_encrypted` BYTEA).
- `family_history_entries` — one condition per relative: relationship +
condition label + optional age-at-onset; the free-text note is encrypted.
Both are owner-scoped and soft-deleted. Migrations 0215/0216 are additive
and idempotent. The three new encrypted columns are registered in
ENCRYPTED_COLUMNS and wired into the key-rotation script, so a key drop can
never silently strand them undecryptable.
Add the validated mental-health self-assessment surface, beside mood tracking
(never replacing it) and off by default:
- MentalHealthAssessment stores per-item answers in a single AES-256-GCM Bytes
blob (registered for key rotation); only the server-authoritative total,
severity band, and item-9 flag are denormalised. The total projects to a
PHQ9_SCORE / GAD7_SCORE measurement row so the trend rides existing infra
without ever exposing item content.
- Item-9 safety: any non-zero PHQ-9 self-harm answer surfaces a calm,
locale-aware crisis-resource set (DE: TelefonSeelsorge 0800 111 0 111 / 112;
international fallback) and stamps crisisShownAt. No clinical interpretation,
no AI ingestion of item content, no third-party alerting.
- API + OpenAPI: POST/GET /api/mental-health/assessments; the response excludes
raw item answers by construction. Required Pfizer/PHQ attribution lives in the
instrument module, rendered verbatim.
The screener scores are kept off the Coach snapshot and MCP surfaces, and out of
exports by default — item content never reaches the AI pipeline.
Add the clinical-signals wave's new measurement types and wire them through
the signal registry so FHIR, correlation eligibility, ingestion, and the
measurement-list surfaces derive from one definition each:
- MeasurementType: PHQ9_SCORE, GAD7_SCORE, GRIP_STRENGTH, PAIN_NRS,
WAIST_CIRCUMFERENCE, WAIST_TO_HEIGHT (+ AssessmentInstrument enum and the
MentalHealthAssessment model that backs the screener scores).
- Registry: the six measurement/score signals plus the longevity lab panel as
kind:"biomarker" entries; the FHIR golden fixture is regenerated in lockstep.
Grip-strength and waist-to-height carry a null LOINC (NEEDS-VERIFY) and emit a
local text concept until a stable code is confirmed.
- FHIR: add the survey observation category for the screener / pain signals.
- Labs: extend the biomarker catalogue with ApoB, Lp(a), fasting insulin, and
the omega-3 index; add LOINC codes for those plus GGT and hs-CRP; add a
computed HOMA-IR helper.
- Exhaustive maps updated to keep the enum coverage, category, list-meta, and
personal-record drift guards green.
The requirePublicHost egress paths now dispatch through undici's own
fetch, so the existing global fetch stubs in the AI, GeoIP, moodlog,
send, and ntfy suites no longer intercept them. Delegate undici's fetch
back to the installed global stub so each suite keeps asserting its
request shape and canned responses without exercising the network.
CIMD client resolution swallowed every fetch failure as ssrf_blocked and
/authorize logged a bare invalid event, so an unreachable metadata host
was indistinguishable from a genuine SSRF block and the consent flow
returned invalid_client with no diagnosable cause.
Split the resolution failure into ssrf_blocked (a real private-host
refusal) and fetch_failed (timeout / connect error), carrying the
concrete SafeFetchError kind and cause as a short non-secret detail.
Thread the resolution reason and detail into the authorize.invalid
annotation behind a pinned meta shape, and cache a resolved CIMD
document for up to 24h so the consent flow does not re-fetch on every
hit.
The connect-time SSRF pin returned only the first vetted address and
attached an undici@8 Agent to Node's built-in fetch. Two failures:
- A host whose first DNS record is an unreachable IPv6 (no IPv6 route on
the container / LAN / Tailscale) failed the connect instantly, because
the single-address pin discarded the working IPv4 record behind it and
Happy Eyeballs never ran.
- Node's global fetch is backed by its own pinned undici copy; handing it
an Agent built from the project's undici throws UND_ERR_INVALID_ARG on
a dispatch-handler interface mismatch.
Hand undici the full vetted survivor set and enable autoSelectFamily so
it runs RFC 8305 Happy Eyeballs across the already-vetted IPs (rebinding
stays closed: every dialable IP passed isPublicIp in the same lookup).
Drive the pinned path through undici's own fetch so the dispatcher and
the fetch engine share one undici version.
Add an end-to-end regression that drives a real localhost-bound server
through the pinned path (not a mocked fetch, which hid the coupling),
covering the IPv6-to-IPv4 fallback, streaming, timeout, and abort.
Replace the hand-maintained MEASUREMENT_LOINC literal and the BUCKETED_TYPES
correlation list with signal-registry projections. Both reads are byte-identical
to the previous tables (guarded by the registry-invariant test), so there is no
behaviour change — only one source of truth for each.
Introduce one declarative table describing each health signal across the
surfaces it touches — DB measurement type, detail page, correlation engine,
Coach snapshot, MCP read tool, and FHIR coding — discriminated on kind
(measurement / biomarker / score). Promote the proto-registry in
metric-status-registry.ts to the canonical definition and add thin adapters
that derive the per-surface tables from it.
The registry coexists with the enum-driven code: the DB enum stays the source
of truth for persistence and the registry sits above it. An invariant test
pins every derived table byte-for-byte to the values the hand-maintained
tables carry today, so cross-surface drift fails CI.