mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +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
@@ -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