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] 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; +}