feat(mental-health): make the screener score rows server-owned COMPUTED measurements

Each completed PHQ-9 / GAD-7 administration already projects its total
onto a PHQ9_SCORE / GAD7_SCORE measurement row (unit score, linked via
externalId assessment:<id>). Those rows were attributed MANUAL — a
client-writable source. The projection is a server-derived value, so it
now writes source COMPUTED, the same server-owned source RECOVERY_SCORE
uses: no client write surface can attribute COMPUTED, so a forged score
trend point cannot enter through the measurement POST. Migration 0225
re-attributes the existing assessment-linked projection rows; a score a
user typed into the generic measurement form keeps MANUAL. OpenAPI
description updated and the spec regenerated.
This commit is contained in:
Marc-André Bombeck committed 2026-07-05 14:54:17 +02:00
1 parent f7920d81a6
commit b6aedfeb61
7 files changed
+64 -10

No files matched your search

@@ -0,0 +1,21 @@
-- v1.27.6 — mental-wellbeing score rows become server-owned.
--
-- The `PHQ9_SCORE` / `GAD7_SCORE` enum values exist since 0213; the screener
-- route has projected each completed administration onto a Measurement row
-- from the start. Those rows were attributed `MANUAL`, which is a
-- client-writable source — the projection is a server-derived value (the sum
-- over the encrypted item answers), so it now writes `COMPUTED`, the same
-- server-owned source RECOVERY_SCORE uses. Clients cannot attribute COMPUTED
-- on any write surface, so a forged score trend point cannot enter through
-- the measurement POST.
--
-- Re-attribute the existing projection rows so the trend is uniformly
-- server-owned. Scoped to the `assessment:` externalId prefix the route
-- stamps: a hand-entered score row a user may have logged deliberately
-- through the generic measurement form keeps its MANUAL attribution.
-- Idempotent, forward-only.
UPDATE "measurements"
SET "source" = 'COMPUTED'
WHERE "type" IN ('PHQ9_SCORE', 'GAD7_SCORE')
AND "source" = 'MANUAL'
AND "external_id" LIKE 'assessment:%';
+4 -2
View File
@@ -1306,8 +1306,10 @@ enum AssessmentInstrument {
/// nor logged. The derived total / band / item-9 flag are denormalised
/// (server-authoritative) for cheap history reads without decrypting. A
/// matching `PHQ9_SCORE` / `GAD7_SCORE` Measurement row is written per
/// administration (linked by `externalId = assessment:<id>`) so the score rides
/// the existing chart / rollup infra — the trend, never the item content.
/// administration (linked by `externalId = assessment:<id>`, `source =
/// COMPUTED` — server-owned like RECOVERY_SCORE, never client-attributable) so
/// the score rides the existing chart / rollup infra — the trend, never the
/// item content.
model MentalHealthAssessment {
id String @id @default(cuid())
userId String @map("user_id")