From 2143445da2d8a506c63b7cf2a16b74290f3ac427 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 10:17:00 +0200 Subject: [PATCH] feat(ai): show stored model text inside a shared record as the owner would Serving stored text and starting model work are separate questions. A delegate may not start model work on somebody else's record, but reads the owner's stored briefing, period narrative, status notes, workout notes and Coach unread signal exactly when the owner would: those readers now resolve the record's own capability state, not the viewer's. Generation, warms and enqueues still ask as the viewer, so a delegate's read of a stale narrative queues nothing. A hidden model-written narrative is no longer overwritten by a deterministic warm while AI is unavailable. The sharing front door reads the digest's briefing lead again, and an integration case pins both halves. Refs #975 --- .../__tests__/route.test.ts | 8 +-- .../insights/biomarker-assessment/route.ts | 4 +- .../__tests__/route.test.ts | 14 ++-- .../insights/blood-pressure-status/route.ts | 4 +- src/app/api/insights/bmi-status/route.ts | 4 +- .../nudge-status/__tests__/route.test.ts | 12 ++-- .../api/insights/coach/nudge-status/route.ts | 9 +-- .../comprehensive/__tests__/route.test.ts | 12 ++-- src/app/api/insights/comprehensive/route.ts | 6 +- src/app/api/insights/derived/route.ts | 4 +- .../medication-compliance-status/route.ts | 4 +- .../metric-status/__tests__/route.test.ts | 8 +-- src/app/api/insights/metric-status/route.ts | 4 +- .../mood-status/__tests__/route.test.ts | 10 +-- src/app/api/insights/mood-status/route.ts | 4 +- src/app/api/insights/narrative/route.ts | 45 +++++++----- src/app/api/insights/pulse-status/route.ts | 4 +- src/app/api/insights/weight-status/route.ts | 4 +- src/app/api/workouts/[id]/route.ts | 4 +- src/app/api/workouts/__tests__/detail.test.ts | 15 ++-- .../__tests__/biomarker-status.test.ts | 5 ++ .../__tests__/blood-pressure-status.test.ts | 5 ++ src/lib/insights/__tests__/bmi-status.test.ts | 5 ++ ...ensive-briefing-grounding-disposal.test.ts | 5 ++ .../comprehensive-briefing-reroll.test.ts | 5 ++ .../comprehensive-budget-refusal.test.ts | 5 ++ .../comprehensive-generate-force.test.ts | 2 + .../comprehensive-json-retry.test.ts | 5 ++ .../comprehensive-privacy-flip.test.ts | 5 ++ .../comprehensive-timeout-honored.test.ts | 5 ++ .../comprehensive-warm-timeout.test.ts | 5 ++ .../insights/__tests__/general-status.test.ts | 5 ++ .../invalidate-status-insights.test.ts | 2 + .../__tests__/locale-pipeline.test.ts | 5 ++ .../medication-compliance-status.test.ts | 5 ++ .../insights/__tests__/metric-status.test.ts | 5 ++ .../insights/__tests__/mood-status.test.ts | 5 ++ .../insights/__tests__/payload-budget.test.ts | 5 ++ .../__tests__/pulse-status-timeout.test.ts | 5 ++ .../insights/__tests__/pulse-status.test.ts | 5 ++ .../__tests__/status-cache-delegated.test.ts | 2 + .../insights/__tests__/status-cache.test.ts | 2 + .../__tests__/status-provider-budget.test.ts | 5 +- .../insights/__tests__/weight-status.test.ts | 5 +- .../period-narrative-generate.test.ts | 5 +- .../narrative/period-narrative-generate.ts | 7 +- src/lib/insights/status-cache.ts | 12 ++-- .../ai-optional-mixed-routes.test.ts | 69 +++++++++++++++++++ tests/integration/sharing-front-door.test.ts | 19 +++++ 49 files changed, 306 insertions(+), 93 deletions(-) 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",