diff --git a/src/app/api/insights/biomarker-assessment/__tests__/route.test.ts b/src/app/api/insights/biomarker-assessment/__tests__/route.test.ts index 0c5c973b3..d596a5f57 100644 --- a/src/app/api/insights/biomarker-assessment/__tests__/route.test.ts +++ b/src/app/api/insights/biomarker-assessment/__tests__/route.test.ts @@ -8,7 +8,7 @@ vi.mock("@/lib/db", () => ({ })); // The `statusText` capability decides whether the note is served; the // unavailable body's provider-presence probe is stubbed. -vi.mock("@/lib/ai/capabilities/gate", () => ({ getAiCapability: vi.fn() })); +vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityToServe: vi.fn() })); vi.mock("@/lib/ai/provider", () => ({ probeProviderPresence: vi.fn(async () => true), })); @@ -63,7 +63,7 @@ import { getSession } from "@/lib/auth/session"; import { prisma } from "@/lib/db"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { generateBiomarkerStatus } from "@/lib/insights/biomarker-status"; -import { getAiCapability } from "@/lib/ai/capabilities/gate"; +import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -87,7 +87,7 @@ beforeEach(() => { vi.resetAllMocks(); vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never); vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true }); - vi.mocked(getAiCapability).mockResolvedValue({ + vi.mocked(aiCapabilityToServe).mockResolvedValue({ available: true, reason: null, onDeviceAllowed: true, @@ -135,7 +135,7 @@ describe("GET /api/insights/biomarker-assessment", () => { it("answers 200 with no note while statusText is unavailable, calling no generator", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(getAiCapability).mockResolvedValue({ + vi.mocked(aiCapabilityToServe).mockResolvedValue({ available: false, reason: "user_disabled", onDeviceAllowed: false, diff --git a/src/app/api/insights/biomarker-assessment/route.ts b/src/app/api/insights/biomarker-assessment/route.ts index 2adc3c7f2..194acc3fb 100644 --- a/src/app/api/insights/biomarker-assessment/route.ts +++ b/src/app/api/insights/biomarker-assessment/route.ts @@ -25,7 +25,7 @@ import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; -import { getAiCapability } from "@/lib/ai/capabilities/gate"; +import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate"; import { unavailableStatusBody } from "@/lib/insights/status-unavailable"; import { generateBiomarkerStatus } from "@/lib/insights/biomarker-status"; import { resolveMetricStatusLocale } from "@/lib/insights/metric-status"; @@ -52,7 +52,7 @@ export const GET = apiHandler(async (request: NextRequest) => { return returnAllZodIssues(parsed.error, 422); } - const ai = await getAiCapability("statusText"); + const ai = await aiCapabilityToServe(user.id, "statusText"); if (!ai.available) { annotate({ action: { name: "insights.biomarker-status.unavailable" }, diff --git a/src/app/api/insights/blood-pressure-status/__tests__/route.test.ts b/src/app/api/insights/blood-pressure-status/__tests__/route.test.ts index c5d8177c1..748572207 100644 --- a/src/app/api/insights/blood-pressure-status/__tests__/route.test.ts +++ b/src/app/api/insights/blood-pressure-status/__tests__/route.test.ts @@ -46,7 +46,7 @@ vi.mock("@/lib/insights/blood-pressure-status", () => ({ // The `statusText` capability decides whether the note is served; the // unavailable body's provider-presence probe is stubbed. -vi.mock("@/lib/ai/capabilities/gate", () => ({ getAiCapability: vi.fn() })); +vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityToServe: vi.fn() })); vi.mock("@/lib/ai/provider", () => ({ probeProviderPresence: vi.fn(async () => true), })); @@ -54,7 +54,7 @@ vi.mock("@/lib/ai/provider", () => ({ import { GET } from "../route"; import { getSession } from "@/lib/auth/session"; import { requireModuleEnabled } from "@/lib/modules/gate"; -import { getAiCapability } from "@/lib/ai/capabilities/gate"; +import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate"; import { probeProviderPresence } from "@/lib/ai/provider"; import { generateBloodPressureStatusForUser } from "@/lib/insights/blood-pressure-status"; import { @@ -79,7 +79,7 @@ function makeReq(): NextRequest { beforeEach(() => { vi.resetAllMocks(); - vi.mocked(getAiCapability).mockResolvedValue(AI_AVAILABLE); + vi.mocked(aiCapabilityToServe).mockResolvedValue(AI_AVAILABLE); vi.mocked(probeProviderPresence).mockResolvedValue(true); vi.mocked(generateBloodPressureStatusForUser).mockResolvedValue({ hasProvider: true, @@ -97,7 +97,7 @@ describe("GET /api/insights/blood-pressure-status", () => { const body = (await res.json()) as { data: Record }; expect(body.data.text).toBe("ok"); expect(body.data.ai).toEqual(AI_AVAILABLE); - expect(getAiCapability).toHaveBeenCalledWith("statusText"); + expect(aiCapabilityToServe).toHaveBeenCalledWith("user-1", "statusText"); expect(requireModuleEnabled).not.toHaveBeenCalled(); }); @@ -110,7 +110,7 @@ describe("GET /api/insights/blood-pressure-status", () => { "answers 200 with no note for %s, reading and warming nothing", async (reason) => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(getAiCapability).mockResolvedValue(aiUnavailable(reason)); + vi.mocked(aiCapabilityToServe).mockResolvedValue(aiUnavailable(reason)); const res = await callGet(makeReq()); expect(res.status).toBe(200); const body = (await res.json()) as { data: Record }; @@ -128,7 +128,9 @@ describe("GET /api/insights/blood-pressure-status", () => { it("reports hasProvider as presence only: false for a missing provider", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(getAiCapability).mockResolvedValue(aiUnavailable("no_provider")); + vi.mocked(aiCapabilityToServe).mockResolvedValue( + aiUnavailable("no_provider"), + ); vi.mocked(probeProviderPresence).mockResolvedValue(false); const res = await callGet(makeReq()); const body = (await res.json()) as { data: Record }; diff --git a/src/app/api/insights/blood-pressure-status/route.ts b/src/app/api/insights/blood-pressure-status/route.ts index 58976745a..9a0cec204 100644 --- a/src/app/api/insights/blood-pressure-status/route.ts +++ b/src/app/api/insights/blood-pressure-status/route.ts @@ -7,7 +7,7 @@ import { import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; -import { getAiCapability } from "@/lib/ai/capabilities/gate"; +import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate"; import { unavailableStatusBody } from "@/lib/insights/status-unavailable"; export const dynamic = "force-dynamic"; @@ -27,7 +27,7 @@ export const GET = apiHandler(async (request: NextRequest) => { // record, which is not a section a scoped grant can name. The miss behind it // enqueues nothing while a delegate is holding the request. const { user } = await requireRecordAuth("manage", "record"); - const ai = await getAiCapability("statusText"); + const ai = await aiCapabilityToServe(user.id, "statusText"); if (!ai.available) { annotate({ action: { name: "insights.blood-pressure-status.unavailable" }, diff --git a/src/app/api/insights/bmi-status/route.ts b/src/app/api/insights/bmi-status/route.ts index 160115c27..a78c2a57d 100644 --- a/src/app/api/insights/bmi-status/route.ts +++ b/src/app/api/insights/bmi-status/route.ts @@ -7,7 +7,7 @@ import { import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; -import { getAiCapability } from "@/lib/ai/capabilities/gate"; +import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate"; import { unavailableStatusBody } from "@/lib/insights/status-unavailable"; export const dynamic = "force-dynamic"; @@ -27,7 +27,7 @@ export const GET = apiHandler(async (request: NextRequest) => { // record, which is not a section a scoped grant can name. The miss behind it // enqueues nothing while a delegate is holding the request. const { user } = await requireRecordAuth("manage", "record"); - const ai = await getAiCapability("statusText"); + const ai = await aiCapabilityToServe(user.id, "statusText"); if (!ai.available) { annotate({ action: { name: "insights.bmi-status.unavailable" }, diff --git a/src/app/api/insights/coach/nudge-status/__tests__/route.test.ts b/src/app/api/insights/coach/nudge-status/__tests__/route.test.ts index 04ab9baa7..164aa9d9b 100644 --- a/src/app/api/insights/coach/nudge-status/__tests__/route.test.ts +++ b/src/app/api/insights/coach/nudge-status/__tests__/route.test.ts @@ -11,7 +11,7 @@ */ import { describe, it, expect, vi, beforeEach } from "vitest"; -const { requireRecordAuth, evaluateContext, readStatus, getAiCapability } = +const { requireRecordAuth, evaluateContext, readStatus, aiCapabilityToServe } = vi.hoisted(() => ({ requireRecordAuth: vi.fn(), evaluateContext: vi.fn(async () => ({ surfaced: 0, errored: 0 })), @@ -21,7 +21,7 @@ const { requireRecordAuth, evaluateContext, readStatus, getAiCapability } = nudgedAt: null, }), ), - getAiCapability: vi.fn(), + aiCapabilityToServe: vi.fn(), })); const AVAILABLE = { available: true, reason: null, onDeviceAllowed: true }; @@ -30,7 +30,7 @@ vi.mock("@/lib/api-handler", () => ({ apiHandler: unknown>(fn: T) => fn, requireRecordAuth, })); -vi.mock("@/lib/ai/capabilities/gate", () => ({ getAiCapability })); +vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityToServe })); vi.mock("@/lib/ai/coach/nudge-status", () => ({ readCoachNudgeStatus: readStatus, })); @@ -47,7 +47,7 @@ import { GET } from "../route"; beforeEach(() => { vi.clearAllMocks(); readStatus.mockResolvedValue({ unread: false, nudgedAt: null }); - getAiCapability.mockResolvedValue(AVAILABLE); + aiCapabilityToServe.mockResolvedValue(AVAILABLE); }); describe("nudge-status — NEXT_APP_OPEN hook", () => { @@ -114,7 +114,7 @@ describe("nudge-status — the coach capability", () => { data: Record; }; - expect(getAiCapability).toHaveBeenCalledWith("coach"); + expect(aiCapabilityToServe).toHaveBeenCalledWith("u1", "coach"); expect(res.data).toEqual({ unread: true, nudgedAt: "2026-07-18", @@ -130,7 +130,7 @@ describe("nudge-status — the coach capability", () => { actor: { id: "u1" }, }); const ai = { available: false, reason, onDeviceAllowed: false }; - getAiCapability.mockResolvedValue(ai); + aiCapabilityToServe.mockResolvedValue(ai); const res = (await (GET as unknown as () => Promise)()) as { data: Record; diff --git a/src/app/api/insights/coach/nudge-status/route.ts b/src/app/api/insights/coach/nudge-status/route.ts index d4f778d0f..d5908a363 100644 --- a/src/app/api/insights/coach/nudge-status/route.ts +++ b/src/app/api/insights/coach/nudge-status/route.ts @@ -22,15 +22,16 @@ * local seen-stamp keys on a stable value (kept for the existing client * contract). * - * A mixed read: while the `coach` capability is unavailable (operator switch, - * opt-out, no provider, no consent) the route still answers 200, with the + * A mixed read: while the record's `coach` capability is unavailable + * (operator switch, opt-out, no provider, no consent) the route still answers + * 200, with the * quiet shape `{ unread: false, nudgedAt: null, conversationId: null }` and * an `ai` state saying why. There is no Coach to open, so there is nothing * unread, and no app-open reminder is evaluated. */ import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { apiSuccess } from "@/lib/api-response"; -import { getAiCapability } from "@/lib/ai/capabilities/gate"; +import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate"; import { readCoachNudgeStatus } from "@/lib/ai/coach/nudge-status"; import { evaluateCoachContextReminders } from "@/lib/ai/coach/context-reminders"; import { prisma } from "@/lib/db"; @@ -52,7 +53,7 @@ export const GET = apiHandler(async () => { // budget and writes into their conversation, and reading whether the thread // has something unopened does neither. const { user, actor } = await requireRecordAuth("read", "record"); - const ai = await getAiCapability("coach"); + const ai = await aiCapabilityToServe(user.id, "coach"); if (!ai.available) { return apiSuccess({ nudgedAt: null, diff --git a/src/app/api/insights/comprehensive/__tests__/route.test.ts b/src/app/api/insights/comprehensive/__tests__/route.test.ts index 51c11427f..d6673001e 100644 --- a/src/app/api/insights/comprehensive/__tests__/route.test.ts +++ b/src/app/api/insights/comprehensive/__tests__/route.test.ts @@ -87,7 +87,7 @@ vi.mock("@/lib/ai/provider", () => ({ // The per-request `ai` block beside the body. Pinned here so the envelope // assertions do not depend on the capability loader's own reads. vi.mock("@/lib/ai/capabilities/gate", () => ({ - getAiCapability: vi.fn(), + aiCapabilityToServe: vi.fn(), })); vi.mock("@/lib/medication-category", () => ({ @@ -105,7 +105,7 @@ vi.mock("@/lib/modules/gate", async (importOriginal) => ({ import { GET } from "../route"; import { getSession } from "@/lib/auth/session"; import { requireModuleEnabled } from "@/lib/modules/gate"; -import { getAiCapability } from "@/lib/ai/capabilities/gate"; +import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate"; import { probeProviderPresence } from "@/lib/ai/provider"; import { prisma } from "@/lib/db"; import { buildComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator"; @@ -132,7 +132,7 @@ beforeEach(() => { vi.resetAllMocks(); // resetAllMocks drops the module gate's default; restore it. vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never); - vi.mocked(getAiCapability).mockResolvedValue({ + vi.mocked(aiCapabilityToServe).mockResolvedValue({ available: true, reason: null, onDeviceAllowed: true, @@ -268,7 +268,7 @@ describe("GET /api/insights/comprehensive — envelope shape", () => { enabled: false, response: new Response(null, { status: 403 }), } as never); - vi.mocked(getAiCapability).mockResolvedValue({ + vi.mocked(aiCapabilityToServe).mockResolvedValue({ available: false, reason: "user_disabled", onDeviceAllowed: false, @@ -295,8 +295,8 @@ describe("GET /api/insights/comprehensive — envelope shape", () => { expect(body.data.ai.statusText.reason).toBe("user_disabled"); expect( vi - .mocked(getAiCapability) - .mock.calls.map((c) => c[0]) + .mocked(aiCapabilityToServe) + .mock.calls.map((c) => c[1]) .sort(), ).toEqual(["briefing", "statusText"]); }); diff --git a/src/app/api/insights/comprehensive/route.ts b/src/app/api/insights/comprehensive/route.ts index b816bb2d1..be30df6e1 100644 --- a/src/app/api/insights/comprehensive/route.ts +++ b/src/app/api/insights/comprehensive/route.ts @@ -1,6 +1,6 @@ import { prisma } from "@/lib/db"; import { probeProviderPresence } from "@/lib/ai/provider"; -import { getAiCapability } from "@/lib/ai/capabilities/gate"; +import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate"; import { apiError, apiSuccess } from "@/lib/api-response"; import type { DataPoint, DataSummary } from "@/lib/analytics/trends"; import { summarize } from "@/lib/analytics/trends"; @@ -95,8 +95,8 @@ export const GET = apiHandler(async () => { ); const [briefing, statusText] = await Promise.all([ - getAiCapability("briefing"), - getAiCapability("statusText"), + aiCapabilityToServe(user.id, "briefing"), + aiCapabilityToServe(user.id, "statusText"), ]); return apiSuccess({ diff --git a/src/app/api/insights/derived/route.ts b/src/app/api/insights/derived/route.ts index d60034261..2438fd636 100644 --- a/src/app/api/insights/derived/route.ts +++ b/src/app/api/insights/derived/route.ts @@ -35,7 +35,7 @@ import { import { DERIVED_MAX_WINDOW_DAYS } from "@/lib/insights/derived/types"; import { resolveDerivedAssessment } from "@/lib/insights/derived/derived-assessment-ai"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; -import { getAiCapability } from "@/lib/ai/capabilities/gate"; +import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate"; export const dynamic = "force-dynamic"; @@ -161,7 +161,7 @@ export const GET = apiHandler(async (request: NextRequest) => { // The model-written override is served, and warmed, only while // `statusText` is available. Otherwise the deterministic text stands and // `ai` says why there is no warmer prose. - const ai = await getAiCapability("statusText"); + const ai = await aiCapabilityToServe(user.id, "statusText"); const assessment = await resolveDerivedAssessment({ metric, userId: user.id, diff --git a/src/app/api/insights/medication-compliance-status/route.ts b/src/app/api/insights/medication-compliance-status/route.ts index 963003a93..38d8bade3 100644 --- a/src/app/api/insights/medication-compliance-status/route.ts +++ b/src/app/api/insights/medication-compliance-status/route.ts @@ -7,7 +7,7 @@ import { import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; -import { getAiCapability } from "@/lib/ai/capabilities/gate"; +import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate"; import { unavailableComplianceStatusBody } from "@/lib/insights/status-unavailable"; export const dynamic = "force-dynamic"; @@ -27,7 +27,7 @@ export const GET = apiHandler(async (request: NextRequest) => { // record, which is not a section a scoped grant can name. The miss behind it // enqueues nothing while a delegate is holding the request. const { user } = await requireRecordAuth("manage", "record"); - const ai = await getAiCapability("statusText"); + const ai = await aiCapabilityToServe(user.id, "statusText"); if (!ai.available) { annotate({ action: { name: "insights.medication-compliance-status.unavailable" }, diff --git a/src/app/api/insights/metric-status/__tests__/route.test.ts b/src/app/api/insights/metric-status/__tests__/route.test.ts index 9e229192e..393f1a7ec 100644 --- a/src/app/api/insights/metric-status/__tests__/route.test.ts +++ b/src/app/api/insights/metric-status/__tests__/route.test.ts @@ -9,7 +9,7 @@ vi.mock("@/lib/db", () => ({ })); // The `statusText` capability decides whether the note is served; the // unavailable body's provider-presence probe is stubbed. -vi.mock("@/lib/ai/capabilities/gate", () => ({ getAiCapability: vi.fn() })); +vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityToServe: vi.fn() })); vi.mock("@/lib/ai/provider", () => ({ probeProviderPresence: vi.fn(async () => true), })); @@ -62,7 +62,7 @@ import { getSession } from "@/lib/auth/session"; import { prisma } from "@/lib/db"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { generateMetricStatus } from "@/lib/insights/metric-status"; -import { getAiCapability } from "@/lib/ai/capabilities/gate"; +import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -85,7 +85,7 @@ beforeEach(() => { vi.resetAllMocks(); vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never); vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true }); - vi.mocked(getAiCapability).mockResolvedValue({ + vi.mocked(aiCapabilityToServe).mockResolvedValue({ available: true, reason: null, onDeviceAllowed: true, @@ -146,7 +146,7 @@ describe("GET /api/insights/metric-status", () => { "answers 200 with no note while statusText is unavailable (%i readings → insufficient %s)", async (readings, insufficient) => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(getAiCapability).mockResolvedValue({ + vi.mocked(aiCapabilityToServe).mockResolvedValue({ available: false, reason: "operator_disabled", onDeviceAllowed: false, diff --git a/src/app/api/insights/metric-status/route.ts b/src/app/api/insights/metric-status/route.ts index ac396068e..4c105e83f 100644 --- a/src/app/api/insights/metric-status/route.ts +++ b/src/app/api/insights/metric-status/route.ts @@ -30,7 +30,7 @@ import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; import { requireModuleEnabled, type ModuleKey } from "@/lib/modules/gate"; -import { getAiCapability } from "@/lib/ai/capabilities/gate"; +import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate"; import { unavailableStatusBody } from "@/lib/insights/status-unavailable"; import { prisma } from "@/lib/db"; import { @@ -104,7 +104,7 @@ export const GET = apiHandler(async (request: NextRequest) => { if (!gate.enabled) return gate.response; } - const ai = await getAiCapability("statusText"); + const ai = await aiCapabilityToServe(user.id, "statusText"); if (!ai.available) { // The note is not served, but whether there is anything to assess is // data: the card keeps its insufficient-data state. diff --git a/src/app/api/insights/mood-status/__tests__/route.test.ts b/src/app/api/insights/mood-status/__tests__/route.test.ts index a5cabeac5..7f411003d 100644 --- a/src/app/api/insights/mood-status/__tests__/route.test.ts +++ b/src/app/api/insights/mood-status/__tests__/route.test.ts @@ -12,7 +12,7 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); // The `statusText` capability decides whether the note is served; the // unavailable body's provider-presence probe is stubbed. -vi.mock("@/lib/ai/capabilities/gate", () => ({ getAiCapability: vi.fn() })); +vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityToServe: vi.fn() })); vi.mock("@/lib/ai/provider", () => ({ probeProviderPresence: vi.fn(async () => true), })); @@ -51,7 +51,7 @@ import { GET } from "../route"; import { getSession } from "@/lib/auth/session"; import { prisma } from "@/lib/db"; import { generateMoodStatusForUser } from "@/lib/insights/mood-status"; -import { getAiCapability } from "@/lib/ai/capabilities/gate"; +import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -81,7 +81,7 @@ beforeEach(() => { vi.resetAllMocks(); vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never); vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue(null as never); - vi.mocked(getAiCapability).mockResolvedValue({ + vi.mocked(aiCapabilityToServe).mockResolvedValue({ available: true, reason: null, onDeviceAllowed: true, @@ -120,13 +120,13 @@ describe("GET /api/insights/mood-status — module gate", () => { userRow({ mood: false }) as never, ); await callGet(makeReq()); - expect(getAiCapability).not.toHaveBeenCalled(); + expect(aiCapabilityToServe).not.toHaveBeenCalled(); }); it("answers 200 with no note while statusText is unavailable", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(prisma.user.findUnique).mockResolvedValue(userRow(null) as never); - vi.mocked(getAiCapability).mockResolvedValue({ + vi.mocked(aiCapabilityToServe).mockResolvedValue({ available: false, reason: "no_provider", onDeviceAllowed: true, diff --git a/src/app/api/insights/mood-status/route.ts b/src/app/api/insights/mood-status/route.ts index 187dcaf71..1b7314d71 100644 --- a/src/app/api/insights/mood-status/route.ts +++ b/src/app/api/insights/mood-status/route.ts @@ -8,7 +8,7 @@ import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; import { requireModuleEnabled } from "@/lib/modules/gate"; -import { getAiCapability } from "@/lib/ai/capabilities/gate"; +import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate"; import { unavailableStatusBody } from "@/lib/insights/status-unavailable"; export const dynamic = "force-dynamic"; @@ -34,7 +34,7 @@ export const GET = apiHandler(async (request: NextRequest) => { const gate = await requireModuleEnabled(user.id, "mood"); if (!gate.enabled) return gate.response; - const ai = await getAiCapability("statusText"); + const ai = await aiCapabilityToServe(user.id, "statusText"); if (!ai.available) { annotate({ action: { name: "insights.mood-status.unavailable" }, diff --git a/src/app/api/insights/narrative/route.ts b/src/app/api/insights/narrative/route.ts index 3a9fa8d76..f4314dd37 100644 --- a/src/app/api/insights/narrative/route.ts +++ b/src/app/api/insights/narrative/route.ts @@ -13,10 +13,12 @@ * * A mixed read. The narrative is data when the deterministic composer wrote * it and model output when a provider did. Model-written text is served only - * while the `periodNarrative` capability is available; otherwise `narrative` - * is null and `ai` says why. The warm is enqueued only when the capability is - * available, or when there is no servable text at all (the worker then writes - * the deterministic narrative). The `insights` module is the AI analysis + * while the record's `periodNarrative` capability is available (answered from + * the record's own state, so a delegate reads the owner's narrative exactly + * when the owner would); otherwise `narrative` is null and `ai` says why. The + * warm is enqueued only when the capability is available, or when no row + * exists at all (the worker then writes the deterministic narrative); never + * for a delegate. The `insights` module is the AI analysis * opt-out and is folded into the capability, so it no longer refuses the * route. */ @@ -27,9 +29,9 @@ import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; import { locales, defaultLocale, type Locale } from "@/lib/i18n/config"; -import { getAiCapability } from "@/lib/ai/capabilities/gate"; +import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate"; +import { prisma } from "@/lib/db"; import { readPeriodNarrative } from "@/lib/insights/narrative/period-narrative-generate"; -import { DETERMINISTIC_PROVIDER_TYPE } from "@/lib/insights/narrative/period-narrative-deterministic"; import { PERIOD_DAYS, type NarrativePeriod, @@ -86,21 +88,26 @@ export const GET = apiHandler(async (request: NextRequest) => { }); const locale = narrowLocale(resolved); - const ai = await getAiCapability("periodNarrative"); - const stored = await readPeriodNarrative(user.id, period, locale); - // A row without a recorded provider is treated as model text: failing - // closed hides at most a deterministic line the next warm rewrites. - const modelWritten = - stored !== null && stored.providerType !== DETERMINISTIC_PROVIDER_TYPE; - const existing = - stored !== null && (!modelWritten || ai.available) ? stored : null; + // Whether model-written text may be shown is the record's own answer, + // whoever is reading; the reader already applies it (a model-written row + // reads as absent while it may not be shown). Starting a warm for a + // delegate stays suppressed below. + const ai = await aiCapabilityToServe(user.id, "periodNarrative"); + const existing = await readPeriodNarrative(user.id, period, locale); const isFresh = existing !== null && Date.now() - new Date(existing.updatedAt).getTime() < NARRATIVE_FRESH_MS; - // With AI unavailable a warm is still useful when nothing servable exists: - // it produces the deterministic narrative. A stale deterministic row is - // served as it is rather than re-warmed. - const mayWarm = ai.available || existing === null; + // With AI unavailable a warm only helps when no row exists at all: the + // worker then writes the deterministic narrative. A hidden model-written + // row must not be overwritten by one (turning AI back on brings it back), + // and a stale deterministic row is served as it is. + const mayWarm = + ai.available || + (existing === null && + (await prisma.insightNarrative.findUnique({ + where: { userId_period_locale: { userId: user.id, period, locale } }, + select: { id: true }, + })) === null); // Read-only: never block on the provider. Warm out of band whenever the row // is stale / missing. The generator produces AI prose when a provider is @@ -127,7 +134,7 @@ export const GET = apiHandler(async (request: NextRequest) => { has_narrative: existing !== null, revalidating, generation_suppressed: generationSuppressed, - model_text_hidden: modelWritten && !ai.available, + ai_reason: ai.reason, }, }); diff --git a/src/app/api/insights/pulse-status/route.ts b/src/app/api/insights/pulse-status/route.ts index ef290eceb..f22b1b9fe 100644 --- a/src/app/api/insights/pulse-status/route.ts +++ b/src/app/api/insights/pulse-status/route.ts @@ -7,7 +7,7 @@ import { import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; -import { getAiCapability } from "@/lib/ai/capabilities/gate"; +import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate"; import { unavailableStatusBody } from "@/lib/insights/status-unavailable"; export const dynamic = "force-dynamic"; @@ -27,7 +27,7 @@ export const GET = apiHandler(async (request: NextRequest) => { // record, which is not a section a scoped grant can name. The miss behind it // enqueues nothing while a delegate is holding the request. const { user } = await requireRecordAuth("manage", "record"); - const ai = await getAiCapability("statusText"); + const ai = await aiCapabilityToServe(user.id, "statusText"); if (!ai.available) { annotate({ action: { name: "insights.pulse-status.unavailable" }, diff --git a/src/app/api/insights/weight-status/route.ts b/src/app/api/insights/weight-status/route.ts index 2e19b0c01..bfa1e4a74 100644 --- a/src/app/api/insights/weight-status/route.ts +++ b/src/app/api/insights/weight-status/route.ts @@ -7,7 +7,7 @@ import { import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; -import { getAiCapability } from "@/lib/ai/capabilities/gate"; +import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate"; import { unavailableStatusBody } from "@/lib/insights/status-unavailable"; export const dynamic = "force-dynamic"; @@ -27,7 +27,7 @@ export const GET = apiHandler(async (request: NextRequest) => { // record, which is not a section a scoped grant can name. The miss behind it // enqueues nothing while a delegate is holding the request. const { user } = await requireRecordAuth("manage", "record"); - const ai = await getAiCapability("statusText"); + const ai = await aiCapabilityToServe(user.id, "statusText"); if (!ai.available) { annotate({ action: { name: "insights.weight-status.unavailable" }, diff --git a/src/app/api/workouts/[id]/route.ts b/src/app/api/workouts/[id]/route.ts index 2cf0e2322..05d7f2bc5 100644 --- a/src/app/api/workouts/[id]/route.ts +++ b/src/app/api/workouts/[id]/route.ts @@ -30,7 +30,7 @@ import { apiError, apiSuccess } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows"; import { requireModuleEnabled } from "@/lib/modules/gate"; -import { getAiCapability } from "@/lib/ai/capabilities/gate"; +import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate"; import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets"; import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; import { buildWorkoutHrSeries } from "@/lib/workouts/hr-series"; @@ -65,7 +65,7 @@ export const GET = apiHandler( // served only while `workoutInsights` is available (operator switch, the // AI analysis opt-out, provider presence, consent). Otherwise the row is // not even loaded, `aiInsight` is null, and `ai` says why. - const ai = await getAiCapability("workoutInsights"); + const ai = await aiCapabilityToServe(user.id, "workoutInsights"); const row = await prisma.workout.findUnique({ where: { id }, diff --git a/src/app/api/workouts/__tests__/detail.test.ts b/src/app/api/workouts/__tests__/detail.test.ts index 41ea55ac1..3b3e280ab 100644 --- a/src/app/api/workouts/__tests__/detail.test.ts +++ b/src/app/api/workouts/__tests__/detail.test.ts @@ -64,13 +64,13 @@ vi.mock("@/lib/modules/gate", async (importOriginal) => { }); // The Activity Insight paragraph follows the `workoutInsights` capability. -vi.mock("@/lib/ai/capabilities/gate", () => ({ getAiCapability: vi.fn() })); +vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityToServe: vi.fn() })); import { GET } from "../[id]/route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { requireModuleEnabled } from "@/lib/modules/gate"; -import { getAiCapability } from "@/lib/ai/capabilities/gate"; +import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate"; import { AI_AVAILABLE, aiUnavailable, @@ -146,7 +146,7 @@ describe("GET /api/workouts/{id}", () => { vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true, } as never); - vi.mocked(getAiCapability).mockResolvedValue(AI_AVAILABLE); + vi.mocked(aiCapabilityToServe).mockResolvedValue(AI_AVAILABLE); // The timezone resolver caches per user for 60 s in process; drop the // entry so a test that pins a zone is not served the previous one. invalidateUserTimezone(SESSION_OK.user.id); @@ -572,7 +572,7 @@ describe("GET /api/workouts/{id} — aiInsight", () => { vi.clearAllMocks(); vi.stubEnv("ENCRYPTION_KEY", TEST_KEY); _resetCryptoCacheForTests(); - vi.mocked(getAiCapability).mockResolvedValue(AI_AVAILABLE); + vi.mocked(aiCapabilityToServe).mockResolvedValue(AI_AVAILABLE); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(prisma.user.findUnique).mockResolvedValue({ sourcePriorityJson: null, @@ -624,7 +624,10 @@ describe("GET /api/workouts/{id} — aiInsight", () => { paragraph: "A steady, aerobic-leaning run.", generatedAt: generatedAt.toISOString(), }); - expect(getAiCapability).toHaveBeenCalledWith("workoutInsights"); + expect(aiCapabilityToServe).toHaveBeenCalledWith( + "user-1", + "workoutInsights", + ); expect(body.data.ai).toEqual(AI_AVAILABLE); }); @@ -637,7 +640,7 @@ describe("GET /api/workouts/{id} — aiInsight", () => { ] as const)( "does not read or expose the stored paragraph while workoutInsights is %s", async (reason) => { - vi.mocked(getAiCapability).mockResolvedValue(aiUnavailable(reason)); + vi.mocked(aiCapabilityToServe).mockResolvedValue(aiUnavailable(reason)); vi.mocked(prisma.workout.findUnique).mockResolvedValue({ ...BASE_ROW, insight: { diff --git a/src/lib/insights/__tests__/biomarker-status.test.ts b/src/lib/insights/__tests__/biomarker-status.test.ts index 70c8acdef..50c586f06 100644 --- a/src/lib/insights/__tests__/biomarker-status.test.ts +++ b/src/lib/insights/__tests__/biomarker-status.test.ts @@ -31,6 +31,11 @@ vi.mock("@/lib/ai/capabilities/gate", () => ({ reason: null, onDeviceAllowed: true, }), + aiCapabilityToServe: async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), })); import { prisma } from "@/lib/db"; diff --git a/src/lib/insights/__tests__/blood-pressure-status.test.ts b/src/lib/insights/__tests__/blood-pressure-status.test.ts index 7e9ef8e87..191b5e50e 100644 --- a/src/lib/insights/__tests__/blood-pressure-status.test.ts +++ b/src/lib/insights/__tests__/blood-pressure-status.test.ts @@ -41,6 +41,11 @@ vi.mock("@/lib/ai/capabilities/gate", () => ({ reason: null, onDeviceAllowed: true, }), + aiCapabilityToServe: async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), })); vi.mock("@/lib/insights/memory", () => ({ diff --git a/src/lib/insights/__tests__/bmi-status.test.ts b/src/lib/insights/__tests__/bmi-status.test.ts index b133c8df9..0a5122681 100644 --- a/src/lib/insights/__tests__/bmi-status.test.ts +++ b/src/lib/insights/__tests__/bmi-status.test.ts @@ -39,6 +39,11 @@ vi.mock("@/lib/ai/capabilities/gate", () => ({ reason: null, onDeviceAllowed: true, }), + aiCapabilityToServe: async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), })); vi.mock("@/lib/insights/memory", () => ({ diff --git a/src/lib/insights/__tests__/comprehensive-briefing-grounding-disposal.test.ts b/src/lib/insights/__tests__/comprehensive-briefing-grounding-disposal.test.ts index ba835d180..73cb4a4de 100644 --- a/src/lib/insights/__tests__/comprehensive-briefing-grounding-disposal.test.ts +++ b/src/lib/insights/__tests__/comprehensive-briefing-grounding-disposal.test.ts @@ -53,6 +53,11 @@ vi.mock("@/lib/ai/capabilities/gate", () => ({ reason: null, onDeviceAllowed: true, })), + aiCapabilityToServe: async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), })); vi.mock("@/lib/ai/provider", () => ({ resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a), diff --git a/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts b/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts index 4bcad810e..e40fa860c 100644 --- a/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts +++ b/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts @@ -53,6 +53,11 @@ vi.mock("@/lib/ai/capabilities/gate", () => ({ reason: null, onDeviceAllowed: true, })), + aiCapabilityToServe: async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), })); vi.mock("@/lib/ai/provider", () => ({ resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a), diff --git a/src/lib/insights/__tests__/comprehensive-budget-refusal.test.ts b/src/lib/insights/__tests__/comprehensive-budget-refusal.test.ts index 9ca1ab0b9..ee2d27221 100644 --- a/src/lib/insights/__tests__/comprehensive-budget-refusal.test.ts +++ b/src/lib/insights/__tests__/comprehensive-budget-refusal.test.ts @@ -78,6 +78,11 @@ vi.mock("@/lib/ai/capabilities/gate", () => ({ reason: null, onDeviceAllowed: true, })), + aiCapabilityToServe: async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), })); vi.mock("@/lib/ai/provider", () => ({ resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a), diff --git a/src/lib/insights/__tests__/comprehensive-generate-force.test.ts b/src/lib/insights/__tests__/comprehensive-generate-force.test.ts index eb101bd99..c9165b0c9 100644 --- a/src/lib/insights/__tests__/comprehensive-generate-force.test.ts +++ b/src/lib/insights/__tests__/comprehensive-generate-force.test.ts @@ -57,6 +57,8 @@ vi.mock("@/lib/ai/capabilities/egress", () => ({ })); vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityForRecord: (...a: unknown[]) => aiCapabilityForRecord(...a), + aiCapabilityToServe: (...args: unknown[]) => + (aiCapabilityForRecord as (...a: unknown[]) => unknown)(...args), })); vi.mock("@/lib/ai/provider", () => ({ resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a), diff --git a/src/lib/insights/__tests__/comprehensive-json-retry.test.ts b/src/lib/insights/__tests__/comprehensive-json-retry.test.ts index 791557886..a667ad361 100644 --- a/src/lib/insights/__tests__/comprehensive-json-retry.test.ts +++ b/src/lib/insights/__tests__/comprehensive-json-retry.test.ts @@ -51,6 +51,11 @@ vi.mock("@/lib/ai/capabilities/gate", () => ({ reason: null, onDeviceAllowed: true, })), + aiCapabilityToServe: async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), })); vi.mock("@/lib/ai/provider", () => ({ resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a), diff --git a/src/lib/insights/__tests__/comprehensive-privacy-flip.test.ts b/src/lib/insights/__tests__/comprehensive-privacy-flip.test.ts index 92607581e..83d13f02f 100644 --- a/src/lib/insights/__tests__/comprehensive-privacy-flip.test.ts +++ b/src/lib/insights/__tests__/comprehensive-privacy-flip.test.ts @@ -50,6 +50,11 @@ vi.mock("@/lib/ai/capabilities/gate", () => ({ reason: null, onDeviceAllowed: true, })), + aiCapabilityToServe: async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), })); vi.mock("@/lib/ai/provider", () => ({ resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a), diff --git a/src/lib/insights/__tests__/comprehensive-timeout-honored.test.ts b/src/lib/insights/__tests__/comprehensive-timeout-honored.test.ts index 7cc7486a0..825d2a6e8 100644 --- a/src/lib/insights/__tests__/comprehensive-timeout-honored.test.ts +++ b/src/lib/insights/__tests__/comprehensive-timeout-honored.test.ts @@ -55,6 +55,11 @@ vi.mock("@/lib/ai/capabilities/gate", () => ({ reason: null, onDeviceAllowed: true, })), + aiCapabilityToServe: async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), })); vi.mock("@/lib/ai/provider", () => ({ resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a), diff --git a/src/lib/insights/__tests__/comprehensive-warm-timeout.test.ts b/src/lib/insights/__tests__/comprehensive-warm-timeout.test.ts index e6f58c204..4f43377bc 100644 --- a/src/lib/insights/__tests__/comprehensive-warm-timeout.test.ts +++ b/src/lib/insights/__tests__/comprehensive-warm-timeout.test.ts @@ -57,6 +57,11 @@ vi.mock("@/lib/ai/capabilities/gate", () => ({ reason: null, onDeviceAllowed: true, })), + aiCapabilityToServe: async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), })); vi.mock("@/lib/ai/provider", () => ({ resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a), diff --git a/src/lib/insights/__tests__/general-status.test.ts b/src/lib/insights/__tests__/general-status.test.ts index e5fd0f0c8..5dded1ca4 100644 --- a/src/lib/insights/__tests__/general-status.test.ts +++ b/src/lib/insights/__tests__/general-status.test.ts @@ -31,6 +31,11 @@ vi.mock("@/lib/ai/capabilities/gate", () => ({ reason: null, onDeviceAllowed: true, }), + aiCapabilityToServe: async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), })); vi.mock("@/lib/insights/memory", () => ({ diff --git a/src/lib/insights/__tests__/invalidate-status-insights.test.ts b/src/lib/insights/__tests__/invalidate-status-insights.test.ts index 8ed1626a3..3a9e39463 100644 --- a/src/lib/insights/__tests__/invalidate-status-insights.test.ts +++ b/src/lib/insights/__tests__/invalidate-status-insights.test.ts @@ -44,6 +44,8 @@ vi.mock("@/lib/jobs/insight-status-generate-shared", () => ({ vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityForRecord: (...a: unknown[]) => aiCapabilityForRecord(...a), + aiCapabilityToServe: (...args: unknown[]) => + (aiCapabilityForRecord as (...a: unknown[]) => unknown)(...args), aiCapabilityForJob: vi.fn(async () => ({ available: true, reason: null, diff --git a/src/lib/insights/__tests__/locale-pipeline.test.ts b/src/lib/insights/__tests__/locale-pipeline.test.ts index cb8452d11..4254c33be 100644 --- a/src/lib/insights/__tests__/locale-pipeline.test.ts +++ b/src/lib/insights/__tests__/locale-pipeline.test.ts @@ -98,6 +98,11 @@ vi.mock("@/lib/ai/capabilities/gate", () => ({ reason: null, onDeviceAllowed: true, }), + aiCapabilityToServe: async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), })); vi.mock("@/lib/insights/memory", () => ({ diff --git a/src/lib/insights/__tests__/medication-compliance-status.test.ts b/src/lib/insights/__tests__/medication-compliance-status.test.ts index 7252318f9..858ad783d 100644 --- a/src/lib/insights/__tests__/medication-compliance-status.test.ts +++ b/src/lib/insights/__tests__/medication-compliance-status.test.ts @@ -29,6 +29,11 @@ vi.mock("@/lib/ai/capabilities/gate", () => ({ reason: null, onDeviceAllowed: true, }), + aiCapabilityToServe: async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), })); vi.mock("@/lib/insights/memory", () => ({ diff --git a/src/lib/insights/__tests__/metric-status.test.ts b/src/lib/insights/__tests__/metric-status.test.ts index acec22802..091f2cf74 100644 --- a/src/lib/insights/__tests__/metric-status.test.ts +++ b/src/lib/insights/__tests__/metric-status.test.ts @@ -38,6 +38,11 @@ vi.mock("@/lib/ai/capabilities/gate", () => ({ reason: null, onDeviceAllowed: true, }), + aiCapabilityToServe: async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), })); vi.mock("@/lib/insights/memory", () => ({ diff --git a/src/lib/insights/__tests__/mood-status.test.ts b/src/lib/insights/__tests__/mood-status.test.ts index f0dce0b46..ca7e09dcc 100644 --- a/src/lib/insights/__tests__/mood-status.test.ts +++ b/src/lib/insights/__tests__/mood-status.test.ts @@ -30,6 +30,11 @@ vi.mock("@/lib/ai/capabilities/gate", () => ({ reason: null, onDeviceAllowed: true, }), + aiCapabilityToServe: async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), })); vi.mock("@/lib/insights/memory", () => ({ diff --git a/src/lib/insights/__tests__/payload-budget.test.ts b/src/lib/insights/__tests__/payload-budget.test.ts index a20504dbd..8b2286742 100644 --- a/src/lib/insights/__tests__/payload-budget.test.ts +++ b/src/lib/insights/__tests__/payload-budget.test.ts @@ -54,6 +54,11 @@ vi.mock("@/lib/ai/capabilities/gate", () => ({ reason: null, onDeviceAllowed: true, }), + aiCapabilityToServe: async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), })); vi.mock("@/lib/insights/memory", () => ({ diff --git a/src/lib/insights/__tests__/pulse-status-timeout.test.ts b/src/lib/insights/__tests__/pulse-status-timeout.test.ts index 874b78cec..7a3ea488f 100644 --- a/src/lib/insights/__tests__/pulse-status-timeout.test.ts +++ b/src/lib/insights/__tests__/pulse-status-timeout.test.ts @@ -61,6 +61,11 @@ vi.mock("@/lib/ai/capabilities/gate", () => ({ reason: null, onDeviceAllowed: true, }), + aiCapabilityToServe: async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), })); vi.mock("@/lib/insights/memory", () => ({ diff --git a/src/lib/insights/__tests__/pulse-status.test.ts b/src/lib/insights/__tests__/pulse-status.test.ts index 54825a867..0e3ef9cff 100644 --- a/src/lib/insights/__tests__/pulse-status.test.ts +++ b/src/lib/insights/__tests__/pulse-status.test.ts @@ -42,6 +42,11 @@ vi.mock("@/lib/ai/capabilities/gate", () => ({ reason: null, onDeviceAllowed: true, }), + aiCapabilityToServe: async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), })); vi.mock("@/lib/insights/memory", () => ({ diff --git a/src/lib/insights/__tests__/status-cache-delegated.test.ts b/src/lib/insights/__tests__/status-cache-delegated.test.ts index 049fca9b4..b9018f420 100644 --- a/src/lib/insights/__tests__/status-cache-delegated.test.ts +++ b/src/lib/insights/__tests__/status-cache-delegated.test.ts @@ -38,6 +38,8 @@ vi.mock("@/lib/ai/provider", () => ({ vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityForRecord: (...args: unknown[]) => aiCapabilityForRecord(...(args as [])), + aiCapabilityToServe: (...args: unknown[]) => + (aiCapabilityForRecord as (...a: unknown[]) => unknown)(...args), })); vi.mock("@/lib/sharing/delegated-generation", () => ({ diff --git a/src/lib/insights/__tests__/status-cache.test.ts b/src/lib/insights/__tests__/status-cache.test.ts index 210abb34e..a6a36064f 100644 --- a/src/lib/insights/__tests__/status-cache.test.ts +++ b/src/lib/insights/__tests__/status-cache.test.ts @@ -30,6 +30,8 @@ vi.mock("@/lib/ai/coach/bytes-codec", () => ({ const aiCapabilityForRecord = vi.fn(); vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityForRecord: (...a: unknown[]) => aiCapabilityForRecord(...a), + aiCapabilityToServe: (...args: unknown[]) => + (aiCapabilityForRecord as (...a: unknown[]) => unknown)(...args), })); const probeProviderPresence = vi.fn(); diff --git a/src/lib/insights/__tests__/status-provider-budget.test.ts b/src/lib/insights/__tests__/status-provider-budget.test.ts index b1bbad4f5..c50af944a 100644 --- a/src/lib/insights/__tests__/status-provider-budget.test.ts +++ b/src/lib/insights/__tests__/status-provider-budget.test.ts @@ -75,7 +75,10 @@ vi.mock("@/lib/ai/provider", () => ({ const { aiCapabilityForRecord } = vi.hoisted(() => ({ aiCapabilityForRecord: vi.fn(), })); -vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityForRecord })); +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForRecord, + aiCapabilityToServe: aiCapabilityForRecord, +})); const { runRawCompletionWithFallback } = vi.hoisted(() => ({ runRawCompletionWithFallback: vi.fn(), diff --git a/src/lib/insights/__tests__/weight-status.test.ts b/src/lib/insights/__tests__/weight-status.test.ts index 805944536..065e9145f 100644 --- a/src/lib/insights/__tests__/weight-status.test.ts +++ b/src/lib/insights/__tests__/weight-status.test.ts @@ -48,7 +48,10 @@ const { probeProviderPresence: vi.fn(), enqueueStatusGeneration: vi.fn(), })); -vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityForRecord })); +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForRecord, + aiCapabilityToServe: aiCapabilityForRecord, +})); vi.mock("@/lib/ai/provider", async (importOriginal) => ({ ...(await importOriginal()), probeProviderPresence, diff --git a/src/lib/insights/narrative/__tests__/period-narrative-generate.test.ts b/src/lib/insights/narrative/__tests__/period-narrative-generate.test.ts index 21286fb9b..e44257ee4 100644 --- a/src/lib/insights/narrative/__tests__/period-narrative-generate.test.ts +++ b/src/lib/insights/narrative/__tests__/period-narrative-generate.test.ts @@ -20,7 +20,10 @@ vi.mock("@/lib/db", () => ({ prisma: {} })); const { aiCapabilityForRecord } = vi.hoisted(() => ({ aiCapabilityForRecord: vi.fn(), })); -vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityForRecord })); +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForRecord, + aiCapabilityToServe: aiCapabilityForRecord, +})); const UNAVAILABLE = { available: false, diff --git a/src/lib/insights/narrative/period-narrative-generate.ts b/src/lib/insights/narrative/period-narrative-generate.ts index 89d4209df..bb6cfc903 100644 --- a/src/lib/insights/narrative/period-narrative-generate.ts +++ b/src/lib/insights/narrative/period-narrative-generate.ts @@ -32,7 +32,7 @@ import { annotate } from "@/lib/logging/context"; import { AI_BUDGETS, REFERENCE_AI_SEED } from "@/lib/ai/ai-budgets"; import { dayRotatedSeed } from "@/lib/ai/prompts/opener-archetype"; import { runStatusCompletion } from "@/lib/insights/status-provider"; -import { aiCapabilityForRecord } from "@/lib/ai/capabilities/gate"; +import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate"; import { buildPeriodNarrativeContext, type NarrativePeriod, @@ -565,14 +565,15 @@ export async function readPeriodNarrative( /** * Whether a stored narrative may be served: always for the deterministic * narrative, and for model-written prose only while `periodNarrative` is - * available for the record. + * available for the record, answered from the record's own state whoever is + * reading. */ async function narrativeServable( userId: string, providerType: string | null, ): Promise { if (providerType === DETERMINISTIC_PROVIDER_TYPE) return true; - return (await aiCapabilityForRecord(userId, "periodNarrative")).available; + return (await aiCapabilityToServe(userId, "periodNarrative")).available; } /** Prisma `Bytes` ↔ ciphertext, mirroring the CoachMessage helper. */ diff --git a/src/lib/insights/status-cache.ts b/src/lib/insights/status-cache.ts index 126460113..71849bfb4 100644 --- a/src/lib/insights/status-cache.ts +++ b/src/lib/insights/status-cache.ts @@ -1,7 +1,7 @@ import type { MeasurementType } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; -import { aiCapabilityForRecord } from "@/lib/ai/capabilities/gate"; +import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate"; import type { AiUnavailableReason } from "@/lib/ai/capabilities/types"; import { probeProviderPresence } from "@/lib/ai/provider"; import { @@ -161,10 +161,12 @@ async function readStatusNote( /** * Whether stored status notes may be served, or re-dated, for this record. - * Every reader and both unchanged-data gates ask this first. + * Every reader and both unchanged-data gates ask this first. Answered from + * the record's own state, so a delegate reads the owner's notes exactly when + * the owner would; starting work is a separate question (see below). */ async function statusTextServable(userId: string): Promise { - return (await aiCapabilityForRecord(userId, "statusText")).available; + return (await aiCapabilityToServe(userId, "statusText")).available; } /** @@ -708,7 +710,9 @@ export async function resolveReadOnlyStatusMiss(args: { metric: InsightStatusScope; locale: SupportedLocale; }): Promise { - const capability = await aiCapabilityForRecord(args.userId, "statusText"); + // Serving view: whether the record's notes may be shown at all. Starting + // work for a delegate is refused by the suppression branch below. + const capability = await aiCapabilityToServe(args.userId, "statusText"); if (!capability.available) { const reason = capability.reason ?? "check_failed"; annotate({ diff --git a/tests/integration/ai-optional-mixed-routes.test.ts b/tests/integration/ai-optional-mixed-routes.test.ts index f849f52d9..e45966dc6 100644 --- a/tests/integration/ai-optional-mixed-routes.test.ts +++ b/tests/integration/ai-optional-mixed-routes.test.ts @@ -419,3 +419,72 @@ describe("the status family", () => { }, ); }); + +describe("inside somebody else's record (S8, a MANAGE delegate)", () => { + async function seedOwnerText(recordId: string): Promise { + await seed(recordId); + const prisma = getPrismaClient(); + // The narrative is stale, so an owner's read would warm it. + await prisma.insightNarrative.updateMany({ + where: { userId: recordId }, + data: { updatedAt: new Date(Date.now() - 48 * 60 * 60 * 1000) }, + }); + const { writeStatusNote, statusCacheAction } = + await import("@/lib/insights/status-cache"); + const { userDayKey } = await import("@/lib/tz/format"); + await writeStatusNote({ + userId: recordId, + cacheAction: statusCacheAction("weight", "en"), + todayKey: userDayKey(new Date(), "UTC"), + text: "The owner's weight note.", + }); + } + + it("reads the owner's stored model text exactly as the owner would", async () => { + await enterState("S8", seedOwnerText); + + const narrative = await readNarrative(); + expect(narrative.data.narrative?.text).toBe( + "A model wrote this retrospective.", + ); + expect(narrative.data.ai.available).toBe(true); + + const snapshot = await readSnapshot(); + expect(snapshot.data.briefing).not.toBeNull(); + + const digest = await readDigest(); + expect(digest.data.briefingLead).toBe("Model prose about your week."); + + const { GET } = await import("@/app/api/insights/weight-status/route"); + const status = await json<{ text: string | null }>( + await GET(new NextRequest("http://localhost/api/insights/weight-status")), + ); + expect(status.status).toBe(200); + expect(status.data.text).toBe("The owner's weight note."); + }); + + it("starts no model work on the owner's record", async () => { + await enterState("S8", seedOwnerText); + + // A stale narrative the owner's read would warm: the delegate's does not. + const narrative = await readNarrative(); + expect(narrative.data.revalidating).toBe(false); + expect(enqueueNarrativeWarm).not.toHaveBeenCalled(); + + // Every generating route refuses a delegate before any work. + const { POST: chat } = await import("@/app/api/insights/chat/route"); + const chatRes = await chat( + new NextRequest("http://localhost/api/insights/chat", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ message: "How was my week?" }), + }), + ); + expect(chatRes.status).toBe(403); + + const warm = await pregenerate(); + expect(warm.status).toBe(403); + expect(enqueueForceWarm).not.toHaveBeenCalled(); + expect(await getPrismaClient().coachConversation.count()).toBe(1); + }); +}); diff --git a/tests/integration/sharing-front-door.test.ts b/tests/integration/sharing-front-door.test.ts index 36c71d62d..301a918da 100644 --- a/tests/integration/sharing-front-door.test.ts +++ b/tests/integration/sharing-front-door.test.ts @@ -261,6 +261,17 @@ frontDoorRead("GET /api/dashboard/snapshot", { read: (data: { user: { username: string } }) => data.user.username, }); +/** The person's own provider (presence only) and an AI consent receipt. */ +function ownProvider() { + return { + aiProvider: "ANTHROPIC", + aiAnthropicKeyEncrypted: "v1:presence-only", + consentReceipts: { + create: { kind: "ai_full", artefact: "test", signedAt: new Date() }, + }, + }; +} + /** A parseable cached briefing — the only thing the digest lifts prose from. */ function cachedBriefing(marker: string): string { return JSON.stringify({ @@ -272,12 +283,16 @@ function cachedBriefing(marker: string): string { } frontDoorRead("GET /api/daily/digest", { + // The briefing lead is stored model text: shown whenever the record's own + // `briefing` capability allows it, whoever reads. Each account therefore + // has its own provider (presence only) and consent. seed: async (userId, marker) => { await getPrismaClient().user.update({ where: { id: userId }, data: { insightsCachedText: cachedBriefing(marker), insightsCachedAt: new Date(), + ...ownProvider(), }, }); }, @@ -342,6 +357,10 @@ async function seedAssistantMessage(userId: string, at: string) { frontDoorRead("GET /api/insights/coach/nudge-status", { seed: async (userId, marker) => { + await getPrismaClient().user.update({ + where: { id: userId }, + data: ownProvider(), + }); await seedAssistantMessage( userId, marker === "owner" ? "2026-07-01T09:00:00Z" : "2026-07-02T09:00:00Z",