Gate the MCP export reads on the module map

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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-19 03:09:29 +02:00
1 parent 99dc3b6915
commit fa2b9531f7
8 files changed
+394 -37

No files matched your search

+9 -19
View File
@@ -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<CoachDataCluster> = new Set<CoachDataCluster>([
* 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<Record<ModuleKey, CoachScopeSource[]>> =
{
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<ModuleKey, CoachScopeSource[]>
>;
/**
* Build the Coach prompt snapshot for `userId`. Always uses
@@ -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<CoachCorrelationsResult> {
// 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({
+22 -1
View File
@@ -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
+26
View File
@@ -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 ?? {}),
+20
View File
@@ -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);
+21
View File
@@ -282,6 +282,27 @@ async function readDoctorVisit(
ctx: McpAuthContext,
window: string,
): Promise<unknown> {
// 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);
+103 -17
View File
@@ -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<CompareMetricResult> {
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<MetricBaselineResult> {
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<ChangepointsResult> {
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.
+169
View File
@@ -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<Record<ModuleKey, string[]>> = {
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<string, ModuleKey> = (() => {
const out = new Map<string, ModuleKey>();
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<Record<MeasurementType, ModuleKey>> =
{
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<MeasurementType> =
new Set<MeasurementType>([
"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<MeasurementType, ModuleKey> =
(() => {
const out = new Map<MeasurementType, ModuleKey>();
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;
}