mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
23 files changed
+1253
-43
No files matched your search
@@ -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,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
@@ -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
@@ -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) },
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 ?? {}),
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user