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

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