From fa2b9531f7cd142e436ed77538c07d28b8c2f740 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 03:09:29 +0200 Subject: [PATCH 1/5] Gate the MCP export reads on the module map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MCP surface reached three data families that deliberately bypass the Coach snapshot and so inherited none of its module narrowing: the doctor-report aggregate behind healthlog://report/doctor-visit and the doctor_visit_summary prompt, the rollup-backed rich reads (compare_metric, get_metric_baseline, detect_changepoints), and the correlations reader. A disabled module — or one an operator switched off server-wide — still egressed to a third-party assistant. Gate at the read rather than per call site. The module-to-data ownership table moves out of the Coach snapshot builder into lib/modules/measurement-scope so every read gates off one map; the rich reads resolve metrics through a module-aware resolver; the correlations reader gates on insights itself, which also closes the same gap on the Coach chat tool and the metric-page read strip. Per-domain reads omit with an honest module_disabled reason; the whole-record aggregate refuses, mirroring /api/export/health-record. collectDoctorReportData gains a fail-closed backstop on its own module key. The clinician share link, found while tracing the aggregate's callers, had the same gap and now collapses to its documents-only state. --- src/lib/ai/coach/snapshot.ts | 28 ++-- src/lib/ai/coach/tools/correlations-read.ts | 24 +++ src/lib/clinician-share/share-view-data.ts | 23 ++- src/lib/doctor-report-data.ts | 26 +++ src/lib/mcp/prompts.ts | 20 +++ src/lib/mcp/resources.ts | 21 +++ src/lib/mcp/rich-reads.ts | 120 ++++++++++++-- src/lib/modules/measurement-scope.ts | 169 ++++++++++++++++++++ 8 files changed, 394 insertions(+), 37 deletions(-) create mode 100644 src/lib/modules/measurement-scope.ts diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index d93b12c2f..43916d418 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -50,6 +50,7 @@ import type { MeasurementType } from "@/generated/prisma/client"; import type { ReferenceMetric } from "@/lib/reference-ranges"; import { isCycleAvailableForUser } from "@/lib/cycle/gate"; import { resolveModuleMap, type ModuleKey } from "@/lib/modules/gate"; +import { MODULE_SCOPED_SOURCES } from "@/lib/modules/measurement-scope"; import { SCHEDULE_COMPLIANCE_SELECT } from "@/lib/analytics/compliance"; import type { BaselineProfile } from "@/lib/insights/derived"; import { @@ -222,26 +223,15 @@ const CORE_CLUSTERS: ReadonlySet = new Set([ * W1 foundation prescribes. `coach` is the surface being narrated, not * a data domain. `labs` / `achievements` / `insights` / `doctorReport` * own no coach-snapshot data domain. + * + * v1.30.22 — the table itself moved to `@/lib/modules/measurement-scope` so + * the reads that deliberately bypass this builder (the MCP rich reads) gate + * off the SAME ownership map instead of inheriting nothing. The narrowing + * below is unchanged; only the definition site moved. */ -const MODULE_EXCLUDED_SOURCES: Partial> = - { - mood: ["mood"], - sleep: ["sleep"], - glucose: ["glucose"], - workouts: ["workouts"], - recovery: ["hrv", "resting_hr", "vo2_max"], - // The environment/exposure cluster owns exactly these sources (mirrors - // `CLUSTER_SOURCES.environment` in clusters.ts). When the opt-in - // environment module is off, strip its audio-exposure / daylight / - // skin-temperature blocks so the model never sees a disabled domain. - environment: [ - "audio_env", - "audio_headphone", - "audio_event", - "daylight", - "skin_temp", - ], - }; +const MODULE_EXCLUDED_SOURCES = MODULE_SCOPED_SOURCES as Partial< + Record +>; /** * Build the Coach prompt snapshot for `userId`. Always uses diff --git a/src/lib/ai/coach/tools/correlations-read.ts b/src/lib/ai/coach/tools/correlations-read.ts index 52dc0b624..15b322a33 100644 --- a/src/lib/ai/coach/tools/correlations-read.ts +++ b/src/lib/ai/coach/tools/correlations-read.ts @@ -25,6 +25,7 @@ import type { MeasurementType } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; +import { isModuleEnabled } from "@/lib/modules/gate"; import { wallClockInTz } from "@/lib/tz/wall-clock"; import { discoverCorrelations, @@ -151,6 +152,29 @@ function humanise(key: string): string { export async function readCoachCorrelations( userId: string, ): Promise { + // v1.30.22 — the `insights` gate lives HERE, at the read, not at each + // caller. This reader is reached from four places (the Coach + // `get_correlations` tool, the MCP `get_correlation` rich read, the + // per-metric "Coach read" strip, and the Coach snapshot); only the REST + // sibling `/api/insights/correlations` gated, so every other caller + // defeated both the user's `insights` toggle and the operator availability + // switch ANDed above it. Gating the reader closes all four at once and + // means a future caller cannot reintroduce the gap by forgetting. + // + // OMIT rather than refuse: this is a per-domain read whose whole contract + // is already `{ present: false }` for honest absence, and every caller + // treats a miss as "no pattern to narrate". A throw here would break a + // Coach turn for a user who simply turned a module off. The distinct + // `module_disabled` reason keeps it honest — the assistant is told the + // domain is off, not that no correlation exists. + // + // Deliberately OUTSIDE the try/catch: the fail-soft below turns any throw + // into `{ present: false }`, so a gate placed inside would still close the + // leak but would silently swallow a real gate failure. Out here, a broken + // gate is loud. + if (!(await isModuleEnabled(userId, "insights"))) { + return { present: false, reason: "module_disabled" }; + } try { const profile = await loadBaselineProfile(prisma, userId); const userRow = await prisma.user.findUnique({ diff --git a/src/lib/clinician-share/share-view-data.ts b/src/lib/clinician-share/share-view-data.ts index 6c30e80e5..88cc3676c 100644 --- a/src/lib/clinician-share/share-view-data.ts +++ b/src/lib/clinician-share/share-view-data.ts @@ -18,6 +18,7 @@ import { type DoctorReportData, type DoctorReportRange, } from "@/lib/doctor-report-data"; +import { isModuleEnabled } from "@/lib/modules/gate"; import { servingClassFor } from "@/lib/documents/upload-policy"; import type { DocumentServingClass } from "@/lib/documents/upload-policy"; import { @@ -109,7 +110,27 @@ export async function loadShareViewData( // the column can only pin — never widen — this is strictly safer than the // derived check alone: a future report section can never re-open a link the // owner froze as documents-only. - const documentOnly = context.documentOnly || !hasAnyReportSection(sections); + // v1.30.22 — the owner's `doctorReport` module gates the aggregate here too. + // This surface serves the doctor-report payload to an unauthenticated third + // party over a link, and it never consulted the module key; an owner (or an + // operator, via the availability switch ANDed above the user toggle) who + // turns the module off would still have had the full record served. Found + // while closing the same gap on the MCP surface. + // + // Degrade to the share's OWN documents-only state rather than throwing: + // `documentOnly` is an existing, load-bearing privacy mode on this surface + // (report `null`, aggregator never called), so a disabled module collapses + // the link to exactly the documents the owner attached. That is fail-closed + // for the health record while keeping a public link from 500-ing. The + // documents themselves are a separate module and keep their own gate. + const reportModuleEnabled = await isModuleEnabled( + context.ownerUserId, + "doctorReport", + ); + const documentOnly = + context.documentOnly || + !hasAnyReportSection(sections) || + !reportModuleEnabled; const [report, documents] = await Promise.all([ documentOnly diff --git a/src/lib/doctor-report-data.ts b/src/lib/doctor-report-data.ts index 2cf72fdfc..ab4781d0a 100644 --- a/src/lib/doctor-report-data.ts +++ b/src/lib/doctor-report-data.ts @@ -95,6 +95,32 @@ export async function collectDoctorReportData( // key and stay unconditional. Injectable for tests. const moduleMap = options.moduleMap ?? (await resolveModuleMap(userId)); + // v1.30.22 — fail-closed backstop on the aggregate's OWN module key, + // mirroring `loadFhirContext`. + // + // Everything below gates the per-domain SECTIONS (mood / sleep / glucose / + // cycle / labs / recovery / workouts) but nothing ever gated `doctorReport` + // itself — the key that decides whether the whole-record aggregate may be + // assembled at all. That left it entirely to callers to remember, and the + // MCP surface did not: `healthlog://report/doctor-visit`, its `{window}` + // template, and the `doctor_visit_summary` prompt all assembled the full + // record for an account with the module off, on the one wire that egresses + // to a third-party assistant. + // + // REFUSE, not omit — the opposite of the per-domain reads. This is the + // whole-record aggregate, so there is no honest partial answer to return; + // `/api/export/health-record` already answers 403 for exactly this state + // and this is the same payload. It throws rather than returning an envelope + // because reaching here means a caller is missing its gate: a bug to + // surface, not a flow to serve. Callers that can degrade gracefully gate + // themselves BEFORE calling (see the MCP resources and the clinician share). + if (moduleMap.doctorReport === false) { + throw new Error( + 'collectDoctorReportData called with the "doctorReport" module ' + + "disabled — the calling surface is missing its module gate", + ); + } + const sections: DoctorReportPrefs = { ...DEFAULT_DOCTOR_REPORT_PREFS, ...(options.sections ?? {}), diff --git a/src/lib/mcp/prompts.ts b/src/lib/mcp/prompts.ts index 7f6331b01..105b9be36 100644 --- a/src/lib/mcp/prompts.ts +++ b/src/lib/mcp/prompts.ts @@ -23,6 +23,7 @@ import { z } from "zod/v4"; import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; import { collectDoctorReportData } from "@/lib/doctor-report-data"; +import { isModuleEnabled } from "@/lib/modules/gate"; import { summariseForVisit } from "./doctor-visit-summary"; import { coachScopeWindowSchema } from "@/lib/ai/coach/types"; import type { CoachScopeWindow } from "@/lib/ai/coach/types"; @@ -281,6 +282,25 @@ export const MCP_PROMPTS: McpPromptDefinition[] = [ const window = ( typeof args.window === "string" ? args.window : "last90days" ) as CoachScopeWindow; + // v1.30.22 — same whole-record gate the doctor-visit RESOURCE applies. + // A prompt assembles real retrieved data, so it egresses exactly the + // aggregate the resource does and needs the same door. Refuse, don't + // omit: there is no honest partial visit summary, and an empty one + // invites the assistant to narrate "nothing on file". + if (!(await isModuleEnabled(ctx.userId, "doctorReport"))) { + annotate({ + action: { name: "mcp.prompt.invoked" }, + meta: { + prompt: "doctor_visit_summary", + refused: "module_disabled", + }, + }); + throw new Error( + 'The "doctorReport" module is turned off for this account, so the ' + + "visit summary is not available.", + ); + } + const days = WINDOW_DAYS[window] ?? 90; const end = new Date(); const start = new Date(end.getTime() - days * MS_PER_DAY); diff --git a/src/lib/mcp/resources.ts b/src/lib/mcp/resources.ts index f2d0d675c..6fcb8b361 100644 --- a/src/lib/mcp/resources.ts +++ b/src/lib/mcp/resources.ts @@ -282,6 +282,27 @@ async function readDoctorVisit( ctx: McpAuthContext, window: string, ): Promise { + // v1.30.22 — gate the whole-record aggregate on its own module key. Both + // the fixed resource and the `{window}` template funnel through here, so + // this is the one door for the MCP side. + // + // REFUSE rather than return `{ present: false }`: unlike the per-domain + // reads, this resource IS the whole record, so there is no honest partial + // payload — mirroring the 403 `/api/export/health-record` answers for the + // same state. Thrown, which the MCP transport surfaces as a JSON-RPC error + // on the read, so the assistant learns the resource is unavailable instead + // of receiving an empty summary it might narrate as "nothing on file". + if (!(await isModuleEnabled(ctx.userId, "doctorReport"))) { + annotate({ + action: { name: "mcp.resource.read" }, + meta: { resource: "doctor-visit-report", refused: "module_disabled" }, + }); + throw new Error( + 'The "doctorReport" module is turned off for this account, so the ' + + "doctor-visit summary is not available.", + ); + } + const days = WINDOW_DAYS[window] ?? 90; const end = new Date(); const start = new Date(end.getTime() - days * MS_PER_DAY); diff --git a/src/lib/mcp/rich-reads.ts b/src/lib/mcp/rich-reads.ts index 0ceeb0c70..62e9ebe42 100644 --- a/src/lib/mcp/rich-reads.ts +++ b/src/lib/mcp/rich-reads.ts @@ -32,6 +32,8 @@ import type { MeasurementType } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; +import { isModuleEnabled } from "@/lib/modules/gate"; +import { moduleForMeasurementType } from "@/lib/modules/measurement-scope"; import { readCoachCorrelations } from "@/lib/ai/coach/tools/correlations-read"; import { buildCoachReadStrip } from "@/lib/insights/derived/coach-read"; import { @@ -478,7 +480,23 @@ export async function metricStatusDiscoveryRows(userId: string): Promise< _count: { _all: true }, }); const countByType = new Map(rows.map((r) => [r.type, r._count._all])); - return MCP_METRIC_STATUS_DISCOVERY.map((sig) => { + + // v1.30.22 — drop a metric whose owning module the account has off (or the + // operator switched off server-wide). Discovery is the assistant's map of + // what exists; advertising a metric that `compare_metric` will then refuse + // both leaks the fact that the domain is tracked and sends the assistant + // down a dead end. Gating here keeps discovery and fetch telling the same + // story. Metrics no module owns are unaffected. + const gated = await Promise.all( + MCP_METRIC_STATUS_DISCOVERY.map(async (sig) => { + const owner = moduleForMeasurementType(sig.measurementType); + if (owner && !(await isModuleEnabled(userId, owner))) return null; + return sig; + }), + ); + + return gated.flatMap((sig) => { + if (!sig) return []; const count = countByType.get(sig.measurementType); return { tool: "compare_metric", @@ -536,6 +554,65 @@ export function resolveRichMetric(input: string): RichMetric | null { return null; } +/** + * v1.30.22 — the module-gated resolver every rich read must use. + * + * `resolveRichMetric` answers "does this name resolve to a series?"; it says + * nothing about whether the account is allowed to read that series. The three + * rollup-backed reads (`compare_metric` / `get_metric_baseline` / + * `detect_changepoints`) deliberately bypass `buildCoachSnapshot`, which is + * where the module narrowing lives for the Coach-routed reads — so they + * inherited no gate at all. `get_metric_series` honestly reported + * `{ present: false }` for a glucose-disabled account while + * `get_metric_baseline` handed back the median, the MAD band and today's + * placement for the same metric. That defeats the user toggle AND the + * operator availability switch `resolveModuleMap` ANDs above it — on the one + * surface that egresses to a third-party assistant. + * + * Gating at the RESOLVER rather than per tool is the point: a new read that + * takes a free-text metric name has to resolve it, and resolving it gates it. + * + * OMIT rather than refuse. These are per-domain reads whose contract already + * carries `{ present: false, reason }` for absence, matching what + * `get_metric_series` / `get_nutrients` / `get_intraday_pulse` already do for + * a disabled module. The `module_disabled` reason is distinct from `no_data` + * so the assistant is told the domain is switched off rather than inferring + * the user has no readings. + * + * A metric no module owns (`moduleForMeasurementType` → `null`) resolves + * ungated — see `UNSCOPED_REVIEWED_TYPES` for the reviewed set that answers + * null and why gating them would be wrong. + */ +async function resolveRichMetricForUser( + userId: string, + input: string, +): Promise< + { ok: true; metric: RichMetric } | { ok: false; reason: RichMissReason } +> { + const resolved = resolveRichMetric(input); + if (!resolved) return { ok: false, reason: "unknown_metric" }; + + const owner = moduleForMeasurementType(resolved.measurementType); + if (owner && !(await isModuleEnabled(userId, owner))) { + return { ok: false, reason: "module_disabled" }; + } + + // The HRV union may swap the metric to its RMSSD fallback. Both types are + // owned by `recovery`, but gate the RESULT too so a future fallback pair + // that crosses a module boundary cannot smuggle a gated type through. + const metric = await withHrvFallback(userId, resolved); + if (metric.measurementType !== resolved.measurementType) { + const fallbackOwner = moduleForMeasurementType(metric.measurementType); + if (fallbackOwner && !(await isModuleEnabled(userId, fallbackOwner))) { + return { ok: false, reason: "module_disabled" }; + } + } + return { ok: true, metric }; +} + +/** The miss reasons the module-gated resolver can produce. */ +type RichMissReason = "unknown_metric" | "module_disabled"; + // ── 1. get_correlation ─────────────────────────────────────────────── export interface CorrelationResult { @@ -782,11 +859,11 @@ export async function compareMetric( rangeB?: DateRange; }, ): Promise { - const resolvedA = resolveRichMetric(args.metric); - if (!resolvedA) { - return { present: false, reason: "unknown_metric" }; + const resolvedA = await resolveRichMetricForUser(userId, args.metric); + if (!resolvedA.ok) { + return { present: false, reason: resolvedA.reason }; } - const metricA = await withHrvFallback(userId, resolvedA); + const metricA = resolvedA.metric; const sideA: SideSpec = { window: args.window, range: args.range }; const annotateMiss = () => @@ -800,14 +877,23 @@ export async function compareMetric( let sideB: SideSpec; if (args.metricB) { - const resolvedB = resolveRichMetric(args.metricB); - if (!resolvedB) { + const resolvedB = await resolveRichMetricForUser(userId, args.metricB); + if (!resolvedB.ok) { annotateMiss(); - return { present: false, reason: "unknown_metric_b" }; + // Side B carries its own suffixed miss reasons so the assistant can tell + // which half of the comparison failed. A gated side B refuses the whole + // comparison rather than silently degrading to a one-sided answer. + return { + present: false, + reason: + resolvedB.reason === "module_disabled" + ? "module_disabled_b" + : "unknown_metric_b", + }; } // Both metrics share side A's horizon (window or range). mode = "metric_vs_metric"; - metricB = await withHrvFallback(userId, resolvedB); + metricB = resolvedB.metric; sideB = sideA; } else if (args.rangeB || args.windowB) { mode = "window_vs_window"; @@ -881,11 +967,11 @@ export async function getMetricBaseline( userId: string, args: { metric: string }, ): Promise { - const resolved = resolveRichMetric(args.metric); - if (!resolved) { - return { present: false, reason: "unknown_metric" }; + const resolved = await resolveRichMetricForUser(userId, args.metric); + if (!resolved.ok) { + return { present: false, reason: resolved.reason }; } - const metric = await withHrvFallback(userId, resolved); + const metric = resolved.metric; const strip = await buildCoachReadStrip(userId, metric.measurementType); @@ -1026,11 +1112,11 @@ export async function detectChangepoints( userId: string, args: { metric: string; window?: CoachScopeWindow; range?: DateRange }, ): Promise { - const resolved = resolveRichMetric(args.metric); - if (!resolved) { - return { present: false, reason: "unknown_metric" }; + const resolved = await resolveRichMetricForUser(userId, args.metric); + if (!resolved.ok) { + return { present: false, reason: resolved.reason }; } - const metric = await withHrvFallback(userId, resolved); + const metric = resolved.metric; // Either an explicit `{from,to}` range (reach-back + filter) or one of the // five fixed trailing windows — both land on the same rollup reader. diff --git a/src/lib/modules/measurement-scope.ts b/src/lib/modules/measurement-scope.ts new file mode 100644 index 000000000..636c0d224 --- /dev/null +++ b/src/lib/modules/measurement-scope.ts @@ -0,0 +1,169 @@ +/** + * v1.30.22 — the ONE table of "which module owns which data domain". + * + * Before this file the ownership map lived as a private const inside the + * Coach snapshot builder, which made it reachable only by reads that route + * through `buildCoachSnapshot`. Three read families deliberately bypass the + * snapshot (the MCP rich reads, the correlations reader, the doctor-report + * aggregate) and therefore inherited no gating at all — the module toggle and + * the operator kill-switch above it were silently defeated on the one wire + * that egresses to a third party. + * + * The fix is to hold the ownership map at the READ level rather than at each + * call site: a read resolves its metric, asks this table who owns it, and + * gates. A future read family that resolves a metric therefore cannot land + * ungated without deliberately skipping the resolver. + * + * Two layers, matching how the metric registries are actually split: + * + * 1. `MODULE_SCOPED_SOURCES` — module key → the Coach scope-source tokens + * it owns. This is the map the snapshot builder has always used to + * narrow its sources; it now lives here and the builder imports it, so + * there is exactly one definition. + * 2. `METRIC_STATUS_MODULE_OWNERS` — the reviewed ownership entries for the + * metric-status-only ids, which carry no signal-registry scope of their + * own and so cannot be derived from layer 1. + * + * `moduleForMeasurementType()` unions both and is keyed on `MeasurementType` + * because that is what every resolution path in the rich reads produces and + * what the underlying rollup query actually reads. + */ +import type { MeasurementType } from "@/generated/prisma/client"; + +import { allSignals } from "@/lib/signals/registry"; +import type { ModuleKey } from "./registry"; + +/** + * Module key → the Coach scope-source tokens that module owns. + * + * `cycle` is intentionally absent: its block resolves through the fully + * two-layer cycle gate (`isCycleAvailableForUser` — the per-user toggle AND + * the operator server-wide kill-switch) wherever it is read, so folding it in + * here would double-gate. `coach` is the surface being narrated, not a data + * domain. `labs` / `achievements` / `insights` / `doctorReport` own no + * measurement-level data domain — they gate whole surfaces instead, which is + * why the correlations reader and the doctor-report aggregate gate on their + * module key directly rather than through this table. + */ +export const MODULE_SCOPED_SOURCES: Partial> = { + mood: ["mood"], + sleep: ["sleep"], + glucose: ["glucose"], + workouts: ["workouts"], + recovery: ["hrv", "resting_hr", "vo2_max"], + // The environment/exposure cluster owns exactly these sources (mirrors + // `CLUSTER_SOURCES.environment` in coach/clusters.ts). When the opt-in + // environment module is off, its audio-exposure / daylight / skin-temperature + // blocks are stripped so no disabled domain is ever read. + environment: [ + "audio_env", + "audio_headphone", + "audio_event", + "daylight", + "skin_temp", + ], +}; + +/** Inverse of {@link MODULE_SCOPED_SOURCES}: scope-source token → owning module. */ +const OWNER_BY_SOURCE: ReadonlyMap = (() => { + const out = new Map(); + for (const [key, sources] of Object.entries(MODULE_SCOPED_SOURCES)) { + for (const src of sources ?? []) { + out.set(src, key as ModuleKey); + } + } + return out; +})(); + +/** + * Reviewed ownership for the metric-status-only ids — the derived metrics that + * exist in the metric-status registry but carry `coachSnapshot: false` in the + * signal registry, so layer 1 cannot reach them. Each entry is a deliberate, + * human-named assignment: the metric is a direct product of that module's + * domain, so an account with the module off must not see it. + * + * - SLEEP_SCORE / BREATHING_DISTURBANCES — computed from the sleep record. + * - DAY_STRAIN / WORKOUT_STRAIN / CARDIO_LOAD — training-load metrics that + * exist only because workouts are tracked. + * - ANS_CHARGE / CARDIO_RECOVERY — autonomic recovery scores, the same + * domain `recovery` already owns via hrv / resting_hr / vo2_max. + * + * Deliberately NOT assigned an owner (see `UNSCOPED_REVIEWED_TYPES` below): + * ids whose domain no module actually claims. Guessing an owner there would + * hide a metric behind a toggle the user never associated with it. + */ +const METRIC_STATUS_MODULE_OWNERS: Partial> = + { + SLEEP_SCORE: "sleep", + BREATHING_DISTURBANCES: "sleep", + DAY_STRAIN: "workouts", + WORKOUT_STRAIN: "workouts", + CARDIO_LOAD: "workouts", + ANS_CHARGE: "recovery", + CARDIO_RECOVERY: "recovery", + }; + +/** + * Measurement types that resolve over the rich reads but that NO module owns — + * reviewed and deliberately ungated. They are core clinical / mobility figures + * that exist independently of any toggleable module (weight, pulse, blood + * pressure, BMI, body composition, gait, fall count, the walk-test metrics), + * so gating them would hide data the user never opted out of. + * + * This set exists so the structural test can distinguish "reviewed as + * unscoped" from "nobody has looked at this yet" — a new resolvable metric + * that is neither owned nor listed here fails the test. + */ +export const UNSCOPED_REVIEWED_TYPES: ReadonlySet = + new Set([ + "WEIGHT", + "PULSE", + "BODY_MASS_INDEX", + "BLOOD_PRESSURE_SYS", + "BLOOD_PRESSURE_DIA", + "BODY_FAT", + "WRIST_TEMPERATURE", + "ENERGY_EXPENDITURE_KJ", + "FALL_COUNT", + "SIX_MINUTE_WALK_DISTANCE", + "STAIR_ASCENT_SPEED", + "STAIR_DESCENT_SPEED", + "AVERAGE_HEART_RATE", + "MAX_HEART_RATE", + ]); + +/** + * Layer 1, derived once: `MeasurementType` → owning module, via the signal + * registry's `coachSnapshot.scope` token and {@link OWNER_BY_SOURCE}. Derived + * rather than hand-listed so a signal that changes its scope cannot drift out + * of sync with the snapshot builder's narrowing. + */ +const OWNER_BY_MEASUREMENT_TYPE: ReadonlyMap = + (() => { + const out = new Map(); + for (const sig of allSignals()) { + if (sig.kind !== "measurement") continue; + if (sig.surfaces.coachSnapshot === false) continue; + const owner = OWNER_BY_SOURCE.get(sig.surfaces.coachSnapshot.scope); + if (owner) out.set(sig.source.measurementType, owner); + } + for (const [type, owner] of Object.entries(METRIC_STATUS_MODULE_OWNERS)) { + if (owner) out.set(type as MeasurementType, owner); + } + return out; + })(); + +/** + * The module that owns `type`, or `null` when no module claims it. + * + * A `null` here means "read it ungated" — it is NOT a fail-open default for + * unknown input, because the caller has already resolved `type` through the + * closed metric resolver before asking. The structural test in + * `__tests__/measurement-scope.test.ts` pins the set of types that may + * legitimately answer `null`. + */ +export function moduleForMeasurementType( + type: MeasurementType, +): ModuleKey | null { + return OWNER_BY_MEASUREMENT_TYPE.get(type) ?? null; +} From bb41b1673ef87050d34e7e3945605f34d78bfeac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 03:18:25 +0200 Subject: [PATCH 2/5] Cover the module gates on the MCP export reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One suite per gate: the rich reads omit with an honest module_disabled reason, the correlations reader omits and touches no channel, the doctor-visit resource and prompt refuse without assembling the record, and the clinician share collapses to its documents-only state. Each asserts the operator availability path alongside the user toggle, and that the gate short-circuits before the engine runs rather than building the answer and withholding it. The structural test pins that every metric the rich reads can resolve has either an owning module or a written review decision, and is explicit in its own comment about what it cannot prove — it does not show that any read calls the gate. Writing it surfaced an unowned HRV_RMSSD, the fallback type an RMSSD-only account resolves to, which would have served recovery data the primary type refuses. --- .../doctor-report-module-backstop.test.ts | 100 ++++++++++ .../correlations-module-gate.test.ts | 104 ++++++++++ .../coach/__tests__/correlations-read.test.ts | 3 + .../__tests__/share-view-data.test.ts | 80 ++++++++ .../doctor-report-module-gate.test.ts | 148 ++++++++++++++ src/lib/mcp/__tests__/prompts.test.ts | 3 + src/lib/mcp/__tests__/resources.test.ts | 3 + .../__tests__/rich-reads-module-gate.test.ts | 180 ++++++++++++++++++ src/lib/mcp/__tests__/rich-reads.test.ts | 3 + .../__tests__/measurement-scope.test.ts | 136 +++++++++++++ src/lib/modules/measurement-scope.ts | 67 ++++++- 11 files changed, 822 insertions(+), 5 deletions(-) create mode 100644 src/lib/__tests__/doctor-report-module-backstop.test.ts create mode 100644 src/lib/ai/coach/__tests__/correlations-module-gate.test.ts create mode 100644 src/lib/mcp/__tests__/doctor-report-module-gate.test.ts create mode 100644 src/lib/mcp/__tests__/rich-reads-module-gate.test.ts create mode 100644 src/lib/modules/__tests__/measurement-scope.test.ts diff --git a/src/lib/__tests__/doctor-report-module-backstop.test.ts b/src/lib/__tests__/doctor-report-module-backstop.test.ts new file mode 100644 index 000000000..a9ace3b97 --- /dev/null +++ b/src/lib/__tests__/doctor-report-module-backstop.test.ts @@ -0,0 +1,100 @@ +/** + * v1.30.22 — the fail-closed backstop inside `collectDoctorReportData`. + * + * The MCP surfaces now gate before calling, but gating only at call sites is + * exactly the arrangement that failed: `loadFhirContext` has an internal + * backstop for the same aggregate and the routes above it still gate, while + * this function had none and three MCP callers plus a public share link + * forgot. The backstop makes the door itself refuse, so the next caller + * cannot reintroduce the leak by omission. + * + * It throws rather than returning an envelope because reaching it means a + * caller is missing its gate — a bug to surface, not a flow to serve. Callers + * that can degrade gracefully (the MCP resources, the clinician share) gate + * themselves first and never reach it. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const { prismaMock } = vi.hoisted(() => { + // Every model access throws, so any DB touch past the backstop is loud. + const boom = () => { + throw new Error("DB touched"); + }; + return { + prismaMock: new Proxy( + {}, + { + get: () => new Proxy({}, { get: () => boom }), + }, + ), + }; +}); +vi.mock("@/lib/db", () => ({ prisma: prismaMock })); +vi.mock("@/lib/modules/gate", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, resolveModuleMap: vi.fn() }; +}); + +import { collectDoctorReportData } from "../doctor-report-data"; +import { resolveModuleMap, MODULE_KEYS } from "@/lib/modules/gate"; + +const RANGE = { + start: new Date("2026-01-01T00:00:00.000Z"), + end: new Date("2026-04-01T00:00:00.000Z"), + days: 90, +}; + +/** Every module on, then apply the overrides. */ +function moduleMap(overrides: Record = {}) { + const out: Record = {}; + for (const key of MODULE_KEYS) out[key] = true; + return { ...out, ...overrides } as Record; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("collectDoctorReportData — doctorReport backstop", () => { + it("throws before any DB read when the module is off", async () => { + await expect( + collectDoctorReportData( + "u1", + RANGE, + // Injected rather than read, so the refusal is attributable to the + // module state and not to a DB stub. + { + moduleMap: moduleMap({ + doctorReport: false, + }) as never, + }, + ), + ).rejects.toThrow(/doctorReport/); + }); + + it("resolves the map itself when none is injected, and still refuses", async () => { + // The real callers do not inject; this is the path that actually protects + // the aggregate in production. + vi.mocked(resolveModuleMap).mockResolvedValue( + moduleMap({ doctorReport: false }) as never, + ); + + await expect(collectDoctorReportData("u1", RANGE)).rejects.toThrow( + /doctorReport/, + ); + expect(resolveModuleMap).toHaveBeenCalledWith("u1"); + }); + + it("does not refuse when the module is on", async () => { + // Positive control. The aggregate needs a full DB behind it, which this + // suite deliberately does not provide — so it will fail on the first + // query. What matters is that it gets THERE: the failure must be the DB + // stub, never the backstop, or the "off" assertions above would pass + // trivially for a function that always throws. + await expect( + collectDoctorReportData("u1", RANGE, { + moduleMap: moduleMap() as never, + }), + ).rejects.toThrow("DB touched"); + }); +}); diff --git a/src/lib/ai/coach/__tests__/correlations-module-gate.test.ts b/src/lib/ai/coach/__tests__/correlations-module-gate.test.ts new file mode 100644 index 000000000..dba6f3418 --- /dev/null +++ b/src/lib/ai/coach/__tests__/correlations-module-gate.test.ts @@ -0,0 +1,104 @@ +/** + * v1.30.22 — the `insights` gate on the correlations reader. + * + * `readCoachCorrelations` is reached from four places — the Coach + * `get_correlations` tool, the MCP `get_correlation` rich read, the + * per-metric "Coach read" strip, and the Coach snapshot — and only the REST + * sibling `/api/insights/correlations` ever gated. The reader also feeds on + * mood, symptom and compliance channels, so a mood-disabled account could + * receive pair rows naming mood with direction, lag, n and r. + * + * The gate lives in the reader, so these assertions cover every caller at + * once. That is the point of the placement: a fifth caller cannot reintroduce + * the gap by forgetting. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/modules/gate", () => ({ + isModuleEnabled: vi.fn(async () => true), +})); +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); + +const { prismaMock } = vi.hoisted(() => ({ + prismaMock: { + user: { findUnique: vi.fn() }, + measurement: { findMany: vi.fn() }, + moodEntry: { findMany: vi.fn() }, + userSourcePriority: { findMany: vi.fn() }, + }, +})); +vi.mock("@/lib/db", () => ({ prisma: prismaMock })); + +import { readCoachCorrelations } from "../tools/correlations-read"; +import { isModuleEnabled } from "@/lib/modules/gate"; + +const USER = "u1"; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(isModuleEnabled).mockImplementation(async () => true); + prismaMock.user.findUnique.mockResolvedValue({ timezone: "Europe/Berlin" }); + prismaMock.measurement.findMany.mockResolvedValue([]); + prismaMock.moodEntry.findMany.mockResolvedValue([]); + prismaMock.userSourcePriority.findMany.mockResolvedValue([]); +}); + +describe("readCoachCorrelations — insights module gate", () => { + it("gates on `insights`, not on a per-domain key", async () => { + await readCoachCorrelations(USER); + expect(isModuleEnabled).toHaveBeenCalledWith(USER, "insights"); + }); + + it("omits with the module OFF, and reads nothing at all", async () => { + vi.mocked(isModuleEnabled).mockImplementation(async () => false); + + const res = await readCoachCorrelations(USER); + + expect(res.present).toBe(false); + // Distinct from `no_data`: the assistant is told the domain is switched + // off rather than inferring the user has no correlatable history. + expect(res.reason).toBe("module_disabled"); + // The gate short-circuits before any channel is sourced. This is the + // load-bearing assertion — the reader draws on mood / symptom / + // compliance channels, so "computed then hid it" would still have pulled + // a disabled domain's rows into this process. + expect(prismaMock.measurement.findMany).not.toHaveBeenCalled(); + expect(prismaMock.moodEntry.findMany).not.toHaveBeenCalled(); + }); + + it("proceeds to the engine with the module ON", async () => { + const res = await readCoachCorrelations(USER); + // The synthetic fixture has no paired data, so the honest answer is a + // miss — but it must be the ENGINE's miss, reached after the real reads + // ran, not the gate's. + expect(prismaMock.measurement.findMany).toHaveBeenCalled(); + expect(res.reason).not.toBe("module_disabled"); + }); + + it("closes the operator kill-switch path, not only the user toggle", async () => { + // `isModuleEnabled` resolves `operatorAvailable && userEnabled`, so a + // server-wide operator disable arrives here as the same `false`. Asserted + // explicitly because it is the sharper case: the account holder cannot + // override an operator decision, so a leak past it is strictly worse than + // one past a personal preference. + vi.mocked(isModuleEnabled).mockImplementation( + async (_u: string, key: string) => key !== "insights", + ); + + const res = await readCoachCorrelations(USER); + + expect(res.present).toBe(false); + expect(res.reason).toBe("module_disabled"); + expect(prismaMock.measurement.findMany).not.toHaveBeenCalled(); + }); + + it("surfaces a gate failure instead of swallowing it as a miss", async () => { + // The reader's body is fail-soft (any throw degrades to + // `{ present: false }`) so a correlation hiccup never sinks a Coach turn. + // The gate sits OUTSIDE that try/catch on purpose: a broken gate must be + // loud, not silently indistinguishable from "no pattern found". + vi.mocked(isModuleEnabled).mockRejectedValue(new Error("gate down")); + + await expect(readCoachCorrelations(USER)).rejects.toThrow("gate down"); + }); +}); diff --git a/src/lib/ai/coach/__tests__/correlations-read.test.ts b/src/lib/ai/coach/__tests__/correlations-read.test.ts index 2ab4f2cb6..14b72f915 100644 --- a/src/lib/ai/coach/__tests__/correlations-read.test.ts +++ b/src/lib/ai/coach/__tests__/correlations-read.test.ts @@ -13,6 +13,9 @@ const intakeEventFindMany = vi.fn(); const illnessEpisodeFindMany = vi.fn(); const illnessDayLogFindMany = vi.fn(); const labResultFindMany = vi.fn(); +vi.mock("@/lib/modules/gate", () => ({ + isModuleEnabled: vi.fn(async () => true), +})); vi.mock("@/lib/db", () => ({ prisma: { measurement: { findMany: (a: unknown) => measurementFindMany(a) }, diff --git a/src/lib/clinician-share/__tests__/share-view-data.test.ts b/src/lib/clinician-share/__tests__/share-view-data.test.ts index f5dc1a1cf..5eac1b659 100644 --- a/src/lib/clinician-share/__tests__/share-view-data.test.ts +++ b/src/lib/clinician-share/__tests__/share-view-data.test.ts @@ -10,6 +10,9 @@ */ import { describe, it, expect, vi, beforeEach } from "vitest"; +vi.mock("@/lib/modules/gate", () => ({ + isModuleEnabled: vi.fn(async () => true), +})); vi.mock("@/lib/doctor-report-data", () => ({ collectDoctorReportData: vi.fn(), })); @@ -23,6 +26,7 @@ vi.mock("@/lib/db", () => ({ })); import { loadShareViewData } from "../share-view-data"; +import { isModuleEnabled } from "@/lib/modules/gate"; import { collectDoctorReportData } from "@/lib/doctor-report-data"; import { EMPTY_DOCTOR_REPORT_PREFS } from "@/lib/validations/doctor-report-prefs"; import { prisma } from "@/lib/db"; @@ -350,3 +354,79 @@ describe("loadShareViewData — grouped sections are folded, not silently defaul expect(opts.sections.weight).toBe(true); }); }); + +/** + * v1.30.22 — the owner's `doctorReport` module gates this surface too. + * + * Found while tracing every caller of the whole-record aggregate for the MCP + * fix: this link serves that same payload to an unauthenticated third party + * and never consulted the module key, so an owner (or an operator, via the + * availability switch ANDed above the user toggle) who turned the module off + * still had the full record served. + * + * Degrades to the share's OWN documents-only state rather than throwing: + * `documentOnly` is an existing, load-bearing privacy mode here (report + * `null`, aggregator never called), so a disabled module collapses the link to + * exactly the documents the owner attached — fail-closed for the health record + * without 500-ing a public link. + */ +describe("clinician share — owner doctorReport module gate", () => { + beforeEach(() => { + // Sibling of the suite above, so the outer reset does not reach here. + vi.clearAllMocks(); + vi.mocked(isModuleEnabled).mockImplementation(async () => true); + collect.mockResolvedValue({ patient: { displayName: "Shared record" } }); + }); + + it("aggregates normally with the module on", async () => { + collect.mockResolvedValue({ patient: { displayName: "Shared record" } }); + findDocs.mockResolvedValue([]); + + const res = await loadShareViewData( + ctx({ sectionsJson: { bp: true }, documentOnly: false }), + ); + + expect(res.documentOnly).toBe(false); + expect(res.report).not.toBeNull(); + expect(collect).toHaveBeenCalled(); + }); + + it("collapses to documents-only with the module off", async () => { + vi.mocked(isModuleEnabled).mockImplementation(async () => false); + findDocs.mockResolvedValue([]); + + const res = await loadShareViewData( + ctx({ sectionsJson: { bp: true }, documentOnly: false }), + ); + + expect(res.documentOnly).toBe(true); + expect(res.report).toBeNull(); + // Load-bearing: the aggregator is never called, so no health data leaves + // the DB at all — not built-then-withheld. + expect(collect).not.toHaveBeenCalled(); + }); + + it("gates on the OWNER's module state, not a viewer's", async () => { + // The link is public; there is no viewer session. The owner id must come + // from the frozen share context. + findDocs.mockResolvedValue([]); + await loadShareViewData( + ctx({ sectionsJson: { bp: true }, documentOnly: false }), + ); + expect(isModuleEnabled).toHaveBeenCalledWith("owner-1", "doctorReport"); + }); + + it("closes the operator kill-switch path too", async () => { + vi.mocked(isModuleEnabled).mockImplementation( + async (_u: string, key: string) => key !== "doctorReport", + ); + findDocs.mockResolvedValue([]); + + const res = await loadShareViewData( + ctx({ sectionsJson: { bp: true }, documentOnly: false }), + ); + + expect(res.documentOnly).toBe(true); + expect(collect).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/mcp/__tests__/doctor-report-module-gate.test.ts b/src/lib/mcp/__tests__/doctor-report-module-gate.test.ts new file mode 100644 index 000000000..a58a3e0ed --- /dev/null +++ b/src/lib/mcp/__tests__/doctor-report-module-gate.test.ts @@ -0,0 +1,148 @@ +/** + * v1.30.22 — the `doctorReport` gate on the whole-record aggregate. + * + * `collectDoctorReportData` consulted `resolveModuleMap` for its per-domain + * SECTIONS (mood / sleep / glucose / cycle / labs / recovery / workouts) but + * never for the `doctorReport` key that decides whether the aggregate may be + * assembled at all. Unlike `loadFhirContext` it carried no internal backstop, + * so it was left entirely to callers to remember — and the MCP surface did + * not: the fixed resource, its `{window}` template, and the + * `doctor_visit_summary` prompt each assembled the full record for an account + * with the module off. + * + * REFUSE, not omit. This is the whole record, so there is no honest partial + * answer; `/api/export/health-record` already answers 403 for exactly this + * state, and an empty summary invites the assistant to narrate "nothing on + * file" — a worse failure than an explicit unavailability. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/modules/gate", () => ({ + isModuleEnabled: vi.fn(async () => true), +})); +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/doctor-report-data", () => ({ + collectDoctorReportData: vi.fn(), +})); +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique: vi.fn(async () => null) }, + medication: { findMany: vi.fn(async () => []) }, + labResult: { findMany: vi.fn(async () => []) }, + }, +})); +vi.mock("@/lib/ai/coach/tools/inventory", () => ({ + buildCoachDataInventory: vi.fn(async () => ({})), +})); +vi.mock("@/lib/ai/coach/tools/executor", () => ({ + executeCoachTool: vi.fn(async () => ({ present: false })), +})); +vi.mock("@/lib/insights/derived/baseline", () => ({ + loadBaselineProfile: vi.fn(async () => ({})), +})); +vi.mock("@/lib/insights/derived-briefing", () => ({ + detectDerivedBriefingSignals: vi.fn(async () => null), +})); +vi.mock("@/lib/insights/illness-cycle-briefing", () => ({ + buildBriefingIllnessCycleContext: vi.fn(async () => null), +})); +vi.mock("@/lib/tz/resolver", () => ({ + resolveUserTimezone: vi.fn(async () => "Europe/Berlin"), +})); +vi.mock("@/lib/mcp/rich-reads", () => ({ + metricStatusDiscoveryRows: vi.fn(async () => []), + MCP_METRIC_STATUS_DISCOVERY: [], + MCP_CLINICAL_SIGNALS: [], + getNutrients: vi.fn(async () => ({ present: false })), + getLabHistory: vi.fn(async () => ({ present: false })), + resolveRichMetric: vi.fn(() => null), +})); + +import { MCP_RESOURCES, MCP_RESOURCE_TEMPLATES } from "../resources"; +import { MCP_PROMPTS } from "../prompts"; +import { isModuleEnabled } from "@/lib/modules/gate"; +import { collectDoctorReportData } from "@/lib/doctor-report-data"; +import type { McpAuthContext } from "../auth"; + +const ctx = { userId: "u1" } as McpAuthContext; + +/** Minimal aggregate the visit summariser can reduce without throwing. */ +const REPORT_FIXTURE = { + period: { + days: 90, + since: "2026-03-29T00:00:00.000Z", + start: "2026-03-29T00:00:00.000Z", + end: "2026-06-27T00:00:00.000Z", + }, + patient: { username: "tester" }, + stats: { WEIGHT: { avg: 80, min: 79, max: 82, count: 30, latest: 80 } }, + compliance: {}, + medications: [], + labs: [], +}; + +const fixedResource = MCP_RESOURCES.find( + (r) => r.uri === "healthlog://report/doctor-visit", +)!; +const templateResource = MCP_RESOURCE_TEMPLATES.find( + (t) => t.uriTemplate === "healthlog://report/doctor-visit/{window}", +)!; +const prompt = MCP_PROMPTS.find((p) => p.name === "doctor_visit_summary")!; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(isModuleEnabled).mockImplementation(async () => true); + vi.mocked(collectDoctorReportData).mockResolvedValue( + REPORT_FIXTURE as unknown as Awaited< + ReturnType + >, + ); +}); + +describe("doctor-visit aggregate — doctorReport module gate", () => { + describe.each([ + { + name: "fixed resource", + run: () => fixedResource.read(ctx, {}), + }, + { + name: "windowed template", + run: () => templateResource.read(ctx, { window: "last30days" }), + }, + { + name: "doctor_visit_summary prompt", + run: () => prompt.run(ctx, {}), + }, + ])("$name", ({ run }) => { + it("serves with the module ON", async () => { + await expect(run()).resolves.toBeDefined(); + expect(collectDoctorReportData).toHaveBeenCalled(); + }); + + it("REFUSES with the module OFF and never assembles the record", async () => { + vi.mocked(isModuleEnabled).mockImplementation(async () => false); + + await expect(run()).rejects.toThrow(/doctorReport/); + // The refusal must precede assembly — the whole point is that the + // record is never built, not that it is built and then withheld. + expect(collectDoctorReportData).not.toHaveBeenCalled(); + }); + + it("refuses on the operator kill-switch path too", async () => { + // `isModuleEnabled` ANDs operator availability over the user toggle, so + // an operator disabling `doctorReport` server-wide arrives as the same + // `false`. The sharper case: no per-account setting can override it. + vi.mocked(isModuleEnabled).mockImplementation( + async (_u: string, key: string) => key !== "doctorReport", + ); + + await expect(run()).rejects.toThrow(/doctorReport/); + expect(collectDoctorReportData).not.toHaveBeenCalled(); + }); + }); + + it("gates on doctorReport specifically", async () => { + await fixedResource.read(ctx, {}); + expect(isModuleEnabled).toHaveBeenCalledWith("u1", "doctorReport"); + }); +}); diff --git a/src/lib/mcp/__tests__/prompts.test.ts b/src/lib/mcp/__tests__/prompts.test.ts index b3d3f9203..b5dc70158 100644 --- a/src/lib/mcp/__tests__/prompts.test.ts +++ b/src/lib/mcp/__tests__/prompts.test.ts @@ -1,5 +1,8 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +vi.mock("@/lib/modules/gate", () => ({ + isModuleEnabled: vi.fn(async () => true), +})); vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); vi.mock("@/lib/doctor-report-data", () => ({ collectDoctorReportData: vi.fn(), diff --git a/src/lib/mcp/__tests__/resources.test.ts b/src/lib/mcp/__tests__/resources.test.ts index cf55c8b57..f65313dc1 100644 --- a/src/lib/mcp/__tests__/resources.test.ts +++ b/src/lib/mcp/__tests__/resources.test.ts @@ -1,5 +1,8 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +vi.mock("@/lib/modules/gate", () => ({ + isModuleEnabled: vi.fn(async () => true), +})); vi.mock("@/lib/db", () => ({ prisma: { user: { findUnique: vi.fn() }, diff --git a/src/lib/mcp/__tests__/rich-reads-module-gate.test.ts b/src/lib/mcp/__tests__/rich-reads-module-gate.test.ts new file mode 100644 index 000000000..be6811fc2 --- /dev/null +++ b/src/lib/mcp/__tests__/rich-reads-module-gate.test.ts @@ -0,0 +1,180 @@ +/** + * v1.30.22 — the module gate on the rollup-backed rich reads. + * + * These three reads deliberately bypass `buildCoachSnapshot`, which is where + * the module narrowing lives for every Coach-routed read. They therefore + * inherited no gate at all: `get_metric_series` honestly reported + * `{ present: false }` for a glucose-disabled account while + * `get_metric_baseline` handed back the median, the MAD band and today's + * placement for the same metric — on the one wire that egresses to a + * third-party assistant. + * + * Both layers matter and both are asserted here. `isModuleEnabled` resolves + * `operatorAvailable(key) && userEnabled(key)`, so a `false` from it is the + * OPERATOR kill-switch just as much as the user toggle — the sharper case, + * because a leak there defeats a server-wide decision the account holder + * cannot override. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/modules/gate", () => ({ + isModuleEnabled: vi.fn(async () => true), +})); +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/ai/coach/tools/correlations-read", () => ({ + readCoachCorrelations: vi.fn(async () => ({ present: false })), +})); +vi.mock("@/lib/insights/derived/coach-read", () => ({ + buildCoachReadStrip: vi.fn(), +})); +vi.mock("@/lib/rollups/measurement-read-wmy", async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, readBestGranularityRollups: vi.fn() }; +}); +const { labResult, measurement } = vi.hoisted(() => ({ + labResult: { findMany: vi.fn() }, + measurement: { count: vi.fn(async () => 0), groupBy: vi.fn(async () => []) }, +})); +vi.mock("@/lib/db", () => ({ prisma: { labResult, measurement } })); + +import { + compareMetric, + getMetricBaseline, + detectChangepoints, + metricStatusDiscoveryRows, + MCP_METRIC_STATUS_DISCOVERY, +} from "../rich-reads"; +import { isModuleEnabled } from "@/lib/modules/gate"; +import { buildCoachReadStrip } from "@/lib/insights/derived/coach-read"; +import { readBestGranularityRollups } from "@/lib/rollups/measurement-read-wmy"; +import type { ModuleKey } from "@/lib/modules/gate"; + +const USER = "u1"; + +/** A bucket sequence dense enough for every read to produce a real answer. */ +function buckets(n = 60) { + const start = Date.UTC(2026, 0, 1); + return { + granularity: "DAY" as const, + rows: Array.from({ length: n }, (_, i) => ({ + bucketStart: new Date(start + i * 86_400_000), + // A clean level shift halfway so `detect_changepoints` has something to + // find — the read must be capable of a `present: true` answer, or a + // `present: false` under the gate would prove nothing. + sum: i < n / 2 ? 100 : 160, + count: 1, + min: i < n / 2 ? 100 : 160, + max: i < n / 2 ? 100 : 160, + })), + }; +} + +/** Drive the gate: every module on except the named one. */ +function disableModule(key: ModuleKey) { + vi.mocked(isModuleEnabled).mockImplementation( + async (_userId: string, moduleKey: ModuleKey) => moduleKey !== key, + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(isModuleEnabled).mockImplementation(async () => true); + measurement.count.mockResolvedValue(0); + measurement.groupBy.mockResolvedValue([]); + vi.mocked(readBestGranularityRollups).mockResolvedValue( + buckets() as unknown as Awaited< + ReturnType + >, + ); + vi.mocked(buildCoachReadStrip).mockResolvedValue({ + baseline: { median: 100, low: 90, high: 110 }, + learning: false, + latest: { value: 105, at: new Date().toISOString() }, + } as unknown as Awaited>); +}); + +describe("rich reads — per-domain module gate", () => { + // `glucose` → BLOOD_GLUCOSE is the pairing the audit named explicitly: the + // metric where the honest `get_metric_series` and the leaking + // `get_metric_baseline` disagreed for the same account. + describe.each([ + { metric: "glucose", module: "glucose" as ModuleKey }, + { metric: "sleep", module: "sleep" as ModuleKey }, + { metric: "hrv", module: "recovery" as ModuleKey }, + { metric: "sleep_score", module: "sleep" as ModuleKey }, + { metric: "day_strain", module: "workouts" as ModuleKey }, + ])("$metric (owned by $module)", ({ metric, module }) => { + it("get_metric_baseline answers with the module ON", async () => { + const on = await getMetricBaseline(USER, { metric }); + expect(on.present).toBe(true); + }); + + it("get_metric_baseline omits with the module OFF", async () => { + disableModule(module); + const off = await getMetricBaseline(USER, { metric }); + expect(off.present).toBe(false); + expect(off.reason).toBe("module_disabled"); + // The gate must short-circuit BEFORE the engine runs — a read that + // computes the band and then hides it has still loaded the data. + expect(buildCoachReadStrip).not.toHaveBeenCalled(); + }); + + it("compare_metric omits with the module OFF", async () => { + disableModule(module); + const off = await compareMetric(USER, { + metric, + window: "last30days", + windowB: "last90days", + }); + expect(off.present).toBe(false); + expect(off.reason).toBe("module_disabled"); + expect(readBestGranularityRollups).not.toHaveBeenCalled(); + }); + + it("detect_changepoints omits with the module OFF", async () => { + disableModule(module); + const off = await detectChangepoints(USER, { metric }); + expect(off.present).toBe(false); + expect(off.reason).toBe("module_disabled"); + expect(readBestGranularityRollups).not.toHaveBeenCalled(); + }); + }); + + it("refuses the whole comparison when only side B is gated", async () => { + // A one-sided degrade would answer the question anyway using the ungated + // side, so the gated side must sink the entire call. + disableModule("glucose"); + const res = await compareMetric(USER, { + metric: "weight", + metricB: "glucose", + }); + expect(res.present).toBe(false); + expect(res.reason).toBe("module_disabled_b"); + }); + + it("leaves a metric no module owns ungated", async () => { + // Weight/pulse/BP are core clinical figures with no owning module; gating + // them would hide data the user never opted out of. Turning every module + // off must not touch them. + vi.mocked(isModuleEnabled).mockImplementation(async () => false); + const res = await getMetricBaseline(USER, { metric: "weight" }); + expect(res.present).toBe(true); + }); + + it("drops a gated metric from the discovery listing", async () => { + // Discovery is the assistant's map of what exists. Advertising a metric + // that the fetch will then refuse both leaks that the domain is tracked + // and sends the assistant down a dead end. + disableModule("sleep"); + const rows = await metricStatusDiscoveryRows(USER); + const keys = rows.map((r) => r.metric); + expect(keys).not.toContain("SLEEP_SCORE"); + expect(keys).not.toContain("BREATHING_DISTURBANCES"); + // …while an unowned metric in the same listing survives. + expect(MCP_METRIC_STATUS_DISCOVERY.map((s) => s.key)).toContain( + "FALL_COUNT", + ); + expect(keys).toContain("FALL_COUNT"); + }); +}); diff --git a/src/lib/mcp/__tests__/rich-reads.test.ts b/src/lib/mcp/__tests__/rich-reads.test.ts index f786b14cc..e6a4f1460 100644 --- a/src/lib/mcp/__tests__/rich-reads.test.ts +++ b/src/lib/mcp/__tests__/rich-reads.test.ts @@ -1,6 +1,9 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; // --- Engine mocks hoisted before importing the module under test. --- +vi.mock("@/lib/modules/gate", () => ({ + isModuleEnabled: vi.fn(async () => true), +})); vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); vi.mock("@/lib/ai/coach/tools/correlations-read", () => ({ readCoachCorrelations: vi.fn(), diff --git a/src/lib/modules/__tests__/measurement-scope.test.ts b/src/lib/modules/__tests__/measurement-scope.test.ts new file mode 100644 index 000000000..c9bc9eda8 --- /dev/null +++ b/src/lib/modules/__tests__/measurement-scope.test.ts @@ -0,0 +1,136 @@ +/** + * v1.30.22 — structural guard on the module→data ownership table. + * + * The three MCP leaks all had one root cause: the layer assumed every read + * inherits gating from `buildCoachSnapshot`, which is true for the + * Coach-routed tools and false for the families that deliberately bypass it. + * Fixing the three call sites alone would leave the assumption in place, so + * the ownership map moved to a shared table and the rich reads gate off it. + * + * WHAT THIS TEST FREEZES + * + * 1. Every metric the rich reads can resolve has been LOOKED AT: it either + * has an owning module or is named in `UNSCOPED_REVIEWED_TYPES`. A new + * metric that lands in the resolver without either fails here, which is + * the specific way finding 2 was able to happen — metrics became + * resolvable over MCP without anyone deciding who owned them. + * 2. The table stays consistent with the Coach snapshot's own narrowing, so + * the MCP wire and the Coach prompt cannot drift into disagreeing about + * which module owns which domain. + * 3. `UNSCOPED_REVIEWED_TYPES` stays a review record, not a dumping ground: + * an entry that later gains an owner must leave the list. + * + * WHAT IT CANNOT PROVE — read this before trusting it + * + * - It does NOT prove any read calls the gate. A future `getMetricSummary` + * that calls `resolveRichMetric` directly instead of the module-aware + * resolver would leak exactly as before and this test would stay green. + * Only the per-read tests in `mcp/__tests__/rich-reads-module-gate.test.ts` + * cover that, and only for the reads that exist today. There is no + * compile-time barrier stopping a new caller from reaching the ungated + * resolver — `resolveRichMetric` is still exported because + * `build-efficacy` and the tests use it for pure resolvability checks. + * - It does NOT prove the OWNERSHIP assignments are correct, only that they + * exist. Mapping SLEEP_SCORE to `sleep` is a human judgement; if it truly + * belonged to `recovery`, this test would be just as green. + * - It says nothing about the surfaces that gate on a module key directly + * rather than through a metric (`insights` for correlations, `doctorReport` + * for the aggregate). Those are covered by their own tests. + */ +import { describe, it, expect } from "vitest"; + +import { + MODULE_SCOPED_SOURCES, + UNSCOPED_REVIEWED_TYPES, + moduleForMeasurementType, +} from "../measurement-scope"; +import { MODULE_KEYS, type ModuleKey } from "../gate"; +import { + MCP_METRIC_STATUS_DISCOVERY, + MCP_CLINICAL_SIGNALS, + resolveRichMetric, +} from "@/lib/mcp/rich-reads"; +import { METRIC_STATUS_IDS } from "@/lib/insights/metric-status-registry"; + +/** + * Every `MeasurementType` reachable through the rich-read resolver, collected + * the way an assistant would actually reach them: the discovery listings plus + * every metric-status id the resolver accepts by exact id. + */ +function resolvableTypes(): Set { + const out = new Set(); + for (const sig of MCP_METRIC_STATUS_DISCOVERY) out.add(sig.measurementType); + for (const sig of MCP_CLINICAL_SIGNALS) out.add(sig.measurementType); + for (const id of METRIC_STATUS_IDS) { + const m = resolveRichMetric(id); + if (m) out.add(m.measurementType); + } + for (const name of ["weight", "pulse", "bmi", "glucose", "sleep", "hrv"]) { + const m = resolveRichMetric(name); + if (m) out.add(m.measurementType); + } + return out; +} + +describe("module→data ownership table", () => { + it("has an owner or a written review decision for every resolvable metric", () => { + const unreviewed = [...resolvableTypes()].filter( + (t) => + moduleForMeasurementType(t as never) === null && + !UNSCOPED_REVIEWED_TYPES.has(t as never), + ); + + // A failure here is not a bug to suppress: a metric became readable over + // MCP without anyone deciding whether a module owns it. Either add it to + // `METRIC_STATUS_MODULE_OWNERS` or record it in `UNSCOPED_REVIEWED_TYPES` + // with a reason. + expect(unreviewed).toEqual([]); + }); + + it("never assigns a metric to a module key that does not exist", () => { + for (const type of resolvableTypes()) { + const owner = moduleForMeasurementType(type as never); + if (owner) expect(MODULE_KEYS).toContain(owner); + } + }); + + it("keeps the reviewed-unscoped list free of metrics that now have an owner", () => { + const stale = [...UNSCOPED_REVIEWED_TYPES].filter( + (t) => moduleForMeasurementType(t) !== null, + ); + expect(stale).toEqual([]); + }); + + it("scopes only module keys that actually exist", () => { + for (const key of Object.keys(MODULE_SCOPED_SOURCES)) { + expect(MODULE_KEYS).toContain(key as ModuleKey); + } + }); + + it("resolves the domains the audit named to the modules that own them", () => { + // Spot-checks with real consequences: these are the exact pairings where + // `get_metric_series` (gated, via the snapshot) and `get_metric_baseline` + // (ungated, before this change) disagreed for the same account. + expect(moduleForMeasurementType("BLOOD_GLUCOSE")).toBe("glucose"); + expect(moduleForMeasurementType("SLEEP_DURATION")).toBe("sleep"); + expect(moduleForMeasurementType("HEART_RATE_VARIABILITY")).toBe("recovery"); + expect(moduleForMeasurementType("HRV_RMSSD")).toBe("recovery"); + expect(moduleForMeasurementType("SLEEP_SCORE")).toBe("sleep"); + expect(moduleForMeasurementType("BREATHING_DISTURBANCES")).toBe("sleep"); + expect(moduleForMeasurementType("DAY_STRAIN")).toBe("workouts"); + expect(moduleForMeasurementType("WORKOUT_STRAIN")).toBe("workouts"); + expect(moduleForMeasurementType("CARDIO_LOAD")).toBe("workouts"); + expect(moduleForMeasurementType("ANS_CHARGE")).toBe("recovery"); + expect(moduleForMeasurementType("CARDIO_RECOVERY")).toBe("recovery"); + }); + + it("leaves the core clinical figures unowned on purpose", () => { + // Gating these would hide data behind a toggle the user never associated + // with them. Pinned so a future broad-brush "gate everything" change has + // to argue with a test. + expect(moduleForMeasurementType("WEIGHT")).toBeNull(); + expect(moduleForMeasurementType("PULSE")).toBeNull(); + expect(moduleForMeasurementType("BLOOD_PRESSURE_SYS")).toBeNull(); + expect(moduleForMeasurementType("BODY_MASS_INDEX")).toBeNull(); + }); +}); diff --git a/src/lib/modules/measurement-scope.ts b/src/lib/modules/measurement-scope.ts index 636c0d224..58d4b58ac 100644 --- a/src/lib/modules/measurement-scope.ts +++ b/src/lib/modules/measurement-scope.ts @@ -101,14 +101,38 @@ const METRIC_STATUS_MODULE_OWNERS: Partial> = CARDIO_LOAD: "workouts", ANS_CHARGE: "recovery", CARDIO_RECOVERY: "recovery", + // The HRV fallback type. `resolveRichMetricForUser` swaps HRV to its RMSSD + // fallback for an account that only has ring/strap nightly RMSSD rows, so + // leaving this unowned would have let the fallback path serve exactly the + // recovery data the primary type refuses. Caught by the structural test. + HRV_RMSSD: "recovery", }; /** * Measurement types that resolve over the rich reads but that NO module owns — - * reviewed and deliberately ungated. They are core clinical / mobility figures - * that exist independently of any toggleable module (weight, pulse, blood - * pressure, BMI, body composition, gait, fall count, the walk-test metrics), - * so gating them would hide data the user never opted out of. + * reviewed and deliberately ungated. + * + * These are not an oversight and not a backlog: they are the domains the + * module model has never claimed. The Coach snapshot does not narrow any of + * them either (none of their scope tokens appears in + * {@link MODULE_SCOPED_SOURCES}), so gating them here would make the MCP wire + * STRICTER than the app and the Coach — hiding data behind a toggle the user + * never associated with it. Grouped by why: + * + * - Core clinical figures every account carries: weight, pulse, blood + * pressure, BMI, body temperature. + * - Body composition: fat / lean / muscle / bone / water / visceral. + * - Activity volume: steps, distance, flights, active energy. Owned by no + * module — `workouts` gates workout SESSIONS, not ambient movement. + * - Gait and mobility, incl. the fall / walk-test / stair metrics. + * - Cardio-vascular readings with no module home: SpO2, respiratory rate, + * walking HR, pulse-wave velocity, vascular age, avg/max HR. + * - v1.25 clinical signals: grip strength, pain NRS, waist measures. + * - WRIST_TEMPERATURE and ENERGY_EXPENDITURE_KJ: plausible arguments exist + * for `sleep` and `workouts` respectively, but neither module claims them + * anywhere else in the tree, and guessing an owner would hide a metric + * behind an unrelated toggle. Left unowned deliberately rather than + * assigned on a hunch. * * This set exists so the structural test can distinguish "reviewed as * unscoped" from "nobody has looked at this yet" — a new resolvable metric @@ -116,20 +140,53 @@ const METRIC_STATUS_MODULE_OWNERS: Partial> = */ export const UNSCOPED_REVIEWED_TYPES: ReadonlySet = new Set([ + // Core clinical. "WEIGHT", "PULSE", "BODY_MASS_INDEX", "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", + "BODY_TEMPERATURE", + // Body composition. "BODY_FAT", - "WRIST_TEMPERATURE", + "FAT_MASS", + "FAT_FREE_MASS", + "LEAN_BODY_MASS", + "MUSCLE_MASS", + "BONE_MASS", + "TOTAL_BODY_WATER", + "VISCERAL_FAT", + // Ambient activity volume (not workout sessions). + "ACTIVITY_STEPS", + "WALKING_RUNNING_DISTANCE", + "FLIGHTS_CLIMBED", + "ACTIVE_ENERGY_BURNED", "ENERGY_EXPENDITURE_KJ", + // Gait / mobility. + "WALKING_SPEED", + "WALKING_STEADINESS", + "WALKING_ASYMMETRY", + "WALKING_DOUBLE_SUPPORT", + "WALKING_STEP_LENGTH", "FALL_COUNT", "SIX_MINUTE_WALK_DISTANCE", "STAIR_ASCENT_SPEED", "STAIR_DESCENT_SPEED", + // Cardio-vascular with no module home. + "OXYGEN_SATURATION", + "RESPIRATORY_RATE", + "WALKING_HEART_RATE_AVERAGE", + "PULSE_WAVE_VELOCITY", + "VASCULAR_AGE", "AVERAGE_HEART_RATE", "MAX_HEART_RATE", + // v1.25 clinical signals. + "GRIP_STRENGTH", + "PAIN_NRS", + "WAIST_CIRCUMFERENCE", + "WAIST_TO_HEIGHT", + // Temperature with a plausible but unclaimed owner. + "WRIST_TEMPERATURE", ]); /** From 8e94f6cb2048e73f69bfd850cf65cc83d5ff8a66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 03:22:19 +0200 Subject: [PATCH 3/5] Match the doctor-visit resource read signature in its gate test --- src/lib/mcp/__tests__/doctor-report-module-gate.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/mcp/__tests__/doctor-report-module-gate.test.ts b/src/lib/mcp/__tests__/doctor-report-module-gate.test.ts index a58a3e0ed..c85a7f8a7 100644 --- a/src/lib/mcp/__tests__/doctor-report-module-gate.test.ts +++ b/src/lib/mcp/__tests__/doctor-report-module-gate.test.ts @@ -103,7 +103,7 @@ describe("doctor-visit aggregate — doctorReport module gate", () => { describe.each([ { name: "fixed resource", - run: () => fixedResource.read(ctx, {}), + run: () => fixedResource.read(ctx), }, { name: "windowed template", @@ -142,7 +142,7 @@ describe("doctor-visit aggregate — doctorReport module gate", () => { }); it("gates on doctorReport specifically", async () => { - await fixedResource.read(ctx, {}); + await fixedResource.read(ctx); expect(isModuleEnabled).toHaveBeenCalledWith("u1", "doctorReport"); }); }); From 61d0d01cf1af7ccebb2713ae88a7ae421214c43b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 03:23:53 +0200 Subject: [PATCH 4/5] fix(jobs): make the queue-policy reconcile observable The reconcile is the only thing that carries a policy onto a queue that already exists, so whether it ran is the difference between the previous release working and doing nothing on an upgraded instance. It reported that through workerLog at info level, which is deliberately silent - so the intended deploy signal could never appear, and the change was not verifiable in production. Emit a boot wide event instead, on every boot including the no-op one: a reconciled count of zero on a later boot confirms an earlier boot already migrated everything, whereas silence is indistinguishable from the reconcile never running. --- src/lib/jobs/reminder/registrar-shared.ts | 31 ++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/src/lib/jobs/reminder/registrar-shared.ts b/src/lib/jobs/reminder/registrar-shared.ts index bb0e11eae..ceb84ea2b 100644 --- a/src/lib/jobs/reminder/registrar-shared.ts +++ b/src/lib/jobs/reminder/registrar-shared.ts @@ -21,6 +21,8 @@ import { PgBoss } from "pg-boss"; import { prisma } from "@/lib/db"; import { workerLog } from "./shared"; +import { withBackgroundEvent } from "@/lib/logging/background"; +import { annotate } from "@/lib/logging/context"; /** * Cron schedule tuple: `[queueName, cronExpression, sendOptions?]`. The @@ -107,6 +109,9 @@ export type QueuePolicyTable = Readonly>; async function reconcileQueuePolicies( policies: QueuePolicyTable, ): Promise { + const reconciled: string[] = []; + const failed: string[] = []; + for (const [name, { policy }] of Object.entries(policies)) { try { const changed = await prisma.$executeRaw` @@ -114,15 +119,35 @@ async function reconcileQueuePolicies( SET policy = ${policy}, updated_on = now() WHERE name = ${name} AND policy IS DISTINCT FROM ${policy} `; - if (changed > 0) { - workerLog("info", `[queue-policy] reconciled ${name} to ${policy}`); - } + if (changed > 0) reconciled.push(`${name}=${policy}`); } catch (err) { // Never fail worker boot on a reconcile miss: the queue still works, it // just keeps whatever de-duplication semantics it already had. + failed.push(name); workerLog("error", `[queue-policy] failed to reconcile ${name}`, err); } } + + // The reconcile is the ONLY thing that carries a policy onto a queue that + // already exists — `createQueue` cannot, because `create_queue()` ends in + // ON CONFLICT DO NOTHING. So whether it ran is the difference between this + // fix working and doing nothing at all on an upgraded instance, and that has + // to be observable. `workerLog("info", …)` is deliberately silent, which + // made the intended signal impossible to see; a wide event reaches stdout + // and the log store like every other boot task. + // + // Emitted on EVERY boot, including the no-op one: "reconciled 0" on a second + // boot is the confirmation that the first boot already migrated everything. + // Silence would be indistinguishable from the reconcile never running. + await withBackgroundEvent("worker.boot.queue_policy_reconcile", async () => { + annotate({ + meta: { + queue_policy_reconciled_count: reconciled.length, + queue_policy_reconciled: reconciled.join(",") || "none", + queue_policy_failed_count: failed.length, + }, + }); + }); } /** From 4b60852f9f33ed878a7c392d798717a7472a61f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 03:27:30 +0200 Subject: [PATCH 5/5] =?UTF-8?q?chore(release):=20v1.30.23=20=E2=80=94=20mo?= =?UTF-8?q?dule=20switches=20hold=20on=20the=20connector=20wire?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 11 +++++++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- public/sw.js | 2 +- 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 664a78634..6ff2d3f2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ ## [Unreleased] +## [1.30.23] — 2026-07-19 + +Switching a module off holds on the connector wire too. + +- **Assistant connectors honour your module switches.** Three read paths reached the data directly instead of through the gated builder, so with a module off a connected assistant could still read the whole-record doctor summary, and could still get baselines, comparisons and level-shift detection for a metric whose own series correctly reported no data. Correlations were reachable the same way — and through the in-app assistant as well, which had the identical gap. All of them now check first. +- **A shared clinician link served the whole-record summary without checking that module** — the same aggregate, reachable by someone who is not signed in at all. It now degrades to the documents-only view instead. +- **The function that assembles that summary now refuses on its own** when the module is off, rather than trusting each of its five callers to remember. Three of them had not. +- **The confirmation that background queues were migrated is now visible.** The previous release reported it at a log level that is deliberately discarded, so there was no way to tell from the outside whether it had taken effect. It is now a boot entry that appears on every start, including one where nothing needed migrating. + +No breaking changes. + ## [1.30.22] — 2026-07-19 Background work stops piling up on itself. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 39d5b39f8..b9a809224 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.30.22 + version: 1.30.23 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index f9459206b..ecd0f2440 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.30.22", + "version": "1.30.23", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index 31400a3fe..4e3ac9a32 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.30.22"; + /* @sw-version-fallback */ "v1.30.23"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`