feat(modules): illness defaults on (remove born-gated mechanism)

This commit is contained in:
Marc-André Bombeck committed 2026-06-17 14:11:30 +02:00
commit 6ba2d435c9
10 files changed
+37 -72

No files matched your search

+4 -4
View File
@@ -6357,7 +6357,7 @@ paths:
- Illness
summary: Edit an illness episode (v1.18.1)
description: Partial edit; omitted fields are left untouched. A re-parent must point at an owned, live episode (and
never the episode itself). Audits as `illness.episode.update`. Owner-scoped + born-gated.
never the episode itself). Audits as `illness.episode.update`. Owner-scoped + module-gated.
parameters:
- in: path
name: id
@@ -6430,7 +6430,7 @@ paths:
- Illness
summary: Soft-delete an illness episode (v1.18.1)
description: "Stamps `deletedAt` (tombstone). Idempotent — a re-delete is a no-op. Returns the `{ deleted: true }`
envelope (the cross-module DELETE shape). Audits as `illness.episode.delete`. Owner-scoped + born-gated."
envelope (the cross-module DELETE shape). Audits as `illness.episode.delete`. Owner-scoped + module-gated."
parameters:
- in: path
name: id
@@ -6455,7 +6455,7 @@ paths:
summary: Mark an illness episode recovered (v1.18.1)
description: Stamps `resolvedAt` (defaults to 'now' when the body omits it). A CHRONIC_ONGOING episode has no recovery
date and 422s with `errorCode:"illness.episode.chronic-no-resolve"`. Audits as `illness.episode.resolve`.
Owner-scoped + born-gated.
Owner-scoped + module-gated.
parameters:
- in: path
name: id
@@ -6622,7 +6622,7 @@ paths:
nadir, per-vital physiological returns, the headline recovery-gap, and any red flags. The findings derive from
the user's OWN baseline (median ± MAD) over a contamination-guarded window — never a population constant.
`status:\"insufficient\"` carries coverage + a reason. Retrospective ONLY — never a predictor or diagnoser.
Owner-scoped + born-gated."
Owner-scoped + module-gated."
parameters:
- in: path
name: id
@@ -78,12 +78,12 @@ const MODULE_ROUTE_TREES: ReadonlyArray<string> = [
"src/app/api/biomarkers",
"src/app/api/cycle",
// v1.18.1 (W-B) — the illness/condition journal. Every `/api/illness/*`
// route gates on the born-gated `illness` module via the thin
// route gates on the `illness` module via the thin
// `requireIllnessEnabled(...)` wrapper (which re-stamps the
// illness-specific errorCode over `requireModuleEnabled("illness")`),
// recognised below as a delegated gate. Walking the tree means a NEW
// ungated illness route fails this test BY NAME rather than leaking the
// surface over a Bearer token when the account never opted in.
// surface over a Bearer token when the account turned the module off.
"src/app/api/illness",
"src/app/api/gamification",
// v1.18.1 (D3) — medications graduated from CORE to a toggleable module.
+1 -1
View File
@@ -26,7 +26,7 @@ import { createBiomarkerSchema } from "@/lib/validations/biomarkers";
* The Labs module toggle is a UX-only nav preference; the data is owner-scoped
* and read by cross-feature surfaces (Vorsorge reminder resolution, the
* doctor-report PDF) regardless of the toggle. Deliberate opt-out, in contrast
* to the born-gated `/api/illness/*` routes.
* to the gated `/api/illness/*` routes.
*
* The catalog is the Labs feature's primary object: a marker is defined ONCE
* (name, unit, reference bounds, optional context) and every later reading
+1 -1
View File
@@ -28,7 +28,7 @@ import {
/**
* v1.17.1 — structured lab-result store (`/api/labs`).
*
* Module gate: unlike `/api/illness/*` (born-gated, every route 403s when the
* Module gate: unlike `/api/illness/*` (gated, every route 403s when the
* module is off), Labs is intentionally NOT server-gated. The Labs module
* toggle is a UX-only nav/visibility preference — the data is always
* owner-scoped and safe to read/write, and the Vorsorge lab-panel reminder
+4 -4
View File
@@ -289,9 +289,9 @@ export interface DoctorReportData {
}> | null;
/**
* v1.18.1 P4 — illness / condition episodes overlapping the report window.
* Populated ONLY when the `illness` module is enabled (born-gated opt-in);
* the user turned the journal on and is sharing it deliberately, so the
* privacy stance matches labs (ON when the module is on), not mood / cycle.
* Populated ONLY when the `illness` module is enabled (default-on, opt-out);
* the journal is on and shared deliberately, so the privacy stance matches
* labs (ON when the module is on), not mood / cycle.
* Statistics + labels only — the free-text note is NEVER read here, so no
* encrypted plaintext can leak into the report. Each entry feeds a FHIR
* `Condition` (+ `Encounter`) and a PDF table row. Null/absent when the
@@ -1348,7 +1348,7 @@ export async function collectDoctorReportData(
}
// v1.18.1 P4 — illness / condition episodes overlapping the window. Gated
// on the illness module (born-gated opt-in): when off, no read, no section.
// on the illness module (default-on, opt-out): when off, no read, no section.
// An episode overlaps when it began on or before the window end AND is
// either still ongoing or resolved on or after the window start. Labels +
// lifecycle + dates only — the encrypted note is never selected, so no
+9 -9
View File
@@ -1,12 +1,12 @@
/**
* Illness / condition-journal feature gate (v1.18.1).
*
* The illness journal is BORN-GATED (opt-in / default-off) and resolves
* its enabled-state through the module foundation — there is no per-domain
* profile row and no second source of truth. `isIllnessEnabled` and the
* `requireIllnessEnabled` 403 guard both delegate to the module gate's
* `isModuleEnabled(userId, "illness")`, which (for a born-gated key) reads
* an explicit `modulePreferencesJson.illness === true` AND the operator
* The illness journal is a standard optional module (default-on, opt-out)
* and resolves its enabled-state through the module foundation — there is
* no per-domain profile row and no second source of truth. `isIllnessEnabled`
* and the `requireIllnessEnabled` 403 guard both delegate to the module
* gate's `isModuleEnabled(userId, "illness")`, which reads the disabled
* allowlist (`modulePreferencesJson.illness !== false`) AND the operator
* server-wide availability. This mirrors `src/lib/cycle/gate.ts` so every
* `/api/illness/*` route gates identically.
*/
@@ -17,7 +17,7 @@ import { isModuleEnabled, requireModuleEnabled } from "@/lib/modules/gate";
export const ILLNESS_DISABLED_ERROR_CODE = "illness.disabled";
/**
* Fully-resolved illness availability for an account: the born-gated opt-in
* Fully-resolved illness availability for an account: the per-user opt-out
* AND the operator server-wide kill-switch, via the module foundation.
*/
export function isIllnessEnabled(userId: string): Promise<boolean> {
@@ -34,8 +34,8 @@ export type IllnessGateResult =
/**
* Enforce the gate for an `/api/illness/*` route. Returns a 403
* `illness.disabled` envelope when the account has not opted in (or the
* operator turned the module off server-wide) — even with a valid Bearer
* `illness.disabled` envelope when the account has turned the module off
* (or the operator turned it off server-wide) — even with a valid Bearer
* token. Delegates to `requireModuleEnabled` but re-stamps the
* illness-specific `errorCode` so the iOS classifier branches cleanly.
*/
+8 -8
View File
@@ -78,14 +78,14 @@ describe("resolveModuleEnabled — disabled allowlist (default-on)", () => {
});
});
describe("resolveModuleEnabled — born-gated module (illness, default-off)", () => {
it("is OFF when no preference is recorded (opt-in, not default-on)", () => {
describe("resolveModuleEnabled — illness module (default-on, opt-out)", () => {
it("is ON when no preference is recorded (default-on like its siblings)", () => {
expect(resolveModuleEnabled("illness", inputs(), true, ALL_AVAILABLE)).toBe(
false,
true,
);
});
it("is ON only on an explicit true", () => {
it("stays ON on an explicit true", () => {
expect(
resolveModuleEnabled(
"illness",
@@ -96,7 +96,7 @@ describe("resolveModuleEnabled — born-gated module (illness, default-off)", ()
).toBe(true);
});
it("stays OFF on an explicit false", () => {
it("is OFF only on an explicit false (opt-out)", () => {
expect(
resolveModuleEnabled(
"illness",
@@ -107,7 +107,7 @@ describe("resolveModuleEnabled — born-gated module (illness, default-off)", ()
).toBe(false);
});
it("operator-off short-circuits even when the user opted in", () => {
it("operator-off short-circuits even when the user enabled it", () => {
expect(
resolveModuleEnabled(
"illness",
@@ -118,9 +118,9 @@ describe("resolveModuleEnabled — born-gated module (illness, default-off)", ()
).toBe(false);
});
it("does not affect sibling default-on modules", () => {
it("matches its sibling default-on modules", () => {
const i = inputs({ modulePreferences: {} });
expect(resolveModuleEnabled("illness", i, true, ALL_AVAILABLE)).toBe(false);
expect(resolveModuleEnabled("illness", i, true, ALL_AVAILABLE)).toBe(true);
expect(resolveModuleEnabled("mood", i, true, ALL_AVAILABLE)).toBe(true);
expect(resolveModuleEnabled("labs", i, true, ALL_AVAILABLE)).toBe(true);
});
+1 -13
View File
@@ -53,12 +53,7 @@ import {
getOperatorModuleAvailability,
type OperatorModuleAvailability,
} from "./operator-availability";
import {
MODULE_KEYS,
isBornGatedModule,
moduleDelegatesTo,
type ModuleKey,
} from "./registry";
import { MODULE_KEYS, moduleDelegatesTo, type ModuleKey } from "./registry";
export {
getOperatorModuleAvailability,
@@ -147,13 +142,6 @@ export function resolveModuleEnabled(
return assistantCoach && !inputs.disableCoach;
}
if (isBornGatedModule(key)) {
// BORN-GATED (opt-in / default-off): inverse of the disabled allowlist.
// The module stays OFF until the account explicitly enables it — only a
// literal `true` turns it on, so the surface never appears unbidden.
return inputs.modulePreferences[key] === true;
}
// Disabled allowlist: only an explicit `false` turns the module off.
return inputs.modulePreferences[key] !== false;
}
-23
View File
@@ -73,17 +73,6 @@ export interface ModuleDefinition {
* link; `labelKey` names the destination ("Account", "Coach settings").
*/
managedAt?: { href: string; labelKey: string };
/**
* v1.18.1 — BORN-GATED (opt-in / default-off). A born-gated module is
* the inverse of the standard disabled-allowlist: it stays OFF until the
* account explicitly enables it (`modulePreferencesJson[key] === true`).
* Used for new optional verticals (the illness/condition journal) that
* ship dark and only appear once the user opts in from the Modules hub.
* The gate's `false`-disables contract still holds — an explicit `false`
* (or simply the absent default) keeps it off — but here ONLY an explicit
* `true` turns it on, so the surface never appears unbidden.
*/
bornGated?: boolean;
}
/**
@@ -196,9 +185,6 @@ export const MODULE_REGISTRY: Readonly<Record<ModuleKey, ModuleDefinition>> =
labelKey: "modules.illness.label",
descriptionKey: "modules.illness.description",
category: "tracking",
// Ships dark — only appears once the account opts in from the
// Modules hub. Default-off, no nag, retrospective-only.
bornGated: true,
},
sleep: {
key: "sleep",
@@ -267,12 +253,3 @@ export function isModuleKey(key: string): key is ModuleKey {
export function moduleDelegatesTo(key: ModuleKey): ModuleDelegation | undefined {
return MODULE_REGISTRY[key].delegatesTo;
}
/**
* v1.18.1 — true for a born-gated (opt-in / default-off) module. The gate
* flips its default from on to off for these keys: they require an explicit
* `modulePreferencesJson[key] === true` to enable.
*/
export function isBornGatedModule(key: ModuleKey): boolean {
return MODULE_REGISTRY[key].bornGated === true;
}
+7 -7
View File
@@ -9,9 +9,9 @@
* carry; iOS renders the DTO, it never recomputes it).
*
* The journal is a CONDITION journal, retrospective-only — never a
* predictor/diagnoser. Every route is born-gated: a non-opted-in account (or
* an operator-disabled instance) 403s with `errorCode:"illness.disabled"`
* even with a valid Bearer token.
* predictor/diagnoser. Every route is module-gated: an account that turned
* the module off (or an operator-disabled instance) 403s with
* `errorCode:"illness.disabled"` even with a valid Bearer token.
*/
import type { ZodOpenApiObject } from "zod-openapi";
import { z } from "zod/v4";
@@ -342,7 +342,7 @@ export const illnessPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Illness"],
summary: "Edit an illness episode (v1.18.1)",
description:
"Partial edit; omitted fields are left untouched. A re-parent must point at an owned, live episode (and never the episode itself). Audits as `illness.episode.update`. Owner-scoped + born-gated.",
"Partial edit; omitted fields are left untouched. A re-parent must point at an owned, live episode (and never the episode itself). Audits as `illness.episode.update`. Owner-scoped + module-gated.",
requestParams: { path: z.object({ id: z.string() }) },
requestBody: {
required: true,
@@ -370,7 +370,7 @@ export const illnessPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Illness"],
summary: "Soft-delete an illness episode (v1.18.1)",
description:
"Stamps `deletedAt` (tombstone). Idempotent — a re-delete is a no-op. Returns the `{ deleted: true }` envelope (the cross-module DELETE shape). Audits as `illness.episode.delete`. Owner-scoped + born-gated.",
"Stamps `deletedAt` (tombstone). Idempotent — a re-delete is a no-op. Returns the `{ deleted: true }` envelope (the cross-module DELETE shape). Audits as `illness.episode.delete`. Owner-scoped + module-gated.",
requestParams: { path: z.object({ id: z.string() }) },
responses: {
"200": {
@@ -394,7 +394,7 @@ export const illnessPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Illness"],
summary: "Mark an illness episode recovered (v1.18.1)",
description:
"Stamps `resolvedAt` (defaults to 'now' when the body omits it). A CHRONIC_ONGOING episode has no recovery date and 422s with `errorCode:\"illness.episode.chronic-no-resolve\"`. Audits as `illness.episode.resolve`. Owner-scoped + born-gated.",
"Stamps `resolvedAt` (defaults to 'now' when the body omits it). A CHRONIC_ONGOING episode has no recovery date and 422s with `errorCode:\"illness.episode.chronic-no-resolve\"`. Audits as `illness.episode.resolve`. Owner-scoped + module-gated.",
requestParams: { path: z.object({ id: z.string() }) },
requestBody: {
required: false,
@@ -491,7 +491,7 @@ export const illnessPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Illness"],
summary: "Per-episode retrospective correlation (v1.18.1)",
description:
"Returns the coverage-gated `Derived<T>` correlation for one episode: the pre-onset anomaly scan, the nadir, per-vital physiological returns, the headline recovery-gap, and any red flags. The findings derive from the user's OWN baseline (median ± MAD) over a contamination-guarded window — never a population constant. `status:\"insufficient\"` carries coverage + a reason. Retrospective ONLY — never a predictor or diagnoser. Owner-scoped + born-gated.",
"Returns the coverage-gated `Derived<T>` correlation for one episode: the pre-onset anomaly scan, the nadir, per-vital physiological returns, the headline recovery-gap, and any red flags. The findings derive from the user's OWN baseline (median ± MAD) over a contamination-guarded window — never a population constant. `status:\"insufficient\"` carries coverage + a reason. Retrospective ONLY — never a predictor or diagnoser. Owner-scoped + module-gated.",
requestParams: { path: z.object({ id: z.string() }) },
responses: {
"200": {