Merge pull request #547 from MBombeck/release/v1.30.23

release/v1.30.23 — switching a module off holds on the connector wire too
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-19 03:40:17 +02:00
commit a6851b7e80
23 files changed
+1253 -43

No files matched your search

+11
View File
@@ -2,6 +2,17 @@
## [Unreleased]
## [1.30.23] — 2026-07-19
Switching a module off holds on the connector wire too.
- **Assistant connectors honour your module switches.** Three read paths reached the data directly instead of through the gated builder, so with a module off a connected assistant could still read the whole-record doctor summary, and could still get baselines, comparisons and level-shift detection for a metric whose own series correctly reported no data. Correlations were reachable the same way — and through the in-app assistant as well, which had the identical gap. All of them now check first.
- **A shared clinician link served the whole-record summary without checking that module** — the same aggregate, reachable by someone who is not signed in at all. It now degrades to the documents-only view instead.
- **The function that assembles that summary now refuses on its own** when the module is off, rather than trusting each of its five callers to remember. Three of them had not.
- **The confirmation that background queues were migrated is now visible.** The previous release reported it at a log level that is deliberately discarded, so there was no way to tell from the outside whether it had taken effect. It is now a boot entry that appears on every start, including one where nothing needed migrating.
No breaking changes.
## [1.30.22] — 2026-07-19
Background work stops piling up on itself.
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.30.22
version: 1.30.23
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.30.22",
"version": "1.30.23",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.30.22";
/* @sw-version-fallback */ "v1.30.23";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
@@ -0,0 +1,100 @@
/**
* v1.30.22 — the fail-closed backstop inside `collectDoctorReportData`.
*
* The MCP surfaces now gate before calling, but gating only at call sites is
* exactly the arrangement that failed: `loadFhirContext` has an internal
* backstop for the same aggregate and the routes above it still gate, while
* this function had none and three MCP callers plus a public share link
* forgot. The backstop makes the door itself refuse, so the next caller
* cannot reintroduce the leak by omission.
*
* It throws rather than returning an envelope because reaching it means a
* caller is missing its gate — a bug to surface, not a flow to serve. Callers
* that can degrade gracefully (the MCP resources, the clinician share) gate
* themselves first and never reach it.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
const { prismaMock } = vi.hoisted(() => {
// Every model access throws, so any DB touch past the backstop is loud.
const boom = () => {
throw new Error("DB touched");
};
return {
prismaMock: new Proxy(
{},
{
get: () => new Proxy({}, { get: () => boom }),
},
),
};
});
vi.mock("@/lib/db", () => ({ prisma: prismaMock }));
vi.mock("@/lib/modules/gate", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/modules/gate")>();
return { ...actual, resolveModuleMap: vi.fn() };
});
import { collectDoctorReportData } from "../doctor-report-data";
import { resolveModuleMap, MODULE_KEYS } from "@/lib/modules/gate";
const RANGE = {
start: new Date("2026-01-01T00:00:00.000Z"),
end: new Date("2026-04-01T00:00:00.000Z"),
days: 90,
};
/** Every module on, then apply the overrides. */
function moduleMap(overrides: Record<string, boolean> = {}) {
const out: Record<string, boolean> = {};
for (const key of MODULE_KEYS) out[key] = true;
return { ...out, ...overrides } as Record<string, boolean>;
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("collectDoctorReportData — doctorReport backstop", () => {
it("throws before any DB read when the module is off", async () => {
await expect(
collectDoctorReportData(
"u1",
RANGE,
// Injected rather than read, so the refusal is attributable to the
// module state and not to a DB stub.
{
moduleMap: moduleMap({
doctorReport: false,
}) as never,
},
),
).rejects.toThrow(/doctorReport/);
});
it("resolves the map itself when none is injected, and still refuses", async () => {
// The real callers do not inject; this is the path that actually protects
// the aggregate in production.
vi.mocked(resolveModuleMap).mockResolvedValue(
moduleMap({ doctorReport: false }) as never,
);
await expect(collectDoctorReportData("u1", RANGE)).rejects.toThrow(
/doctorReport/,
);
expect(resolveModuleMap).toHaveBeenCalledWith("u1");
});
it("does not refuse when the module is on", async () => {
// Positive control. The aggregate needs a full DB behind it, which this
// suite deliberately does not provide — so it will fail on the first
// query. What matters is that it gets THERE: the failure must be the DB
// stub, never the backstop, or the "off" assertions above would pass
// trivially for a function that always throws.
await expect(
collectDoctorReportData("u1", RANGE, {
moduleMap: moduleMap() as never,
}),
).rejects.toThrow("DB touched");
});
});
@@ -0,0 +1,104 @@
/**
* v1.30.22 — the `insights` gate on the correlations reader.
*
* `readCoachCorrelations` 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 — and only the REST
* sibling `/api/insights/correlations` ever gated. The reader also feeds on
* mood, symptom and compliance channels, so a mood-disabled account could
* receive pair rows naming mood with direction, lag, n and r.
*
* The gate lives in the reader, so these assertions cover every caller at
* once. That is the point of the placement: a fifth caller cannot reintroduce
* the gap by forgetting.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/modules/gate", () => ({
isModuleEnabled: vi.fn(async () => true),
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
const { prismaMock } = vi.hoisted(() => ({
prismaMock: {
user: { findUnique: vi.fn() },
measurement: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn() },
userSourcePriority: { findMany: vi.fn() },
},
}));
vi.mock("@/lib/db", () => ({ prisma: prismaMock }));
import { readCoachCorrelations } from "../tools/correlations-read";
import { isModuleEnabled } from "@/lib/modules/gate";
const USER = "u1";
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(isModuleEnabled).mockImplementation(async () => true);
prismaMock.user.findUnique.mockResolvedValue({ timezone: "Europe/Berlin" });
prismaMock.measurement.findMany.mockResolvedValue([]);
prismaMock.moodEntry.findMany.mockResolvedValue([]);
prismaMock.userSourcePriority.findMany.mockResolvedValue([]);
});
describe("readCoachCorrelations — insights module gate", () => {
it("gates on `insights`, not on a per-domain key", async () => {
await readCoachCorrelations(USER);
expect(isModuleEnabled).toHaveBeenCalledWith(USER, "insights");
});
it("omits with the module OFF, and reads nothing at all", async () => {
vi.mocked(isModuleEnabled).mockImplementation(async () => false);
const res = await readCoachCorrelations(USER);
expect(res.present).toBe(false);
// Distinct from `no_data`: the assistant is told the domain is switched
// off rather than inferring the user has no correlatable history.
expect(res.reason).toBe("module_disabled");
// The gate short-circuits before any channel is sourced. This is the
// load-bearing assertion — the reader draws on mood / symptom /
// compliance channels, so "computed then hid it" would still have pulled
// a disabled domain's rows into this process.
expect(prismaMock.measurement.findMany).not.toHaveBeenCalled();
expect(prismaMock.moodEntry.findMany).not.toHaveBeenCalled();
});
it("proceeds to the engine with the module ON", async () => {
const res = await readCoachCorrelations(USER);
// The synthetic fixture has no paired data, so the honest answer is a
// miss — but it must be the ENGINE's miss, reached after the real reads
// ran, not the gate's.
expect(prismaMock.measurement.findMany).toHaveBeenCalled();
expect(res.reason).not.toBe("module_disabled");
});
it("closes the operator kill-switch path, not only the user toggle", async () => {
// `isModuleEnabled` resolves `operatorAvailable && userEnabled`, so a
// server-wide operator disable arrives here as the same `false`. Asserted
// explicitly because it is the sharper case: the account holder cannot
// override an operator decision, so a leak past it is strictly worse than
// one past a personal preference.
vi.mocked(isModuleEnabled).mockImplementation(
async (_u: string, key: string) => key !== "insights",
);
const res = await readCoachCorrelations(USER);
expect(res.present).toBe(false);
expect(res.reason).toBe("module_disabled");
expect(prismaMock.measurement.findMany).not.toHaveBeenCalled();
});
it("surfaces a gate failure instead of swallowing it as a miss", async () => {
// The reader's body is fail-soft (any throw degrades to
// `{ present: false }`) so a correlation hiccup never sinks a Coach turn.
// The gate sits OUTSIDE that try/catch on purpose: a broken gate must be
// loud, not silently indistinguishable from "no pattern found".
vi.mocked(isModuleEnabled).mockRejectedValue(new Error("gate down"));
await expect(readCoachCorrelations(USER)).rejects.toThrow("gate down");
});
});
@@ -13,6 +13,9 @@ const intakeEventFindMany = vi.fn();
const illnessEpisodeFindMany = vi.fn();
const illnessDayLogFindMany = vi.fn();
const labResultFindMany = vi.fn();
vi.mock("@/lib/modules/gate", () => ({
isModuleEnabled: vi.fn(async () => true),
}));
vi.mock("@/lib/db", () => ({
prisma: {
measurement: { findMany: (a: unknown) => measurementFindMany(a) },
+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({
@@ -10,6 +10,9 @@
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/modules/gate", () => ({
isModuleEnabled: vi.fn(async () => true),
}));
vi.mock("@/lib/doctor-report-data", () => ({
collectDoctorReportData: vi.fn(),
}));
@@ -23,6 +26,7 @@ vi.mock("@/lib/db", () => ({
}));
import { loadShareViewData } from "../share-view-data";
import { isModuleEnabled } from "@/lib/modules/gate";
import { collectDoctorReportData } from "@/lib/doctor-report-data";
import { EMPTY_DOCTOR_REPORT_PREFS } from "@/lib/validations/doctor-report-prefs";
import { prisma } from "@/lib/db";
@@ -350,3 +354,79 @@ describe("loadShareViewData — grouped sections are folded, not silently defaul
expect(opts.sections.weight).toBe(true);
});
});
/**
* v1.30.22 — the owner's `doctorReport` module gates this surface too.
*
* Found while tracing every caller of the whole-record aggregate for the MCP
* fix: this link serves that same payload to an unauthenticated third party
* and never consulted the module key, so an owner (or an operator, via the
* availability switch ANDed above the user toggle) who turned the module off
* still had the full record served.
*
* Degrades to the share's OWN documents-only state rather than throwing:
* `documentOnly` is an existing, load-bearing privacy mode here (report
* `null`, aggregator never called), so a disabled module collapses the link to
* exactly the documents the owner attached — fail-closed for the health record
* without 500-ing a public link.
*/
describe("clinician share — owner doctorReport module gate", () => {
beforeEach(() => {
// Sibling of the suite above, so the outer reset does not reach here.
vi.clearAllMocks();
vi.mocked(isModuleEnabled).mockImplementation(async () => true);
collect.mockResolvedValue({ patient: { displayName: "Shared record" } });
});
it("aggregates normally with the module on", async () => {
collect.mockResolvedValue({ patient: { displayName: "Shared record" } });
findDocs.mockResolvedValue([]);
const res = await loadShareViewData(
ctx({ sectionsJson: { bp: true }, documentOnly: false }),
);
expect(res.documentOnly).toBe(false);
expect(res.report).not.toBeNull();
expect(collect).toHaveBeenCalled();
});
it("collapses to documents-only with the module off", async () => {
vi.mocked(isModuleEnabled).mockImplementation(async () => false);
findDocs.mockResolvedValue([]);
const res = await loadShareViewData(
ctx({ sectionsJson: { bp: true }, documentOnly: false }),
);
expect(res.documentOnly).toBe(true);
expect(res.report).toBeNull();
// Load-bearing: the aggregator is never called, so no health data leaves
// the DB at all — not built-then-withheld.
expect(collect).not.toHaveBeenCalled();
});
it("gates on the OWNER's module state, not a viewer's", async () => {
// The link is public; there is no viewer session. The owner id must come
// from the frozen share context.
findDocs.mockResolvedValue([]);
await loadShareViewData(
ctx({ sectionsJson: { bp: true }, documentOnly: false }),
);
expect(isModuleEnabled).toHaveBeenCalledWith("owner-1", "doctorReport");
});
it("closes the operator kill-switch path too", async () => {
vi.mocked(isModuleEnabled).mockImplementation(
async (_u: string, key: string) => key !== "doctorReport",
);
findDocs.mockResolvedValue([]);
const res = await loadShareViewData(
ctx({ sectionsJson: { bp: true }, documentOnly: false }),
);
expect(res.documentOnly).toBe(true);
expect(collect).not.toHaveBeenCalled();
});
});
+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 ?? {}),
+28 -3
View File
@@ -21,6 +21,8 @@ import { PgBoss } from "pg-boss";
import { prisma } from "@/lib/db";
import { workerLog } from "./shared";
import { withBackgroundEvent } from "@/lib/logging/background";
import { annotate } from "@/lib/logging/context";
/**
* Cron schedule tuple: `[queueName, cronExpression, sendOptions?]`. The
@@ -107,6 +109,9 @@ export type QueuePolicyTable = Readonly<Record<string, QueuePolicyDecision>>;
async function reconcileQueuePolicies(
policies: QueuePolicyTable,
): Promise<void> {
const reconciled: string[] = [];
const failed: string[] = [];
for (const [name, { policy }] of Object.entries(policies)) {
try {
const changed = await prisma.$executeRaw`
@@ -114,15 +119,35 @@ async function reconcileQueuePolicies(
SET policy = ${policy}, updated_on = now()
WHERE name = ${name} AND policy IS DISTINCT FROM ${policy}
`;
if (changed > 0) {
workerLog("info", `[queue-policy] reconciled ${name} to ${policy}`);
}
if (changed > 0) reconciled.push(`${name}=${policy}`);
} catch (err) {
// Never fail worker boot on a reconcile miss: the queue still works, it
// just keeps whatever de-duplication semantics it already had.
failed.push(name);
workerLog("error", `[queue-policy] failed to reconcile ${name}`, err);
}
}
// The reconcile is the ONLY thing that carries a policy onto a queue that
// already exists — `createQueue` cannot, because `create_queue()` ends in
// ON CONFLICT DO NOTHING. So whether it ran is the difference between this
// fix working and doing nothing at all on an upgraded instance, and that has
// to be observable. `workerLog("info", …)` is deliberately silent, which
// made the intended signal impossible to see; a wide event reaches stdout
// and the log store like every other boot task.
//
// Emitted on EVERY boot, including the no-op one: "reconciled 0" on a second
// boot is the confirmation that the first boot already migrated everything.
// Silence would be indistinguishable from the reconcile never running.
await withBackgroundEvent("worker.boot.queue_policy_reconcile", async () => {
annotate({
meta: {
queue_policy_reconciled_count: reconciled.length,
queue_policy_reconciled: reconciled.join(",") || "none",
queue_policy_failed_count: failed.length,
},
});
});
}
/**
@@ -0,0 +1,148 @@
/**
* v1.30.22 — the `doctorReport` gate on the whole-record aggregate.
*
* `collectDoctorReportData` consulted `resolveModuleMap` for its per-domain
* SECTIONS (mood / sleep / glucose / cycle / labs / recovery / workouts) but
* never for the `doctorReport` key that decides whether the aggregate may be
* assembled at all. Unlike `loadFhirContext` it carried no internal backstop,
* so it was left entirely to callers to remember — and the MCP surface did
* not: the fixed resource, its `{window}` template, and the
* `doctor_visit_summary` prompt each assembled the full record for an account
* with the module off.
*
* REFUSE, not omit. This is the whole record, so there is no honest partial
* answer; `/api/export/health-record` already answers 403 for exactly this
* state, and an empty summary invites the assistant to narrate "nothing on
* file" — a worse failure than an explicit unavailability.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/modules/gate", () => ({
isModuleEnabled: vi.fn(async () => true),
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/doctor-report-data", () => ({
collectDoctorReportData: vi.fn(),
}));
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn(async () => null) },
medication: { findMany: vi.fn(async () => []) },
labResult: { findMany: vi.fn(async () => []) },
},
}));
vi.mock("@/lib/ai/coach/tools/inventory", () => ({
buildCoachDataInventory: vi.fn(async () => ({})),
}));
vi.mock("@/lib/ai/coach/tools/executor", () => ({
executeCoachTool: vi.fn(async () => ({ present: false })),
}));
vi.mock("@/lib/insights/derived/baseline", () => ({
loadBaselineProfile: vi.fn(async () => ({})),
}));
vi.mock("@/lib/insights/derived-briefing", () => ({
detectDerivedBriefingSignals: vi.fn(async () => null),
}));
vi.mock("@/lib/insights/illness-cycle-briefing", () => ({
buildBriefingIllnessCycleContext: vi.fn(async () => null),
}));
vi.mock("@/lib/tz/resolver", () => ({
resolveUserTimezone: vi.fn(async () => "Europe/Berlin"),
}));
vi.mock("@/lib/mcp/rich-reads", () => ({
metricStatusDiscoveryRows: vi.fn(async () => []),
MCP_METRIC_STATUS_DISCOVERY: [],
MCP_CLINICAL_SIGNALS: [],
getNutrients: vi.fn(async () => ({ present: false })),
getLabHistory: vi.fn(async () => ({ present: false })),
resolveRichMetric: vi.fn(() => null),
}));
import { MCP_RESOURCES, MCP_RESOURCE_TEMPLATES } from "../resources";
import { MCP_PROMPTS } from "../prompts";
import { isModuleEnabled } from "@/lib/modules/gate";
import { collectDoctorReportData } from "@/lib/doctor-report-data";
import type { McpAuthContext } from "../auth";
const ctx = { userId: "u1" } as McpAuthContext;
/** Minimal aggregate the visit summariser can reduce without throwing. */
const REPORT_FIXTURE = {
period: {
days: 90,
since: "2026-03-29T00:00:00.000Z",
start: "2026-03-29T00:00:00.000Z",
end: "2026-06-27T00:00:00.000Z",
},
patient: { username: "tester" },
stats: { WEIGHT: { avg: 80, min: 79, max: 82, count: 30, latest: 80 } },
compliance: {},
medications: [],
labs: [],
};
const fixedResource = MCP_RESOURCES.find(
(r) => r.uri === "healthlog://report/doctor-visit",
)!;
const templateResource = MCP_RESOURCE_TEMPLATES.find(
(t) => t.uriTemplate === "healthlog://report/doctor-visit/{window}",
)!;
const prompt = MCP_PROMPTS.find((p) => p.name === "doctor_visit_summary")!;
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(isModuleEnabled).mockImplementation(async () => true);
vi.mocked(collectDoctorReportData).mockResolvedValue(
REPORT_FIXTURE as unknown as Awaited<
ReturnType<typeof collectDoctorReportData>
>,
);
});
describe("doctor-visit aggregate — doctorReport module gate", () => {
describe.each([
{
name: "fixed resource",
run: () => fixedResource.read(ctx),
},
{
name: "windowed template",
run: () => templateResource.read(ctx, { window: "last30days" }),
},
{
name: "doctor_visit_summary prompt",
run: () => prompt.run(ctx, {}),
},
])("$name", ({ run }) => {
it("serves with the module ON", async () => {
await expect(run()).resolves.toBeDefined();
expect(collectDoctorReportData).toHaveBeenCalled();
});
it("REFUSES with the module OFF and never assembles the record", async () => {
vi.mocked(isModuleEnabled).mockImplementation(async () => false);
await expect(run()).rejects.toThrow(/doctorReport/);
// The refusal must precede assembly — the whole point is that the
// record is never built, not that it is built and then withheld.
expect(collectDoctorReportData).not.toHaveBeenCalled();
});
it("refuses on the operator kill-switch path too", async () => {
// `isModuleEnabled` ANDs operator availability over the user toggle, so
// an operator disabling `doctorReport` server-wide arrives as the same
// `false`. The sharper case: no per-account setting can override it.
vi.mocked(isModuleEnabled).mockImplementation(
async (_u: string, key: string) => key !== "doctorReport",
);
await expect(run()).rejects.toThrow(/doctorReport/);
expect(collectDoctorReportData).not.toHaveBeenCalled();
});
});
it("gates on doctorReport specifically", async () => {
await fixedResource.read(ctx);
expect(isModuleEnabled).toHaveBeenCalledWith("u1", "doctorReport");
});
});
+3
View File
@@ -1,5 +1,8 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/modules/gate", () => ({
isModuleEnabled: vi.fn(async () => true),
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/doctor-report-data", () => ({
collectDoctorReportData: vi.fn(),
+3
View File
@@ -1,5 +1,8 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/modules/gate", () => ({
isModuleEnabled: vi.fn(async () => true),
}));
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn() },
@@ -0,0 +1,180 @@
/**
* v1.30.22 — the module gate on the rollup-backed rich reads.
*
* These three reads deliberately bypass `buildCoachSnapshot`, which is where
* the module narrowing lives for every Coach-routed read. They therefore
* 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 — on the one wire that egresses to a
* third-party assistant.
*
* Both layers matter and both are asserted here. `isModuleEnabled` resolves
* `operatorAvailable(key) && userEnabled(key)`, so a `false` from it is the
* OPERATOR kill-switch just as much as the user toggle — the sharper case,
* because a leak there defeats a server-wide decision the account holder
* cannot override.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/modules/gate", () => ({
isModuleEnabled: vi.fn(async () => true),
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/ai/coach/tools/correlations-read", () => ({
readCoachCorrelations: vi.fn(async () => ({ present: false })),
}));
vi.mock("@/lib/insights/derived/coach-read", () => ({
buildCoachReadStrip: vi.fn(),
}));
vi.mock("@/lib/rollups/measurement-read-wmy", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/rollups/measurement-read-wmy")>();
return { ...actual, readBestGranularityRollups: vi.fn() };
});
const { labResult, measurement } = vi.hoisted(() => ({
labResult: { findMany: vi.fn() },
measurement: { count: vi.fn(async () => 0), groupBy: vi.fn(async () => []) },
}));
vi.mock("@/lib/db", () => ({ prisma: { labResult, measurement } }));
import {
compareMetric,
getMetricBaseline,
detectChangepoints,
metricStatusDiscoveryRows,
MCP_METRIC_STATUS_DISCOVERY,
} from "../rich-reads";
import { isModuleEnabled } from "@/lib/modules/gate";
import { buildCoachReadStrip } from "@/lib/insights/derived/coach-read";
import { readBestGranularityRollups } from "@/lib/rollups/measurement-read-wmy";
import type { ModuleKey } from "@/lib/modules/gate";
const USER = "u1";
/** A bucket sequence dense enough for every read to produce a real answer. */
function buckets(n = 60) {
const start = Date.UTC(2026, 0, 1);
return {
granularity: "DAY" as const,
rows: Array.from({ length: n }, (_, i) => ({
bucketStart: new Date(start + i * 86_400_000),
// A clean level shift halfway so `detect_changepoints` has something to
// find — the read must be capable of a `present: true` answer, or a
// `present: false` under the gate would prove nothing.
sum: i < n / 2 ? 100 : 160,
count: 1,
min: i < n / 2 ? 100 : 160,
max: i < n / 2 ? 100 : 160,
})),
};
}
/** Drive the gate: every module on except the named one. */
function disableModule(key: ModuleKey) {
vi.mocked(isModuleEnabled).mockImplementation(
async (_userId: string, moduleKey: ModuleKey) => moduleKey !== key,
);
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(isModuleEnabled).mockImplementation(async () => true);
measurement.count.mockResolvedValue(0);
measurement.groupBy.mockResolvedValue([]);
vi.mocked(readBestGranularityRollups).mockResolvedValue(
buckets() as unknown as Awaited<
ReturnType<typeof readBestGranularityRollups>
>,
);
vi.mocked(buildCoachReadStrip).mockResolvedValue({
baseline: { median: 100, low: 90, high: 110 },
learning: false,
latest: { value: 105, at: new Date().toISOString() },
} as unknown as Awaited<ReturnType<typeof buildCoachReadStrip>>);
});
describe("rich reads — per-domain module gate", () => {
// `glucose` → BLOOD_GLUCOSE is the pairing the audit named explicitly: the
// metric where the honest `get_metric_series` and the leaking
// `get_metric_baseline` disagreed for the same account.
describe.each([
{ metric: "glucose", module: "glucose" as ModuleKey },
{ metric: "sleep", module: "sleep" as ModuleKey },
{ metric: "hrv", module: "recovery" as ModuleKey },
{ metric: "sleep_score", module: "sleep" as ModuleKey },
{ metric: "day_strain", module: "workouts" as ModuleKey },
])("$metric (owned by $module)", ({ metric, module }) => {
it("get_metric_baseline answers with the module ON", async () => {
const on = await getMetricBaseline(USER, { metric });
expect(on.present).toBe(true);
});
it("get_metric_baseline omits with the module OFF", async () => {
disableModule(module);
const off = await getMetricBaseline(USER, { metric });
expect(off.present).toBe(false);
expect(off.reason).toBe("module_disabled");
// The gate must short-circuit BEFORE the engine runs — a read that
// computes the band and then hides it has still loaded the data.
expect(buildCoachReadStrip).not.toHaveBeenCalled();
});
it("compare_metric omits with the module OFF", async () => {
disableModule(module);
const off = await compareMetric(USER, {
metric,
window: "last30days",
windowB: "last90days",
});
expect(off.present).toBe(false);
expect(off.reason).toBe("module_disabled");
expect(readBestGranularityRollups).not.toHaveBeenCalled();
});
it("detect_changepoints omits with the module OFF", async () => {
disableModule(module);
const off = await detectChangepoints(USER, { metric });
expect(off.present).toBe(false);
expect(off.reason).toBe("module_disabled");
expect(readBestGranularityRollups).not.toHaveBeenCalled();
});
});
it("refuses the whole comparison when only side B is gated", async () => {
// A one-sided degrade would answer the question anyway using the ungated
// side, so the gated side must sink the entire call.
disableModule("glucose");
const res = await compareMetric(USER, {
metric: "weight",
metricB: "glucose",
});
expect(res.present).toBe(false);
expect(res.reason).toBe("module_disabled_b");
});
it("leaves a metric no module owns ungated", async () => {
// Weight/pulse/BP are core clinical figures with no owning module; gating
// them would hide data the user never opted out of. Turning every module
// off must not touch them.
vi.mocked(isModuleEnabled).mockImplementation(async () => false);
const res = await getMetricBaseline(USER, { metric: "weight" });
expect(res.present).toBe(true);
});
it("drops a gated metric from the discovery listing", async () => {
// Discovery is the assistant's map of what exists. Advertising a metric
// that the fetch will then refuse both leaks that the domain is tracked
// and sends the assistant down a dead end.
disableModule("sleep");
const rows = await metricStatusDiscoveryRows(USER);
const keys = rows.map((r) => r.metric);
expect(keys).not.toContain("SLEEP_SCORE");
expect(keys).not.toContain("BREATHING_DISTURBANCES");
// …while an unowned metric in the same listing survives.
expect(MCP_METRIC_STATUS_DISCOVERY.map((s) => s.key)).toContain(
"FALL_COUNT",
);
expect(keys).toContain("FALL_COUNT");
});
});
+3
View File
@@ -1,6 +1,9 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// --- Engine mocks hoisted before importing the module under test. ---
vi.mock("@/lib/modules/gate", () => ({
isModuleEnabled: vi.fn(async () => true),
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/ai/coach/tools/correlations-read", () => ({
readCoachCorrelations: vi.fn(),
+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.
@@ -0,0 +1,136 @@
/**
* v1.30.22 — structural guard on the module→data ownership table.
*
* The three MCP leaks all had one root cause: the layer assumed every read
* inherits gating from `buildCoachSnapshot`, which is true for the
* Coach-routed tools and false for the families that deliberately bypass it.
* Fixing the three call sites alone would leave the assumption in place, so
* the ownership map moved to a shared table and the rich reads gate off it.
*
* WHAT THIS TEST FREEZES
*
* 1. Every metric the rich reads can resolve has been LOOKED AT: it either
* has an owning module or is named in `UNSCOPED_REVIEWED_TYPES`. A new
* metric that lands in the resolver without either fails here, which is
* the specific way finding 2 was able to happen — metrics became
* resolvable over MCP without anyone deciding who owned them.
* 2. The table stays consistent with the Coach snapshot's own narrowing, so
* the MCP wire and the Coach prompt cannot drift into disagreeing about
* which module owns which domain.
* 3. `UNSCOPED_REVIEWED_TYPES` stays a review record, not a dumping ground:
* an entry that later gains an owner must leave the list.
*
* WHAT IT CANNOT PROVE — read this before trusting it
*
* - It does NOT prove any read calls the gate. A future `getMetricSummary`
* that calls `resolveRichMetric` directly instead of the module-aware
* resolver would leak exactly as before and this test would stay green.
* Only the per-read tests in `mcp/__tests__/rich-reads-module-gate.test.ts`
* cover that, and only for the reads that exist today. There is no
* compile-time barrier stopping a new caller from reaching the ungated
* resolver — `resolveRichMetric` is still exported because
* `build-efficacy` and the tests use it for pure resolvability checks.
* - It does NOT prove the OWNERSHIP assignments are correct, only that they
* exist. Mapping SLEEP_SCORE to `sleep` is a human judgement; if it truly
* belonged to `recovery`, this test would be just as green.
* - It says nothing about the surfaces that gate on a module key directly
* rather than through a metric (`insights` for correlations, `doctorReport`
* for the aggregate). Those are covered by their own tests.
*/
import { describe, it, expect } from "vitest";
import {
MODULE_SCOPED_SOURCES,
UNSCOPED_REVIEWED_TYPES,
moduleForMeasurementType,
} from "../measurement-scope";
import { MODULE_KEYS, type ModuleKey } from "../gate";
import {
MCP_METRIC_STATUS_DISCOVERY,
MCP_CLINICAL_SIGNALS,
resolveRichMetric,
} from "@/lib/mcp/rich-reads";
import { METRIC_STATUS_IDS } from "@/lib/insights/metric-status-registry";
/**
* Every `MeasurementType` reachable through the rich-read resolver, collected
* the way an assistant would actually reach them: the discovery listings plus
* every metric-status id the resolver accepts by exact id.
*/
function resolvableTypes(): Set<string> {
const out = new Set<string>();
for (const sig of MCP_METRIC_STATUS_DISCOVERY) out.add(sig.measurementType);
for (const sig of MCP_CLINICAL_SIGNALS) out.add(sig.measurementType);
for (const id of METRIC_STATUS_IDS) {
const m = resolveRichMetric(id);
if (m) out.add(m.measurementType);
}
for (const name of ["weight", "pulse", "bmi", "glucose", "sleep", "hrv"]) {
const m = resolveRichMetric(name);
if (m) out.add(m.measurementType);
}
return out;
}
describe("module→data ownership table", () => {
it("has an owner or a written review decision for every resolvable metric", () => {
const unreviewed = [...resolvableTypes()].filter(
(t) =>
moduleForMeasurementType(t as never) === null &&
!UNSCOPED_REVIEWED_TYPES.has(t as never),
);
// A failure here is not a bug to suppress: a metric became readable over
// MCP without anyone deciding whether a module owns it. Either add it to
// `METRIC_STATUS_MODULE_OWNERS` or record it in `UNSCOPED_REVIEWED_TYPES`
// with a reason.
expect(unreviewed).toEqual([]);
});
it("never assigns a metric to a module key that does not exist", () => {
for (const type of resolvableTypes()) {
const owner = moduleForMeasurementType(type as never);
if (owner) expect(MODULE_KEYS).toContain(owner);
}
});
it("keeps the reviewed-unscoped list free of metrics that now have an owner", () => {
const stale = [...UNSCOPED_REVIEWED_TYPES].filter(
(t) => moduleForMeasurementType(t) !== null,
);
expect(stale).toEqual([]);
});
it("scopes only module keys that actually exist", () => {
for (const key of Object.keys(MODULE_SCOPED_SOURCES)) {
expect(MODULE_KEYS).toContain(key as ModuleKey);
}
});
it("resolves the domains the audit named to the modules that own them", () => {
// Spot-checks with real consequences: these are the exact pairings where
// `get_metric_series` (gated, via the snapshot) and `get_metric_baseline`
// (ungated, before this change) disagreed for the same account.
expect(moduleForMeasurementType("BLOOD_GLUCOSE")).toBe("glucose");
expect(moduleForMeasurementType("SLEEP_DURATION")).toBe("sleep");
expect(moduleForMeasurementType("HEART_RATE_VARIABILITY")).toBe("recovery");
expect(moduleForMeasurementType("HRV_RMSSD")).toBe("recovery");
expect(moduleForMeasurementType("SLEEP_SCORE")).toBe("sleep");
expect(moduleForMeasurementType("BREATHING_DISTURBANCES")).toBe("sleep");
expect(moduleForMeasurementType("DAY_STRAIN")).toBe("workouts");
expect(moduleForMeasurementType("WORKOUT_STRAIN")).toBe("workouts");
expect(moduleForMeasurementType("CARDIO_LOAD")).toBe("workouts");
expect(moduleForMeasurementType("ANS_CHARGE")).toBe("recovery");
expect(moduleForMeasurementType("CARDIO_RECOVERY")).toBe("recovery");
});
it("leaves the core clinical figures unowned on purpose", () => {
// Gating these would hide data behind a toggle the user never associated
// with them. Pinned so a future broad-brush "gate everything" change has
// to argue with a test.
expect(moduleForMeasurementType("WEIGHT")).toBeNull();
expect(moduleForMeasurementType("PULSE")).toBeNull();
expect(moduleForMeasurementType("BLOOD_PRESSURE_SYS")).toBeNull();
expect(moduleForMeasurementType("BODY_MASS_INDEX")).toBeNull();
});
});
+226
View File
@@ -0,0 +1,226 @@
/**
* 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",
// The HRV fallback type. `resolveRichMetricForUser` swaps HRV to its RMSSD
// fallback for an account that only has ring/strap nightly RMSSD rows, so
// leaving this unowned would have let the fallback path serve exactly the
// recovery data the primary type refuses. Caught by the structural test.
HRV_RMSSD: "recovery",
};
/**
* Measurement types that resolve over the rich reads but that NO module owns —
* reviewed and deliberately ungated.
*
* These are not an oversight and not a backlog: they are the domains the
* module model has never claimed. The Coach snapshot does not narrow any of
* them either (none of their scope tokens appears in
* {@link MODULE_SCOPED_SOURCES}), so gating them here would make the MCP wire
* STRICTER than the app and the Coach — hiding data behind a toggle the user
* never associated with it. Grouped by why:
*
* - Core clinical figures every account carries: weight, pulse, blood
* pressure, BMI, body temperature.
* - Body composition: fat / lean / muscle / bone / water / visceral.
* - Activity volume: steps, distance, flights, active energy. Owned by no
* module — `workouts` gates workout SESSIONS, not ambient movement.
* - Gait and mobility, incl. the fall / walk-test / stair metrics.
* - Cardio-vascular readings with no module home: SpO2, respiratory rate,
* walking HR, pulse-wave velocity, vascular age, avg/max HR.
* - v1.25 clinical signals: grip strength, pain NRS, waist measures.
* - WRIST_TEMPERATURE and ENERGY_EXPENDITURE_KJ: plausible arguments exist
* for `sleep` and `workouts` respectively, but neither module claims them
* anywhere else in the tree, and guessing an owner would hide a metric
* behind an unrelated toggle. Left unowned deliberately rather than
* assigned on a hunch.
*
* 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>([
// Core clinical.
"WEIGHT",
"PULSE",
"BODY_MASS_INDEX",
"BLOOD_PRESSURE_SYS",
"BLOOD_PRESSURE_DIA",
"BODY_TEMPERATURE",
// Body composition.
"BODY_FAT",
"FAT_MASS",
"FAT_FREE_MASS",
"LEAN_BODY_MASS",
"MUSCLE_MASS",
"BONE_MASS",
"TOTAL_BODY_WATER",
"VISCERAL_FAT",
// Ambient activity volume (not workout sessions).
"ACTIVITY_STEPS",
"WALKING_RUNNING_DISTANCE",
"FLIGHTS_CLIMBED",
"ACTIVE_ENERGY_BURNED",
"ENERGY_EXPENDITURE_KJ",
// Gait / mobility.
"WALKING_SPEED",
"WALKING_STEADINESS",
"WALKING_ASYMMETRY",
"WALKING_DOUBLE_SUPPORT",
"WALKING_STEP_LENGTH",
"FALL_COUNT",
"SIX_MINUTE_WALK_DISTANCE",
"STAIR_ASCENT_SPEED",
"STAIR_DESCENT_SPEED",
// Cardio-vascular with no module home.
"OXYGEN_SATURATION",
"RESPIRATORY_RATE",
"WALKING_HEART_RATE_AVERAGE",
"PULSE_WAVE_VELOCITY",
"VASCULAR_AGE",
"AVERAGE_HEART_RATE",
"MAX_HEART_RATE",
// v1.25 clinical signals.
"GRIP_STRENGTH",
"PAIN_NRS",
"WAIST_CIRCUMFERENCE",
"WAIST_TO_HEIGHT",
// Temperature with a plausible but unclaimed owner.
"WRIST_TEMPERATURE",
]);
/**
* 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;
}