diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index a02cb2e33..ce8e36ce1 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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 diff --git a/src/app/api/__tests__/module-route-gate-inventory.test.ts b/src/app/api/__tests__/module-route-gate-inventory.test.ts index 57a5daca1..fd4e9bb17 100644 --- a/src/app/api/__tests__/module-route-gate-inventory.test.ts +++ b/src/app/api/__tests__/module-route-gate-inventory.test.ts @@ -78,12 +78,12 @@ const MODULE_ROUTE_TREES: ReadonlyArray = [ "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. diff --git a/src/app/api/biomarkers/route.ts b/src/app/api/biomarkers/route.ts index a9c2a7ff5..b30c84be6 100644 --- a/src/app/api/biomarkers/route.ts +++ b/src/app/api/biomarkers/route.ts @@ -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 diff --git a/src/app/api/labs/route.ts b/src/app/api/labs/route.ts index 5afb9ac69..dce2e5a93 100644 --- a/src/app/api/labs/route.ts +++ b/src/app/api/labs/route.ts @@ -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 diff --git a/src/lib/doctor-report-data.ts b/src/lib/doctor-report-data.ts index 647f0ae27..6e6b13c33 100644 --- a/src/lib/doctor-report-data.ts +++ b/src/lib/doctor-report-data.ts @@ -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 diff --git a/src/lib/illness/gate.ts b/src/lib/illness/gate.ts index 280a1c8c6..16195cdf3 100644 --- a/src/lib/illness/gate.ts +++ b/src/lib/illness/gate.ts @@ -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 { @@ -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. */ diff --git a/src/lib/modules/__tests__/gate.test.ts b/src/lib/modules/__tests__/gate.test.ts index b4f72b2cf..83eab2394 100644 --- a/src/lib/modules/__tests__/gate.test.ts +++ b/src/lib/modules/__tests__/gate.test.ts @@ -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); }); diff --git a/src/lib/modules/gate.ts b/src/lib/modules/gate.ts index 3e1da491b..34a4797a9 100644 --- a/src/lib/modules/gate.ts +++ b/src/lib/modules/gate.ts @@ -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; } diff --git a/src/lib/modules/registry.ts b/src/lib/modules/registry.ts index 90f276002..704962a2f 100644 --- a/src/lib/modules/registry.ts +++ b/src/lib/modules/registry.ts @@ -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> = 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; -} diff --git a/src/lib/openapi/routes/illness.ts b/src/lib/openapi/routes/illness.ts index 1c9c9d62c..1e03d0f50 100644 --- a/src/lib/openapi/routes/illness.ts +++ b/src/lib/openapi/routes/illness.ts @@ -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 = { 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 = { 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 = { 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 = { tags: ["Illness"], summary: "Per-episode retrospective correlation (v1.18.1)", description: - "Returns the coverage-gated `Derived` 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` 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": {