diff --git a/prisma/migrations/0065_v1431_assistant_feature_flags/migration.sql b/prisma/migrations/0065_v1431_assistant_feature_flags/migration.sql new file mode 100644 index 000000000..949b8b9ed --- /dev/null +++ b/prisma/migrations/0065_v1431_assistant_feature_flags/migration.sql @@ -0,0 +1,35 @@ +-- v1.4.31 — Assistant-surface operator feature flags +-- +-- Six new boolean columns on `app_settings`. One master flag plus +-- five sub-flags covering the seven LLM-driven surfaces (Coach, +-- Daily Briefing, per-metric status cards, correlations, +-- Health-Score delta explainer). All default `true` so existing +-- installs upgrade without losing surfaces. The runtime resolver +-- forces every sub-flag false when the master is off — so flipping +-- one row in the admin panel can mute the whole assistant. +-- +-- Additive-only — six nullable-then-default columns, no data +-- movement. `IF NOT EXISTS` keeps the migration idempotent against +-- partial historical runs (same posture as 0058, 0061, 0063). +-- +-- Locks per `.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R5 +-- and the architecture doc at +-- `.planning/research/v15-assistant-optional.md`. + +ALTER TABLE "app_settings" + ADD COLUMN IF NOT EXISTS "assistant_enabled" BOOLEAN NOT NULL DEFAULT true; + +ALTER TABLE "app_settings" + ADD COLUMN IF NOT EXISTS "assistant_coach_enabled" BOOLEAN NOT NULL DEFAULT true; + +ALTER TABLE "app_settings" + ADD COLUMN IF NOT EXISTS "assistant_briefing_enabled" BOOLEAN NOT NULL DEFAULT true; + +ALTER TABLE "app_settings" + ADD COLUMN IF NOT EXISTS "assistant_insight_status_enabled" BOOLEAN NOT NULL DEFAULT true; + +ALTER TABLE "app_settings" + ADD COLUMN IF NOT EXISTS "assistant_correlations_enabled" BOOLEAN NOT NULL DEFAULT true; + +ALTER TABLE "app_settings" + ADD COLUMN IF NOT EXISTS "assistant_health_score_explainer_enabled" BOOLEAN NOT NULL DEFAULT true; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9b358c5bd..81c15483f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1208,6 +1208,26 @@ model AppSettings { // 32 chars) plus headroom. defaultUserTimezone String? @map("default_user_timezone") @db.VarChar(64) + // v1.4.31 — Assistant-surface operator feature flags. The master + // gates every LLM-driven surface app-wide; five sub-flags carve + // the visibility cut per surface (Coach drawer + chat, Daily + // Briefing + advisor, per-metric status cards, correlation + // narration, Health-Score delta explainer). Defaults preserve the + // v1.4.30 behaviour (every surface visible) so upgrades require + // no admin action. The runtime resolver in + // `src/lib/feature-flags/index.ts` forces every sub-flag false + // when the master is off. Locked per + // `.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R5: the same + // flag matrix gates server-routed AND iOS on-device surfaces, so + // operators can opt out of every assistant surface regardless of + // where the model runs. + assistantEnabled Boolean @default(true) @map("assistant_enabled") + assistantCoachEnabled Boolean @default(true) @map("assistant_coach_enabled") + assistantBriefingEnabled Boolean @default(true) @map("assistant_briefing_enabled") + assistantInsightStatusEnabled Boolean @default(true) @map("assistant_insight_status_enabled") + assistantCorrelationsEnabled Boolean @default(true) @map("assistant_correlations_enabled") + assistantHealthScoreExplainerEnabled Boolean @default(true) @map("assistant_health_score_explainer_enabled") + @@map("app_settings") } diff --git a/src/lib/feature-flags/__tests__/index.test.ts b/src/lib/feature-flags/__tests__/index.test.ts new file mode 100644 index 000000000..b67fb446e --- /dev/null +++ b/src/lib/feature-flags/__tests__/index.test.ts @@ -0,0 +1,164 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + appSettings: { + findUnique: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/logging/context", () => ({ + getEvent: () => ({ addWarning: vi.fn() }), +})); + +import { prisma } from "@/lib/db"; +import { + ASSISTANT_FLAGS_DEFAULT, + AssistantDisabledError, + getAssistantFlags, + requireAssistantSurface, + resolveAssistantFlags, +} from "../index"; + +const FIND = prisma.appSettings.findUnique as ReturnType; + +beforeEach(() => { + FIND.mockReset(); +}); + +describe("resolveAssistantFlags", () => { + it("returns the input unchanged when master is on", () => { + const input = { + enabled: true, + coach: true, + briefing: false, + insightStatus: true, + correlations: false, + healthScoreExplainer: true, + }; + expect(resolveAssistantFlags(input)).toEqual(input); + }); + + it("forces every sub-flag false when master is off", () => { + const input = { + enabled: false, + coach: true, + briefing: true, + insightStatus: true, + correlations: true, + healthScoreExplainer: true, + }; + expect(resolveAssistantFlags(input)).toEqual({ + enabled: false, + coach: false, + briefing: false, + insightStatus: false, + correlations: false, + healthScoreExplainer: false, + }); + }); +}); + +describe("getAssistantFlags", () => { + it("returns defaults when the row is missing", async () => { + FIND.mockResolvedValue(null); + const flags = await getAssistantFlags(); + expect(flags).toEqual(ASSISTANT_FLAGS_DEFAULT); + }); + + it("reads each column correctly", async () => { + FIND.mockResolvedValue({ + assistantEnabled: true, + assistantCoachEnabled: false, + assistantBriefingEnabled: true, + assistantInsightStatusEnabled: false, + assistantCorrelationsEnabled: true, + assistantHealthScoreExplainerEnabled: false, + }); + const flags = await getAssistantFlags(); + expect(flags).toEqual({ + enabled: true, + coach: false, + briefing: true, + insightStatus: false, + correlations: true, + healthScoreExplainer: false, + }); + }); + + it("forces every sub-flag false when master is off", async () => { + FIND.mockResolvedValue({ + assistantEnabled: false, + assistantCoachEnabled: true, + assistantBriefingEnabled: true, + assistantInsightStatusEnabled: true, + assistantCorrelationsEnabled: true, + assistantHealthScoreExplainerEnabled: true, + }); + const flags = await getAssistantFlags(); + expect(flags).toEqual({ + enabled: false, + coach: false, + briefing: false, + insightStatus: false, + correlations: false, + healthScoreExplainer: false, + }); + }); + + it("falls back to defaults on a Prisma error", async () => { + FIND.mockRejectedValue(new Error("db down")); + const flags = await getAssistantFlags(); + expect(flags).toEqual(ASSISTANT_FLAGS_DEFAULT); + }); +}); + +describe("requireAssistantSurface", () => { + it("returns the resolved flag set when the surface is enabled", async () => { + FIND.mockResolvedValue({ + assistantEnabled: true, + assistantCoachEnabled: true, + assistantBriefingEnabled: true, + assistantInsightStatusEnabled: true, + assistantCorrelationsEnabled: true, + assistantHealthScoreExplainerEnabled: true, + }); + const flags = await requireAssistantSurface("coach"); + expect(flags.coach).toBe(true); + }); + + it("throws AssistantDisabledError when the sub-flag is off", async () => { + FIND.mockResolvedValue({ + assistantEnabled: true, + assistantCoachEnabled: false, + assistantBriefingEnabled: true, + assistantInsightStatusEnabled: true, + assistantCorrelationsEnabled: true, + assistantHealthScoreExplainerEnabled: true, + }); + await expect(requireAssistantSurface("coach")).rejects.toThrow( + AssistantDisabledError, + ); + }); + + it("throws AssistantDisabledError when the master kills the surface", async () => { + FIND.mockResolvedValue({ + assistantEnabled: false, + assistantCoachEnabled: true, + assistantBriefingEnabled: true, + assistantInsightStatusEnabled: true, + assistantCorrelationsEnabled: true, + assistantHealthScoreExplainerEnabled: true, + }); + await expect(requireAssistantSurface("briefing")).rejects.toThrow( + AssistantDisabledError, + ); + }); + + it("AssistantDisabledError carries the surface-tagged errorCode", () => { + const err = new AssistantDisabledError("correlations"); + expect(err.surface).toBe("correlations"); + expect(err.errorCode).toBe("assistant.disabled.correlations"); + }); +}); diff --git a/src/lib/feature-flags/index.ts b/src/lib/feature-flags/index.ts new file mode 100644 index 000000000..3b0b32551 --- /dev/null +++ b/src/lib/feature-flags/index.ts @@ -0,0 +1,152 @@ +import { prisma } from "@/lib/db"; +import { getEvent } from "@/lib/logging/context"; + +/** + * v1.4.31 — Assistant-surface operator feature flags. + * + * Six boolean toggles on `AppSettings` carve the visibility cut for + * the seven LLM-driven surfaces. The master flag is a single kill- + * switch; the five sub-flags carve specific surfaces. + * + * `assistant.enabled = false` forces every sub-flag false in the + * resolved shape — the master always wins. This means a server-side + * caller never has to compose `master && sub`; reading + * `flags.coach` already accounts for both layers. + * + * Per `.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R5: the + * matrix gates BOTH server-routed AND iOS on-device assistant + * surfaces. The `GET /api/feature-flags` endpoint projects this + * shape so iOS reads the same authoritative set the web reads. + */ + +/** The five assistant sub-flags the operator can carve. */ +export type AssistantSurface = + | "coach" + | "briefing" + | "insightStatus" + | "correlations" + | "healthScoreExplainer"; + +export interface AssistantFlagSet { + /** Master kill-switch — when false, every sub-flag is forced false. */ + enabled: boolean; + /** Coach drawer, chat SSE, history rail, feedback. */ + coach: boolean; + /** Daily Briefing card + advisor recommendations + regen icon. */ + briefing: boolean; + /** Per-metric status cards on every `/insights/` sub-page. */ + insightStatus: boolean; + /** Correlation narration tile on the mother page. */ + correlations: boolean; + /** `?` glyph that opens the Health-Score delta explainer popover. */ + healthScoreExplainer: boolean; +} + +/** All-on default; mirrors v1.4.30 behaviour for fresh installs. */ +export const ASSISTANT_FLAGS_DEFAULT: AssistantFlagSet = Object.freeze({ + enabled: true, + coach: true, + briefing: true, + insightStatus: true, + correlations: true, + healthScoreExplainer: true, +}); + +/** + * Load the assistant flag set from `AppSettings`. Read-through pattern + * matches `getGlobalServiceAvailability()` — null/error/missing-row all + * fall back to defaults so the assistant stays visible on first boot. + * + * The master always wins: every sub-flag is forced false when the + * master is off, before the resolved set leaves this function. + */ +export async function getAssistantFlags(): Promise { + try { + const settings = await prisma.appSettings.findUnique({ + where: { id: "singleton" }, + select: { + assistantEnabled: true, + assistantCoachEnabled: true, + assistantBriefingEnabled: true, + assistantInsightStatusEnabled: true, + assistantCorrelationsEnabled: true, + assistantHealthScoreExplainerEnabled: true, + }, + }); + + const master = settings?.assistantEnabled ?? true; + return resolveAssistantFlags({ + enabled: master, + coach: settings?.assistantCoachEnabled ?? true, + briefing: settings?.assistantBriefingEnabled ?? true, + insightStatus: settings?.assistantInsightStatusEnabled ?? true, + correlations: settings?.assistantCorrelationsEnabled ?? true, + healthScoreExplainer: + settings?.assistantHealthScoreExplainerEnabled ?? true, + }); + } catch { + getEvent()?.addWarning( + "Failed to load assistant feature flags, using defaults", + ); + return ASSISTANT_FLAGS_DEFAULT; + } +} + +/** + * Pure resolver — given a raw set of master + sub-flag values, return + * the operator-effective shape. Exposed for unit tests and for the + * admin PUT handler that needs to echo the resolved set after a write. + */ +export function resolveAssistantFlags(raw: AssistantFlagSet): AssistantFlagSet { + if (!raw.enabled) { + return { + enabled: false, + coach: false, + briefing: false, + insightStatus: false, + correlations: false, + healthScoreExplainer: false, + }; + } + return { ...raw }; +} + +/** + * Error class thrown by `requireAssistantSurface()` when the operator + * has disabled the surface. The api-handler catches it and returns + * the 403 envelope per the iOS contract (R5): + * + * { data: null, error: "...", meta: { errorCode: "assistant.disabled." } } + * + * Older iOS clients that predate the v1.4.31 contract surface this as + * a generic 403 (their existing 403-handler covers the case); v1.4.31+ + * clients read the `errorCode` to render the `AssistantDisabledNotice` + * empty-state. + */ +export class AssistantDisabledError extends Error { + readonly surface: AssistantSurface; + readonly errorCode: string; + + constructor(surface: AssistantSurface) { + super(`Assistant surface "${surface}" is disabled on this server`); + this.name = "AssistantDisabledError"; + this.surface = surface; + this.errorCode = `assistant.disabled.${surface}`; + } +} + +/** + * Server-side gate helper. Throws `AssistantDisabledError` when the + * relevant surface (or the master) is off. Routes call this near the + * top, after auth and rate-limit, and let the apiHandler catch turn + * it into the 403 envelope. + */ +export async function requireAssistantSurface( + surface: AssistantSurface, +): Promise { + const flags = await getAssistantFlags(); + if (!flags[surface]) { + throw new AssistantDisabledError(surface); + } + return flags; +} diff --git a/src/lib/validations/admin.ts b/src/lib/validations/admin.ts index 2930cb93c..9eb1570cd 100644 --- a/src/lib/validations/admin.ts +++ b/src/lib/validations/admin.ts @@ -80,5 +80,19 @@ export const adminSettingsSchema = z // against Intl.supportedValuesOf at runtime; this schema just // guards the shape + length. defaultUserTimezone: z.string().max(64).optional(), + // v1.4.31 — assistant-surface operator feature flags. Master + // kills every sub-flag; sub-flags carve specific surfaces. The + // dedicated admin endpoint at + // /api/admin/settings/assistant-flags also accepts these via + // its own schema; this entry exists so the generic + // /api/admin/settings PUT path can still carry the values for + // operators who script their settings updates over a single + // route. + assistantEnabled: z.boolean().optional(), + assistantCoachEnabled: z.boolean().optional(), + assistantBriefingEnabled: z.boolean().optional(), + assistantInsightStatusEnabled: z.boolean().optional(), + assistantCorrelationsEnabled: z.boolean().optional(), + assistantHealthScoreExplainerEnabled: z.boolean().optional(), }) .strict();