docs(planning): W19c-Backend research-mode + PK phase report

Records the three commits, scope delivered, and the contracts
W19c-Frontend needs:

  - RESEARCH_MODE_DISCLAIMER_VERSION import path,
  - computeOneCompartment / shotPhaseAt signatures,
  - drug-id enum + look-up helper,
  - GET/POST/DELETE API contract for the Settings toggle,
  - chart-wiring rule that hides the curve when persisted
    acknowledgment version drifts behind the server-side constant.

Also notes the two-compartment math deferred to v1.5 and the Ka
textbook-approximation fallback used for non-tirzepatide drugs.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 19:56:36 +02:00
1 parent 983c2ff26a
commit e2fd11ed9c
1 file changed
+252
@@ -0,0 +1,252 @@
# Phase W19c-Backend — v1.4.25 — Research-mode + GLP-1 PK helpers
Status: complete. Three atomic commits on `develop`, all gates clean.
## Commits
| # | SHA | Title |
|---|-----|-------|
| 1 | `5eeedd7` | feat(db): add user.researchMode acknowledgment fields for GLP-1 PK chart |
| 2 | `36d147e` | feat(medications): one-compartment GLP-1 PK helpers (qualitative use) |
| 3 | `cf27df4` | feat(api): research-mode acknowledgment endpoint (GET/POST/DELETE) |
## Scope delivered
### Commit 1 — Migration 0058 + schema
Added three columns to `users`:
- `research_mode_enabled` `BOOLEAN NOT NULL DEFAULT false`
- `research_mode_acknowledged_at` `TIMESTAMP(3)` nullable
- `research_mode_acknowledged_version` `TEXT` nullable
Prisma fields on the `User` model:
- `researchModeEnabled` (Boolean, default false)
- `researchModeAcknowledgedAt` (DateTime, optional)
- `researchModeAcknowledgedVersion` (String, optional)
Migration uses `ADD COLUMN IF NOT EXISTS` (mirrors 0057). Applied to the dev DB
and `_prisma_migrations` was updated to record the migration so future
`prisma migrate` runs do not re-apply.
### Commit 2 — `src/lib/medications/glp1-pk.ts` (pure)
Public exports:
- `RESEARCH_MODE_DISCLAIMER_VERSION` — `"2026-05-14.1"`. Bump on copy
change / new drug / new EMA EPAR version.
- `type DoseEvent = { takenAt: Date; doseMg: number }`
- `type PkSample = { tHours: number; concentration: number }`
- `type OneCompartmentOptions = { windowHoursBefore?: number;
windowHoursAfter?: number; stepHours?: number }`
- `type ShotPhase = "rising" | "peak" | "fading" | "none"`
- `function computeOneCompartment(drug: Glp1DrugId,
doses: readonly DoseEvent[], asOf: Date, opts?: OneCompartmentOptions):
PkSample[]`
- `function shotPhaseAt(drug: Glp1DrugId,
doses: readonly DoseEvent[], asOf: Date): ShotPhase`
Defaults: 14 d look-back, 7 d projection, 6 h sample step (≈ 85 samples per
chart). All math is one-compartment Bateman per research §2.2. Two-compartment
is **explicitly out of scope**; the file header carries the regulatory
rationale (research §2.6 + §11 + §12.4) so any future maintainer reads the
boundary before extending the file.
Reads only `glp1-knowledge.ts` for per-drug constants; no Prisma, no
`fetch`, no `Date.now()`. Where EMA does not publish a pop-PK Ka
(every drug except tirzepatide), the helper falls back to the
textbook `Ka ≈ 3 ln 2 / Tmax` approximation — coarse but adequate for
a unit-less qualitative chart.
15 unit tests:
- 5 per-drug smoke tests (catalog-driven; new drugs will fail until
the typical-dose helper is updated).
- Zero before the first dose; empty-input zero.
- Linear superposition (two doses sum identically to the
single-dose contributions).
- Custom window + step honoured.
- Sawtooth shape over weekly cadence (local max + local min both
required to exist strictly inside the series).
- `shotPhaseAt` returns `none` / `rising` / `fading` at the
appropriate timepoints. (Peak classification is exercised
indirectly through the sawtooth test.)
- `RESEARCH_MODE_DISCLAIMER_VERSION` exported, non-empty, matches
the `YYYY-MM-DD.N` shape.
### Commit 3 — `/api/auth/me/research-mode`
`GET` returns:
```ts
{
enabled: boolean;
acknowledgedAt: string | null; // ISO 8601
acknowledgedVersion: string | null;
currentDisclaimerVersion: string; // === RESEARCH_MODE_DISCLAIMER_VERSION
}
```
The Settings UI compares `acknowledgedVersion` to
`currentDisclaimerVersion`; when they differ, the dialog re-prompts
even if `enabled === true`.
`POST` body shape (strict):
```ts
{ acknowledged: true, version: string }
```
- 401 unauth.
- 422 on malformed JSON or wrong shape.
- 400 (`research-mode.version.stale`) if `version !==
RESEARCH_MODE_DISCLAIMER_VERSION` — defends against a stale
client tab acknowledging old wording.
- 429 on per-user rate-limit overflow (5/min via
`checkRateLimit("research-mode:post:{userId}", 5, 60_000)`).
- Audit log: `user.research-mode.enable` with `{ previous, next }`.
`DELETE`:
- 401 unauth.
- Otherwise idempotent: writes the canonical `{enabled:false,
ackAt:null, ackVersion:null}` shape and emits a
`user.research-mode.disable` audit row even when the flag is
already off.
11 route tests cover every branch.
### Prompt-injection posture
The `POST` body carries only the version string. The route compares
it byte-for-byte to the server-side constant before any DB write
and never forwards it to any LLM surface. There is no free-text
field.
## Quality gates
- `pnpm typecheck` — clean.
- `pnpm lint` — clean.
- Touched-surface tests — clean (15 PK + 11 route = 26 new tests; all green).
## For W19c-Frontend
Import paths and contracts the next agent will need:
```ts
// Disclaimer version constant — display in the dialog footer and
// post back with the acknowledgment.
import { RESEARCH_MODE_DISCLAIMER_VERSION } from "@/lib/medications/glp1-pk";
// PK helpers for the AreaChart and the dashboard chip.
import {
computeOneCompartment,
shotPhaseAt,
type DoseEvent,
type PkSample,
type ShotPhase,
} from "@/lib/medications/glp1-pk";
// Drug id enum — matches the catalog.
import {
GLP1_DRUG_IDS,
type Glp1DrugId,
} from "@/lib/medications/glp1-knowledge";
```
### API contract for the Settings toggle
```
GET /api/auth/me/research-mode
→ 200 { data: { enabled, acknowledgedAt, acknowledgedVersion,
currentDisclaimerVersion } }
POST /api/auth/me/research-mode
body: { acknowledged: true,
version: RESEARCH_MODE_DISCLAIMER_VERSION }
→ 200 { data: { enabled: true, ... } }
→ 400 { error: "research-mode.version.stale" }
→ 422 { error: "research-mode.body.invalid_shape" |
"research-mode.body.invalid_json" }
→ 429 on rate-limit
DELETE /api/auth/me/research-mode
→ 200 { data: { enabled: false, ack*: null, ... } }
```
### Chart wiring sketch
The W19c-Frontend dialog should:
1. On open, `GET` to read the current state.
2. Compare `acknowledgedVersion === currentDisclaimerVersion` — if
they differ (or `enabled === false`), show the disclaimer.
3. The acknowledgment button posts `{ acknowledged: true,
version: currentDisclaimerVersion }`. Use the version returned by
`GET`, not the constant from `glp1-pk.ts` — that way a server
redeploy that bumps the version is reflected without a client
reload.
4. Once `enabled === true`, render the Recharts AreaChart over
`computeOneCompartment(drugId, doses, new Date())`. Hide the
y-axis tick labels. Label the axis "Estimated level
(relative)" — never include a unit (research §2.3).
5. The dashboard tile chip reads `shotPhaseAt(drugId, doses, new
Date())` and maps the four labels to the four chip variants.
### Drug-id enum
The frontend must pass exactly one of:
- `"tirzepatide"`
- `"semaglutide"`
- `"liraglutide"`
- `"dulaglutide"`
- `"exenatide"`
Use `GLP1_DRUG_IDS` to iterate or `findDrugByBrand` from
`glp1-knowledge.ts` to look up by user-entered brand name.
### Settings re-prompt rule
`acknowledgedVersion !== currentDisclaimerVersion` MUST hide the
chart and re-show the disclaimer, even when `enabled === true`. The
backend already rejects a `POST` carrying a stale version, so this
is a defence-in-depth UX rule for the client.
## Concerns / open items handed forward
- **No `dosesPerPen` integration**. The PK math accepts an
arbitrary dose-mg series; integrating W19b's pen-inventory state
into the chart's input is a W19c-Frontend / W19e concern, not
Backend's. The frontend should source `DoseEvent[]` from
`MedicationIntakeEvent` rows (already in the schema).
- **Two-compartment math deferred to v1.5**. The header comment in
`glp1-pk.ts` carries the rationale. If a future agent ports the
two-compartment closed form, it must ship alongside a renewed
Coach refusal-layer audit and a Marc-direct decision on the
unit-less y-axis.
- **Ka fallback rule** (`Ka ≈ 3 ln 2 / Tmax`) is a textbook
approximation. For tirzepatide the catalog carries the
psp4.13099 value verbatim, so the fallback never fires for that
drug; for the others the EMA EPAR does not publish a pop-PK Ka
and the textbook value is the best available. Documented in the
module header.
## Touch-disjoint compliance
Files touched (all permitted by the dispatch spec):
- `prisma/schema.prisma` — User model only (added three fields
in a `// v1.4.25 W19c — ...` block adjacent to W6c's block).
- `prisma/migrations/0058_user_research_mode/migration.sql`
- `src/lib/medications/glp1-pk.ts` (new)
- `src/lib/medications/__tests__/glp1-pk.test.ts` (new)
- `src/app/api/auth/me/research-mode/route.ts` (new)
- `src/app/api/auth/me/research-mode/__tests__/route.test.ts` (new)
- `.planning/phase-W19c-Backend-v1425-report.md` (this file)
Did not touch any W14b-Content surface (`messages/*.json`,
`src/app/onboarding/**`, `src/components/onboarding/**`), any
W19c-Frontend surface (`src/components/medications/**`, settings
page), or any W19c-Safety surface (`src/lib/coach/**`).