refactor(coach): keep the exclusion gate in its own module beside the snapshot builder

This commit is contained in:
Marc-André Bombeck committed 2026-09-27 13:32:58 +02:00
1 parent daf386fe7f
commit b1629e2ad3
3 files changed
+106 -98

No files matched your search

+102
View File
@@ -0,0 +1,102 @@
/**
* The one exclusion gate every Coach read passes: the snapshot builder's
* source narrowing, a table tool's gate, and a stored table shown again.
* Kept apart from the snapshot builder so the gate can be used (and tests
* can stand the builder in) without loading it.
*/
import type { ModuleKey } from "@/lib/modules/registry";
import { MODULE_SCOPED_SOURCES } from "@/lib/modules/measurement-scope";
import type { CoachScopeSource } from "@/lib/ai/coach/types";
import type {
CoachExcludeMetric,
CoachPrefs,
} from "@/lib/validations/coach-prefs";
/**
* v1.18.0 — module enable/disable → coach-snapshot domain map.
*
* When a toggleable data-domain module is disabled for the account, the
* domains it owns must never enter the coach context. We reuse the
* existing `excludeMetrics` filtering path (the `excluded` set narrows
* `sources` before any row is read) by folding the disabled modules into
* a SYSTEM-side exclusion that unions with the user's `excludeMetrics`.
*
* Each toggleable data domain maps to the `CoachScopeSource` token(s)
* its snapshot block(s) gate on:
* - `mood` → the mood block (`mood` source).
* - `sleep` → the per-night sleep block + the sleep-rhythm block
* (both gate on the `sleep` source).
* - `glucose` → the glucose per-context + clinical block.
* - `workouts` → the workouts block.
* - `recovery` → the recovery / strain composites. These are the
* derived block (READINESS / RECOVERY_SCORE / STRAIN_SCORE
* / …), the WHOOP-native dayStrain block, and the
* trajectory block — all gated on `derivedActive`, which
* reads HRV / resting-HR / VO₂max. Dropping those source
* tokens drops the raw additive timelines too; the
* composites are additionally gated below so they never
* build off the sleep signal alone.
* - `environment` → the audio-exposure (env / headphone / event), daylight,
* and skin-temperature blocks — the sources the opt-in
* environment cluster owns (`CLUSTER_SOURCES.environment`).
*
* `cycle` is intentionally absent: its block already resolves through the
* fully two-layer cycle gate (`isCycleAvailableForUser` — the per-user
* toggle AND the operator server-wide kill-switch) below, exactly as the
* 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 = MODULE_SCOPED_SOURCES as Partial<
Record<ModuleKey, CoachScopeSource[]>
>;
/**
* What the Coach may not read for this person: their own `excludeMetrics`
* plus every source a switched-off module owns (`moduleMap[key] === false`;
* the gate has already resolved every delegation, so the map is
* authoritative).
*/
export function coachExclusions(
prefs: Pick<CoachPrefs, "excludeMetrics">,
moduleMap: Readonly<Record<ModuleKey, boolean>>,
): ReadonlySet<CoachExcludeMetric> {
const excluded = new Set<CoachExcludeMetric>(prefs.excludeMetrics);
for (const [key, srcs] of Object.entries(MODULE_EXCLUDED_SOURCES)) {
if (moduleMap[key as ModuleKey] === false) {
for (const src of srcs ?? []) {
// Every entry in MODULE_EXCLUDED_SOURCES is a CoachScopeSource that
// also exists in the CoachExcludeMetric enum overlap the
// source-narrowing check reads.
excluded.add(src as unknown as CoachExcludeMetric);
}
}
}
return excluded;
}
/**
* The sources of a scope the Coach may read: the scope minus the
* exclusions, and no medication compliance when medications are excluded
* (excluding medications means no medication data at all). The one gate
* every read passes, the snapshot's own and the table tools' alike.
*/
export function admitCoachSources(
scoped: Iterable<CoachScopeSource>,
excluded: ReadonlySet<CoachExcludeMetric>,
): Set<CoachScopeSource> {
const sources = new Set<CoachScopeSource>();
for (const src of scoped) {
// The `excludeMetrics` enum is a superset of `CoachScopeSource`
// (medications + anthropometrics live on the exclude-only side); the
// runtime `has` check only catches the overlapping members.
if (!excluded.has(src as unknown as CoachExcludeMetric)) sources.add(src);
}
if (excluded.has("medications")) sources.delete("compliance");
return sources;
}
+2 -93
View File
@@ -19,9 +19,7 @@ import { prisma } from "@/lib/db";
import { extractFeatures } from "@/lib/insights/features";
import {
parseCoachPrefs,
type CoachExcludeMetric,
type CoachDataCluster,
type CoachPrefs,
} from "@/lib/validations/coach-prefs";
import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
import { locales, defaultLocale, type Locale } from "@/lib/i18n/config";
@@ -53,8 +51,8 @@ import type { MeasurementType } from "@/generated/prisma/client";
import { COACH_SOURCE_MEASUREMENT_TYPES } from "@/lib/ai/coach/source-measurement-types";
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 { resolveModuleMap } from "@/lib/modules/gate";
import { admitCoachSources, coachExclusions } from "@/lib/ai/coach/scope-gate";
import { SCHEDULE_COMPLIANCE_SELECT } from "@/lib/analytics/compliance";
import type { BaselineProfile } from "@/lib/insights/derived";
import { toProfileSex } from "@/lib/profile/sex";
@@ -195,50 +193,6 @@ const CORE_CLUSTERS: ReadonlySet<CoachDataCluster> = new Set<CoachDataCluster>([
"glucose",
]);
/**
* v1.18.0 — module enable/disable → coach-snapshot domain map.
*
* When a toggleable data-domain module is disabled for the account, the
* domains it owns must never enter the coach context. We reuse the
* existing `excludeMetrics` filtering path (the `excluded` set narrows
* `sources` before any row is read) by folding the disabled modules into
* a SYSTEM-side exclusion that unions with the user's `excludeMetrics`.
*
* Each toggleable data domain maps to the `CoachScopeSource` token(s)
* its snapshot block(s) gate on:
* - `mood` → the mood block (`mood` source).
* - `sleep` → the per-night sleep block + the sleep-rhythm block
* (both gate on the `sleep` source).
* - `glucose` → the glucose per-context + clinical block.
* - `workouts` → the workouts block.
* - `recovery` → the recovery / strain composites. These are the
* derived block (READINESS / RECOVERY_SCORE / STRAIN_SCORE
* / …), the WHOOP-native dayStrain block, and the
* trajectory block — all gated on `derivedActive`, which
* reads HRV / resting-HR / VO₂max. Dropping those source
* tokens drops the raw additive timelines too; the
* composites are additionally gated below so they never
* build off the sleep signal alone.
* - `environment` → the audio-exposure (env / headphone / event), daylight,
* and skin-temperature blocks — the sources the opt-in
* environment cluster owns (`CLUSTER_SOURCES.environment`).
*
* `cycle` is intentionally absent: its block already resolves through the
* fully two-layer cycle gate (`isCycleAvailableForUser` — the per-user
* toggle AND the operator server-wide kill-switch) below, exactly as the
* 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 = MODULE_SCOPED_SOURCES as Partial<
Record<ModuleKey, CoachScopeSource[]>
>;
/**
* Build the Coach prompt snapshot for `userId`. Always uses
* `includeRaw=false` because the Coach replies are conversational and
@@ -299,51 +253,6 @@ function blockFailed(block: string): (err: unknown) => null {
};
}
/**
* What the Coach may not read for this person: their own `excludeMetrics`
* plus every source a switched-off module owns (`moduleMap[key] === false`;
* the gate has already resolved every delegation, so the map is
* authoritative).
*/
export function coachExclusions(
prefs: Pick<CoachPrefs, "excludeMetrics">,
moduleMap: Readonly<Record<ModuleKey, boolean>>,
): ReadonlySet<CoachExcludeMetric> {
const excluded = new Set<CoachExcludeMetric>(prefs.excludeMetrics);
for (const [key, srcs] of Object.entries(MODULE_EXCLUDED_SOURCES)) {
if (moduleMap[key as ModuleKey] === false) {
for (const src of srcs ?? []) {
// Every entry in MODULE_EXCLUDED_SOURCES is a CoachScopeSource that
// also exists in the CoachExcludeMetric enum overlap the
// source-narrowing check reads.
excluded.add(src as unknown as CoachExcludeMetric);
}
}
}
return excluded;
}
/**
* The sources of a scope the Coach may read: the scope minus the
* exclusions, and no medication compliance when medications are excluded
* (excluding medications means no medication data at all). The one gate
* every read passes, the snapshot's own and the table tools' alike.
*/
export function admitCoachSources(
scoped: Iterable<CoachScopeSource>,
excluded: ReadonlySet<CoachExcludeMetric>,
): Set<CoachScopeSource> {
const sources = new Set<CoachScopeSource>();
for (const src of scoped) {
// The `excludeMetrics` enum is a superset of `CoachScopeSource`
// (medications + anthropometrics live on the exclude-only side); the
// runtime `has` check only catches the overlapping members.
if (!excluded.has(src as unknown as CoachExcludeMetric)) sources.add(src);
}
if (excluded.has("medications")) sources.delete("compliance");
return sources;
}
async function buildCoachSnapshotImpl(
userId: string,
scope?: CoachScope,
+2 -5
View File
@@ -45,11 +45,8 @@ import { annotate } from "@/lib/logging/context";
import type { Locale } from "@/lib/i18n/config";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { resolveModuleMap } from "@/lib/modules/gate";
import {
admitCoachSources,
buildCoachSnapshot,
coachExclusions,
} from "@/lib/ai/coach/snapshot";
import { buildCoachSnapshot } from "@/lib/ai/coach/snapshot";
import { admitCoachSources, coachExclusions } from "@/lib/ai/coach/scope-gate";
import type { CoachPrefs } from "@/lib/validations/coach-prefs";
import { readMessageResults } from "@/lib/ai/coach/persistence";
import type {