mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
99dc3b6915
commit
fa2b9531f7
8 files changed
+394
-37
No files matched your search
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ?? {}),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user