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.
This commit is contained in:
Marc-André Bombeck committed 2026-09-24 12:19:11 +02:00
1 parent 091e4b84cb
commit f700a7c0f1
2 files changed
+77 -31

No files matched your search

@@ -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<string>() }));
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<typeof makeProvider>;
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 () => {
+18 -15
View File
@@ -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();
}