feat(settings): add 6 assistant-surface feature flags to AppSettings

Operator-side kill matrix for every LLM-driven surface. Master flag
`assistantEnabled` plus five sub-flags carve the seven surfaces:
Coach + chat, Daily Briefing + advisor, per-metric status cards,
correlation narration, Health-Score delta explainer. Defaults are
`true` so existing installs upgrade without losing affordances.

New `src/lib/feature-flags/` module exposes `getAssistantFlags()`,
`resolveAssistantFlags()` and the `AssistantDisabledError` typed
error route handlers throw to surface the 403 +
`errorCode: "assistant.disabled.<surface>"` envelope. The resolver
forces every sub-flag false when the master is off so callers never
have to compose `master && sub`.

Locks per `.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R5:
the same matrix gates server-routed AND iOS on-device surfaces.

Migration 0065 is additive only; `app_settings` gains six boolean
columns, all default `true`, idempotent under `IF NOT EXISTS`.
This commit is contained in:
Marc-André Bombeck committed 2026-05-16 13:38:49 +02:00
1 parent 0aaf10d796
commit b68206eca4
5 files changed
+385

No files matched your search

@@ -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;
+20
View File
@@ -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")
}
@@ -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<typeof vi.fn>;
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");
});
});
+152
View File
@@ -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/<metric>` 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<AssistantFlagSet> {
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.<surface>" } }
*
* 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<AssistantFlagSet> {
const flags = await getAssistantFlags();
if (!flags[surface]) {
throw new AssistantDisabledError(surface);
}
return flags;
}
+14
View File
@@ -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();