feat(ai): resolve the AI capability before any background model work

Every AI job now asks its capability for the user before it builds a
snapshot, and skips with a `.skipped` annotation naming the reason: the
nightly pregenerate (briefing and statusText, each half on its own), the
status batch and the per-metric crons, the on-demand status worker, the
narrative cron, Coach nudges and their AI composer, the Coach memory
refresh, reaction lines, workout paragraphs, and the document summary,
catch-up and index jobs. Seven of these read no assistant switch at all
before, so the master switch now stops them too.

The cron candidate queries drop people who switched AI analysis off in
SQL, and no longer read `disableCoach` outside the Coach: hiding the
Coach hides the Coach. Coach nudges use the one presence probe, so a
central Codex account is no longer skipped; a missing consent still gets
the deterministic template. The arrival spine asks only the
provider-free half (switches and modules) before it enqueues, keeping
its module graph free of provider code.

Document reading needs an extraction receipt (ai_extraction or ai_full)
for any external provider; the auto-read toggle is the trigger, no longer
a consent shortcut, so a revoked receipt wins over a toggle left on. Lab
report scans get the same rule for a whole chain.

Two structural guards: every file that calls a provider is named with
the reason its call is admitted, and the chokepoints re-check a
capability; every AI job references its capability gate. An integration
matrix runs each worker under the refusing states with a provider spy.

Refs #975
This commit is contained in:
Marc-André Bombeck committed 2026-09-24 09:07:16 +02:00
1 parent b36c7b6697
commit 3917a2face
42 files changed
+2389 -382

No files matched your search

+289
View File
@@ -0,0 +1,289 @@
/**
* The AI background jobs, run directly against real Postgres under the states
* that must stop them, with a spy in place of the provider.
*
* For every worker and every refusing state: the spy is never called, the
* worker writes no model text, and a skip annotation is recorded. Under the
* all-on baseline the spy IS called, which is what proves the test can see a
* call at all (a spy that no path reaches would pass every refusing case).
*
* States (design "AI optional", 6.2):
* S0 all on: the person's own key, every switch on.
* S1 the operator's master switch off.
* S3 no provider configured anywhere.
* S4 only the operator's key, and no consent receipt.
* S5 the Coach hidden (`disableCoach`), which stops only the Coach.
* S6 AI analysis switched off (the `insights` module), which stops
* everything but the Coach.
*
* Only the provider chain is replaced; the capability resolution, the
* presence probe, the budget ledger and the writes all run for real.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
const providerSpy = vi.fn(async () => ({
content: JSON.stringify({ summary: "A calm and steady stretch." }),
model: "spy-model",
tokensUsed: 12,
}));
vi.mock("@/lib/ai/provider", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/ai/provider")>();
const chain = () => [
{
providerType: "anthropic",
instance: { type: "anthropic", generateCompletion: providerSpy },
},
];
return {
...actual,
resolveProviderChain: vi.fn(async () => chain()),
resolveProvider: vi.fn(async () => ({ type: "none" })),
};
});
const annotations: string[] = [];
vi.mock("@/lib/logging/context", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/logging/context")>();
return {
...actual,
annotate: vi.fn((event: { action?: { name?: string } }) => {
if (event?.action?.name) annotations.push(event.action.name);
return actual.annotate(event as never);
}),
};
});
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { runCoachMemoryRefresh } from "@/lib/ai/coach/coach-memory-refresh-worker";
import { generateStatusBatchForUser } from "@/lib/insights/status-batch";
import { runInsightStatusGenerate } from "@/lib/jobs/insight-status-generate";
import { runReactionLine } from "@/lib/jobs/reaction-line";
import { runWorkoutInsightGenerate } from "@/lib/jobs/workout-insight-generate";
import { userDayKey } from "@/lib/tz/resolver";
type State = "S0" | "S1" | "S3" | "S4" | "S5" | "S6";
let counter = 0;
/** One account in the given state, with the data every worker below reads. */
async function seedState(state: State) {
const prisma = getPrismaClient();
await prisma.appSettings.upsert({
where: { id: "singleton" },
create: {
id: "singleton",
assistantEnabled: state !== "S1",
adminAiKeyEncrypted: state === "S4" ? "operator-key-present" : null,
},
update: {
assistantEnabled: state !== "S1",
adminAiKeyEncrypted: state === "S4" ? "operator-key-present" : null,
},
});
const ownKey = state !== "S3" && state !== "S4";
const suffix = `${state.toLowerCase()}-${counter++}`;
const user = await prisma.user.create({
data: {
username: `jobs-${suffix}`,
email: `jobs-${suffix}@example.test`,
timezone: "UTC",
locale: "en",
heightCm: 180,
disableCoach: state === "S5",
modulePreferencesJson: state === "S6" ? { insights: false } : undefined,
aiProviderChain: ownKey
? [{ providerType: "anthropic", enabled: true }]
: undefined,
aiAnthropicKeyEncrypted: ownKey ? "key-present" : null,
},
});
const now = Date.now();
const DAY = 24 * 60 * 60 * 1000;
await prisma.measurement.createMany({
data: Array.from({ length: 30 }, (_, i) => ({
userId: user.id,
type: "WEIGHT" as const,
value: 82 - i * 0.05,
unit: "kg",
source: "MANUAL" as const,
measuredAt: new Date(now - i * DAY),
})),
});
const workout = await prisma.workout.create({
data: {
userId: user.id,
sportType: "cycling",
startedAt: new Date(now - 2 * 60 * 60 * 1000),
endedAt: new Date(now - 60 * 60 * 1000),
durationSec: 3600,
source: "APPLE_HEALTH",
externalId: `workout-${suffix}`,
},
});
const occurredAt = new Date(now - 5 * 60 * 1000);
const localDate = userDayKey(new Date(now), "UTC");
await prisma.arrivalReaction.create({
data: { userId: user.id, kind: "weight", localDate, occurredAt },
});
// Long enough that the rolling summary has turns to fold.
const conversation = await prisma.coachConversation.create({
data: { userId: user.id, title: "long conversation" },
});
await prisma.coachMessage.createMany({
data: Array.from({ length: 40 }, (_, i) => ({
conversationId: conversation.id,
role: i % 2 === 0 ? "user" : "assistant",
encryptedContent: encryptToBytes(`turn ${i}: about sleep and walks`),
createdAt: new Date(now - (40 - i) * 60_000),
})),
});
return {
userId: user.id,
workoutId: workout.id,
reaction: {
userId: user.id,
kind: "weight" as const,
localDate,
revision: occurredAt.toISOString(),
},
conversationId: conversation.id,
};
}
type Seeded = Awaited<ReturnType<typeof seedState>>;
interface Worker {
name: string;
/** Which states must stop it. */
refusedIn: readonly State[];
run: (s: Seeded) => Promise<unknown>;
/** Whether the worker wrote model text for this account. */
wroteText: (s: Seeded) => Promise<boolean>;
/** The skip annotation it records when refused. */
skipAnnotation: string;
}
const ANALYSIS_REFUSED: readonly State[] = ["S1", "S3", "S4", "S6"];
const COACH_REFUSED: readonly State[] = ["S1", "S3", "S4", "S5"];
const WORKERS: Worker[] = [
{
name: "insight-status-generate (statusText)",
refusedIn: ANALYSIS_REFUSED,
run: (s) =>
runInsightStatusGenerate({
userId: s.userId,
metric: "weight",
locale: "en",
authority: {
origin: "owner",
recordUserId: s.userId,
actorUserId: s.userId,
grantId: null,
},
}),
wroteText: async (s) =>
(await getPrismaClient().insightStatusCache.count({
where: { userId: s.userId, textEncrypted: { not: null } },
})) > 0,
skipAnnotation: "insights.status.generate.skipped",
},
{
name: "nightly status batch (statusText)",
refusedIn: ANALYSIS_REFUSED,
run: (s) => generateStatusBatchForUser(s.userId, { locale: "en" }),
wroteText: async (s) =>
(await getPrismaClient().insightStatusCache.count({
where: { userId: s.userId, textEncrypted: { not: null } },
})) > 0,
skipAnnotation: "insights.status.batch.skipped",
},
{
name: "workout-insight-generate (workoutInsights)",
refusedIn: ANALYSIS_REFUSED,
run: (s) =>
runWorkoutInsightGenerate({ userId: s.userId, workoutId: s.workoutId }),
wroteText: async (s) =>
(await getPrismaClient().workoutInsight.count({
where: { userId: s.userId },
})) > 0,
skipAnnotation: "workouts.insight.skipped",
},
{
name: "reaction-line (reactionLines)",
refusedIn: ANALYSIS_REFUSED,
run: (s) => runReactionLine(s.reaction),
wroteText: async (s) =>
(await getPrismaClient().arrivalReaction.count({
where: { userId: s.userId, lineEncrypted: { not: null } },
})) > 0,
skipAnnotation: "arrival.reaction.skipped",
},
{
name: "coach-memory-refresh (coach)",
refusedIn: COACH_REFUSED,
run: (s) =>
runCoachMemoryRefresh({
conversationId: s.conversationId,
userId: s.userId,
locale: "en",
}),
wroteText: async (s) =>
(await getPrismaClient().coachConversation.count({
where: { id: s.conversationId, summaryEncrypted: { not: null } },
})) > 0,
skipAnnotation: "coach.memory.refresh.skipped",
},
];
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
providerSpy.mockClear();
annotations.length = 0;
});
describe.each(WORKERS)("$name", (worker) => {
it("calls the provider under S0 (the spy can see a call)", async () => {
const seeded = await seedState("S0");
await worker.run(seeded);
expect(providerSpy).toHaveBeenCalled();
expect(annotations).not.toContain(worker.skipAnnotation);
});
it.each(worker.refusedIn)(
"never reaches the provider and writes nothing under %s",
async (state) => {
const seeded = await seedState(state);
await worker.run(seeded);
expect(providerSpy).not.toHaveBeenCalled();
expect(await worker.wroteText(seeded)).toBe(false);
expect(annotations).toContain(worker.skipAnnotation);
},
);
});
describe("the Coach and the analysis are separate opt-outs", () => {
it("hiding the Coach does not stop the status notes", async () => {
const seeded = await seedState("S5");
await WORKERS[0].run(seeded);
expect(providerSpy).toHaveBeenCalled();
});
it("switching AI analysis off does not stop the Coach memory", async () => {
const seeded = await seedState("S6");
await WORKERS[4].run(seeded);
expect(providerSpy).toHaveBeenCalled();
});
});
@@ -0,0 +1,327 @@
/**
* Consent withdrawal deletes the regenerable model-written text, against real
* Postgres, and keeps the person's own records.
*
* What is pinned here and nowhere else:
*
* - the purge reaches every surface the analysis consent covered: status
* notes, the cached briefing, model-written narratives (the deterministic
* one stays), reaction lines (the marker stays), workout paragraphs;
* - Coach conversations and facts, and a document's stored summary, stay;
* - revoke and purge are one transaction: a purge that fails leaves the
* receipt active and every row in place;
* - a withdrawal that leaves the analysis covered, or that never touched
* it, deletes nothing.
*/
import { beforeEach, describe, expect, it } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
import {
purgeRegenerableAiText,
withdrawConsent,
} from "@/lib/consent/withdrawal";
const BYTES = Buffer.from("ciphertext-stand-in", "utf8");
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
});
async function seed(username: string, kinds: string[]) {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username,
email: `${username}@example.test`,
insightsCachedText: JSON.stringify({ briefing: "model prose" }),
insightsCachedAt: new Date(),
insightsSnapshotHash: "hash",
},
});
for (const kind of kinds) {
await prisma.consentReceipt.create({
data: { userId: user.id, kind, artefact: "{}", signedAt: new Date() },
});
}
await prisma.insightStatusCache.createMany({
data: [
{
userId: user.id,
metric: "weight",
locale: "en",
dateKey: "2026-09-20",
textEncrypted: BYTES,
},
{
userId: user.id,
metric: "metric:RESTING_HEART_RATE",
locale: "en",
dateKey: "2026-09-20",
textEncrypted: BYTES,
},
],
});
await prisma.insightNarrative.createMany({
data: [
{
userId: user.id,
period: "week",
locale: "en",
dateKey: "2026-09-20",
encryptedContent: BYTES,
providerType: "anthropic",
},
{
userId: user.id,
period: "month",
locale: "en",
dateKey: "2026-09-20",
encryptedContent: BYTES,
providerType: "deterministic",
},
],
});
await prisma.arrivalReaction.create({
data: {
userId: user.id,
kind: "weight",
localDate: "2026-09-20",
occurredAt: new Date(),
lineEncrypted: BYTES,
generatedAt: new Date(),
},
});
const workout = await prisma.workout.create({
data: {
userId: user.id,
sportType: "cycling",
startedAt: new Date("2026-09-20T06:00:00Z"),
endedAt: new Date("2026-09-20T06:45:00Z"),
durationSec: 2700,
source: "APPLE_HEALTH",
externalId: `ext-${username}`,
},
});
await prisma.workoutInsight.create({
data: {
userId: user.id,
workoutId: workout.id,
paragraphEncrypted: BYTES,
inputHash: "h",
promptVersion: "1",
providerType: "anthropic",
locale: "en",
generatedAt: new Date(),
},
});
const conversation = await prisma.coachConversation.create({
data: { userId: user.id, title: "t", summaryEncrypted: BYTES },
});
await prisma.coachFact.create({
data: {
userId: user.id,
factEncrypted: BYTES,
category: "goal",
sourceConversationId: conversation.id,
},
});
await prisma.inboundDocument.create({
data: {
userId: user.id,
mimeType: "image/png",
byteSize: BYTES.byteLength,
contentEncrypted: BYTES,
summaryEncrypted: BYTES,
summaryState: "READY",
},
});
return user;
}
async function snapshot(userId: string) {
const prisma = getPrismaClient();
const [
user,
notes,
narratives,
reaction,
workouts,
conversations,
facts,
docs,
] = await Promise.all([
prisma.user.findUniqueOrThrow({
where: { id: userId },
select: {
insightsCachedText: true,
insightsCachedAt: true,
insightsSnapshotHash: true,
},
}),
prisma.insightStatusCache.count({ where: { userId } }),
prisma.insightNarrative.findMany({
where: { userId },
select: { providerType: true },
}),
prisma.arrivalReaction.findFirstOrThrow({
where: { userId },
select: { lineEncrypted: true, generatedAt: true },
}),
prisma.workoutInsight.count({ where: { userId } }),
prisma.coachConversation.findMany({
where: { userId },
select: { summaryEncrypted: true },
}),
prisma.coachFact.count({ where: { userId } }),
prisma.inboundDocument.findMany({
where: { userId },
select: { summaryEncrypted: true },
}),
]);
return {
user,
notes,
narratives,
reaction,
workouts,
conversations,
facts,
docs,
};
}
describe("withdrawing the analysis consent", () => {
it("deletes every regenerable text and keeps the person's own records", async () => {
const user = await seed("purge-all", ["ai_full"]);
const result = await withdrawConsent(user.id, ["ai_full"]);
expect(result.revoked.map((r) => r.kind)).toEqual(["ai_full"]);
expect(result.purged).toEqual({
statusNotes: 2,
briefing: 1,
narratives: 1,
reactionLines: 1,
workoutParagraphs: 1,
});
const after = await snapshot(user.id);
expect(after.user).toEqual({
insightsCachedText: null,
insightsCachedAt: null,
insightsSnapshotHash: null,
});
expect(after.notes).toBe(0);
expect(after.narratives).toEqual([{ providerType: "deterministic" }]);
// The marker stays and stays "generated", so nothing writes it again.
expect(after.reaction.lineEncrypted).toBeNull();
expect(after.reaction.generatedAt).not.toBeNull();
expect(after.workouts).toBe(0);
// The person's own records.
expect(after.conversations).toHaveLength(1);
expect(after.conversations[0].summaryEncrypted).not.toBeNull();
expect(after.facts).toBe(1);
expect(after.docs[0].summaryEncrypted).not.toBeNull();
const receipts = await getPrismaClient().consentReceipt.findMany({
where: { userId: user.id },
});
expect(receipts).toHaveLength(1);
expect(receipts[0].revokedAt).not.toBeNull();
});
it("is atomic: a failing purge leaves the receipt active and every row in place", async () => {
const user = await seed("purge-atomic", ["ai_insights_only"]);
const before = await snapshot(user.id);
await expect(
withdrawConsent(
user.id,
["ai_insights_only"],
new Date(),
async (tx, id) => {
// Do part of the work, then fail: the part must roll back too.
await purgeRegenerableAiText(tx, id);
throw new Error("purge failed");
},
),
).rejects.toThrow("purge failed");
const receipt = await getPrismaClient().consentReceipt.findFirstOrThrow({
where: { userId: user.id },
});
expect(receipt.revokedAt).toBeNull();
expect(await snapshot(user.id)).toEqual(before);
});
it("deletes nothing while another receipt still covers the analysis", async () => {
const user = await seed("purge-covered", ["ai_full", "ai_insights_only"]);
const before = await snapshot(user.id);
const result = await withdrawConsent(user.id, ["ai_full"]);
expect(result.purged).toBeNull();
expect(await snapshot(user.id)).toEqual(before);
});
it("deletes nothing for a withdrawal of the Coach or extraction consent alone", async () => {
const user = await seed("purge-other", ["ai_coach", "ai_extraction"]);
const before = await snapshot(user.id);
const result = await withdrawConsent(user.id, [
"ai_coach",
"ai_extraction",
]);
expect(result.revoked).toHaveLength(2);
expect(result.purged).toBeNull();
expect(await snapshot(user.id)).toEqual(before);
});
it("touches no other account", async () => {
const user = await seed("purge-me", ["ai_full"]);
const other = await seed("purge-other-account", ["ai_full"]);
const otherBefore = await snapshot(other.id);
await withdrawConsent(user.id, ["ai_full"]);
expect(await snapshot(other.id)).toEqual(otherBefore);
});
});
describe("a revoked extraction receipt wins over the auto-read toggle", () => {
it("refuses external document egress once the receipt is revoked, toggle still on", async () => {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username: "k1-toggle",
email: "k1-toggle@example.test",
documentsAutoAiRead: true,
},
});
await prisma.consentReceipt.create({
data: {
userId: user.id,
kind: "ai_extraction",
artefact: "{}",
signedAt: new Date(),
},
});
const { assertDocumentEgressConsent, ConsentRequiredError } =
await import("@/lib/ai/consent-guard");
await expect(
assertDocumentEgressConsent({ userId: user.id, providerType: "codex" }),
).resolves.toBeUndefined();
await withdrawConsent(user.id, ["ai_extraction"]);
await expect(
assertDocumentEgressConsent({ userId: user.id, providerType: "codex" }),
).rejects.toBeInstanceOf(ConsentRequiredError);
// A local pick never leaves the machine and stays admitted.
await expect(
assertDocumentEgressConsent({ userId: user.id, providerType: "local" }),
).resolves.toBeUndefined();
});
});