From f700a7c0f104e63efdc4b69709aae19a20dc15f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 12:19:11 +0200 Subject: [PATCH] fix(coach): let the capability table decide the about-me question consent The aboutMeQuestions capability admits an ai_insights_only receipt, but the question generator ran its own gate that demanded a Coach receipt, so a person whose capability read available still got the fallback questions on an operator-held chain. The generator now runs the shared wire re-check (aiEgressRefusal) for the aboutMeQuestions key, so the receipt kinds and the rule for when one is needed come from the capability table alone. --- .../self-context-questions-consent.test.ts | 75 +++++++++++++++---- src/lib/ai/coach/self-context-questions.ts | 33 ++++---- 2 files changed, 77 insertions(+), 31 deletions(-) diff --git a/src/lib/ai/coach/__tests__/self-context-questions-consent.test.ts b/src/lib/ai/coach/__tests__/self-context-questions-consent.test.ts index 7c2ca4a71..5931f593c 100644 --- a/src/lib/ai/coach/__tests__/self-context-questions-consent.test.ts +++ b/src/lib/ai/coach/__tests__/self-context-questions-consent.test.ts @@ -14,16 +14,33 @@ * - the budget runs through the ATOMIC reserve/reconcile pair, not a * read-then-write check. * - * `@/lib/consent/receipts` is mocked rather than the consent guard itself, so - * the real `chainRequiresServerManagedConsent` + `hasActiveConsentForSurface` - * logic is what these tests exercise. + * Only the receipt read is mocked, so the real wire re-check + * (`aiEgressRefusal`) and the capability table's `aboutMeQuestions` row are + * what these tests exercise: which receipts cover the prompt is the table's + * answer, not this module's. */ import { beforeEach, describe, expect, it, vi } from "vitest"; -vi.mock("@/lib/db", () => ({ prisma: {} })); - -const latestActiveReceipt = vi.hoisted(() => vi.fn()); -vi.mock("@/lib/consent/receipts", () => ({ latestActiveReceipt })); +const activeKinds = vi.hoisted(() => ({ kinds: new Set() })); +const consentFindFirst = vi.hoisted(() => + vi.fn(async (args: { where: { kind: { in: string[] } } }) => + args.where.kind.in.some((kind) => activeKinds.kinds.has(kind)) + ? { id: "receipt" } + : null, + ), +); +vi.mock("@/lib/db", () => ({ + prisma: { consentReceipt: { findFirst: consentFindFirst } }, +})); +// The capability itself is open; the route resolved it before calling in. +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForJob: vi.fn(async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + })), + requireAiCapability: vi.fn(async () => undefined), +})); vi.mock("@/lib/documents/document-settings", () => ({ documentAutoReadEnabled: vi.fn(async () => false), })); @@ -54,9 +71,13 @@ vi.mock("@/lib/ai/coach/budget", () => budgetMocks); const buildCoachSnapshot = vi.hoisted(() => vi.fn()); vi.mock("@/lib/ai/coach/snapshot", () => ({ buildCoachSnapshot })); -vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), + getEvent: () => null, +})); import { deriveClarifyingQuestions } from "../self-context-questions"; +import { AI_CAPABILITIES } from "@/lib/ai/capabilities/types"; import type { SelfContext } from "../about-me"; import { DEFAULT_HEALTH_PROFILE_AI_SECTIONS } from "@/lib/validations/health-profile-facts"; @@ -85,7 +106,7 @@ let provider: ReturnType; beforeEach(() => { vi.clearAllMocks(); provider = makeProvider(); - latestActiveReceipt.mockResolvedValue(null); + activeKinds.kinds = new Set(); providerMocks.hasAnyConfiguredProvider.mockResolvedValue(true); providerMocks.resolveProvider.mockResolvedValue({ type: "none" }); budgetMocks.reserveBudget.mockResolvedValue({ @@ -215,9 +236,7 @@ describe("deriveClarifyingQuestions — server-managed consent gate", () => { providerMocks.resolveProviderChain.mockResolvedValue([ { providerType: "admin-openai", instance: provider }, ]); - latestActiveReceipt.mockImplementation(async (_userId, kind) => - kind === "ai_coach" ? { id: "receipt-1" } : null, - ); + activeKinds.kinds = new Set(["ai_coach"]); const out = await deriveClarifyingQuestions( "user-1", @@ -236,9 +255,7 @@ describe("deriveClarifyingQuestions — server-managed consent gate", () => { providerMocks.resolveProviderChain.mockResolvedValue([ { providerType: "admin-openai", instance: provider }, ]); - latestActiveReceipt.mockImplementation(async (_userId, kind) => - kind === "ai_full" ? { id: "receipt-2" } : null, - ); + activeKinds.kinds = new Set(["ai_full"]); const out = await deriveClarifyingQuestions( "user-1", @@ -252,6 +269,32 @@ describe("deriveClarifyingQuestions — server-managed consent gate", () => { expect(provider.generateCompletion).toHaveBeenCalledTimes(1); }); + it("accepts the analysis-only grant the capability table lists", async () => { + // The capability the route resolved admits `ai_insights_only`; the + // question path used to demand a Coach receipt on top and fell back. + providerMocks.resolveProviderChain.mockResolvedValue([ + { providerType: "admin-openai", instance: provider }, + ]); + activeKinds.kinds = new Set(["ai_insights_only"]); + + const out = await deriveClarifyingQuestions( + "user-1", + ctx, + "en", + DEFAULT_HEALTH_PROFILE_AI_SECTIONS, + { aiAvailable: true }, + ); + + expect(out.source).toBe("ai"); + expect(consentFindFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + kind: { in: [...AI_CAPABILITIES.aboutMeQuestions.consent.kinds] }, + }), + }), + ); + }); + it("leaves a BYOK chain ungated — the user's own egress needs no receipt", async () => { providerMocks.resolveProviderChain.mockResolvedValue([ { providerType: "openai", instance: provider }, @@ -267,7 +310,7 @@ describe("deriveClarifyingQuestions — server-managed consent gate", () => { expect(out.source).toBe("ai"); expect(provider.generateCompletion).toHaveBeenCalledTimes(1); - expect(latestActiveReceipt).not.toHaveBeenCalled(); + expect(consentFindFirst).not.toHaveBeenCalled(); }); it("leaves a local chain ungated", async () => { diff --git a/src/lib/ai/coach/self-context-questions.ts b/src/lib/ai/coach/self-context-questions.ts index 4b8b175af..9131feb90 100644 --- a/src/lib/ai/coach/self-context-questions.ts +++ b/src/lib/ai/coach/self-context-questions.ts @@ -36,10 +36,7 @@ import { resolveProviderChain, } from "@/lib/ai/provider"; import type { ProviderChainResolved } from "@/lib/ai/provider-runner"; -import { - chainRequiresServerManagedConsent, - hasActiveConsentForSurface, -} from "@/lib/ai/consent-guard"; +import { aiEgressRefusal } from "@/lib/ai/capabilities/egress"; import { singleUserTurn } from "@/lib/ai/types"; import { buildDateKey, @@ -213,18 +210,24 @@ export async function deriveClarifyingQuestions( const chain = await resolveQuestionsChain(userId); if (chain === null) return fallback(); - // Consent gate — BEFORE the snapshot is built, let alone sent. This - // prompt ships the complete Coach snapshot, so a chain that could egress - // via the operator's server-managed credential needs an active - // `ai_coach` / `ai_full` receipt. BYOK / local / ChatGPT-OAuth chains are - // the user's own egress and stay ungated, matching every other surface. - if ( - chainRequiresServerManagedConsent(chain) && - !(await hasActiveConsentForSurface(userId, "coach")) - ) { + // The wire re-check — BEFORE the snapshot is built, let alone sent. The + // capability table's `aboutMeQuestions` row decides which receipts cover + // this prompt and when one is needed (a chain that could reach the + // operator's key or the shared central Codex; a person's own key, their + // ChatGPT account or a local model need none), so this path and the + // capability the route resolved cannot disagree. + const refusal = await aiEgressRefusal( + "aboutMeQuestions", + userId, + chain.map((entry) => entry.providerType), + ); + if (refusal) { annotate({ - action: { name: "coach.self_context.consent_required" }, - meta: { self_context_questions_source: "fallback" }, + action: { name: "coach.self_context.egress_refused" }, + meta: { + self_context_questions_source: "fallback", + reason: refusal.reason, + }, }); return fallback(); }