From 2c1e0f5bca63ad9cb95b82152561a44065173571 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 12:15:56 +0200 Subject: [PATCH] fix(ai): drop model text whose consent was withdrawn while the call was in flight A consent withdrawal purges the stored status notes, briefing, reaction lines and workout paragraphs in its own transaction, but a provider call that was already in flight still persisted its reply afterwards, bringing the text back under a consent that no longer existed. The consent rule now runs once more after the reply arrives, against the provider that actually answered and with a fresh receipt read: in runStatusCompletion (status notes, workout paragraphs, narratives, derived assessments), before the briefing commit, and before the reaction-line commit. A refused reply is dropped; the spent tokens stay on the ledger. --- .../comprehensive-privacy-flip.test.ts | 44 ++++++++++++++++++- .../__tests__/status-provider-budget.test.ts | 25 +++++++++++ src/lib/insights/comprehensive-generate.ts | 23 +++++++++- src/lib/insights/status-provider.ts | 22 ++++++++++ .../reaction-line-degradation.test.ts | 34 ++++++++++++++ src/lib/jobs/reaction-line.ts | 11 +++++ 6 files changed, 157 insertions(+), 2 deletions(-) diff --git a/src/lib/insights/__tests__/comprehensive-privacy-flip.test.ts b/src/lib/insights/__tests__/comprehensive-privacy-flip.test.ts index 83d13f02f..0af17d76b 100644 --- a/src/lib/insights/__tests__/comprehensive-privacy-flip.test.ts +++ b/src/lib/insights/__tests__/comprehensive-privacy-flip.test.ts @@ -41,8 +41,9 @@ vi.mock("@/lib/db", () => ({ // path has its own test in comprehensive-generate-force.test.ts. // The wire re-check (`aiEgressRefusal`) passes in these fixtures; its refusal // arm has its own test in comprehensive-budget-refusal.test.ts. +const aiEgressRefusal = vi.fn(); vi.mock("@/lib/ai/capabilities/egress", () => ({ - aiEgressRefusal: vi.fn(async () => null), + aiEgressRefusal: (...a: unknown[]) => aiEgressRefusal(...a), })); vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityForRecord: vi.fn(async () => ({ @@ -139,6 +140,47 @@ beforeEach(() => { extractFeatures.mockResolvedValue(FEATURES); userUpdate.mockResolvedValue({}); getSelfContextTextForUser.mockResolvedValue(null); + aiEgressRefusal.mockResolvedValue(null); +}); + +describe("generateComprehensiveInsight — consent withdrawn in flight", () => { + it("drops the reply instead of writing it back over the purge", async () => { + findUnique.mockResolvedValue({ + insightsPrivacyMode: "aggregated", + insightsCachedAt: new Date(Date.now() - 26 * 60 * 60 * 1000), + insightsCachedText: JSON.stringify({ dailyBriefing: { p: "old" } }), + insightsExcludeMetrics: [], + insightsSnapshotHash: "0".repeat(64), + }); + runRawCompletionWithFallback.mockResolvedValue({ + result: { + content: JSON.stringify({ dailyBriefing: { p: "new" } }), + tokensUsed: 10, + providerType: "openai", + model: "m", + }, + workingProvider: { providerType: "openai" }, + fallbackHops: [], + }); + userUpdateMany.mockResolvedValue({ count: 1 }); + const { AiUnavailableError } = + await import("@/lib/ai/capabilities/refusal"); + // Open at the wire; withdrawn by the time the reply is back. + aiEgressRefusal + .mockResolvedValueOnce(null) + .mockResolvedValueOnce( + new AiUnavailableError("briefing", "consent_required"), + ); + + const outcome = await generateComprehensiveInsight("u1", { locale: "de" }); + + expect(outcome).toEqual({ status: "skipped", reason: "no-consent" }); + expect(aiEgressRefusal).toHaveBeenLastCalledWith("briefing", "u1", [ + "openai", + ]); + expect(userUpdateMany).not.toHaveBeenCalled(); + expect(invalidateUserInsights).not.toHaveBeenCalled(); + }); }); describe("generateComprehensiveInsight — scope/version commit guard", () => { diff --git a/src/lib/insights/__tests__/status-provider-budget.test.ts b/src/lib/insights/__tests__/status-provider-budget.test.ts index c50af944a..bc6bdedb7 100644 --- a/src/lib/insights/__tests__/status-provider-budget.test.ts +++ b/src/lib/insights/__tests__/status-provider-budget.test.ts @@ -188,6 +188,31 @@ describe("runStatusCompletion — capability at the wire", () => { ); }); + it("drops a reply whose consent was withdrawn while the call was in flight", async () => { + mockProviderReply(40); + // Open when the request leaves; withdrawn by the time the reply is back. + aiEgressRefusal + .mockResolvedValueOnce(null) + .mockResolvedValueOnce( + new AiUnavailableError("statusText", "consent_required"), + ); + + const outcome = await runStatusCompletion(completionArgs() as never); + + expect(outcome).toEqual({ kind: "none", reason: "consent_required" }); + // Re-checked against the provider that actually answered. + expect(aiEgressRefusal).toHaveBeenLastCalledWith("statusText", "u1", [ + "admin-openai", + ]); + // The tokens were spent upstream and stay on the ledger. + expect(ledgerTotal).toBe(40); + expect(annotate).toHaveBeenCalledWith( + expect.objectContaining({ + meta: expect.objectContaining({ at: "reply" }), + }), + ); + }); + it("returns none with reason no_provider when the chain is empty", async () => { resolveProviderChain.mockResolvedValue([]); const outcome = await runStatusCompletion(completionArgs() as never); diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts index cc29fcfa8..8d4d2ae40 100644 --- a/src/lib/insights/comprehensive-generate.ts +++ b/src/lib/insights/comprehensive-generate.ts @@ -508,7 +508,11 @@ function failBeforeProvider( } type InsightCommitResult = - "committed" | "scope-changed" | "profile-scope-changed"; + | "committed" + | "scope-changed" + | "profile-scope-changed" + | "no-consent" + | "unavailable"; /** * The exact User columns (besides `insightsPrivacyMode`, guarded @@ -530,6 +534,12 @@ type PromptScopeAtRead = { * was built from — without keying on the broad `updatedAt` timestamp, which * a provider credential refresh (e.g. `resolveProviderChain()`'s token * maintenance) also advances and would otherwise false-positive here. + * + * The consent rule runs once more too, against the provider that answered + * (`servedBy`, `null` for a timestamp-only stamp that writes no text), with a + * fresh receipt read: a withdrawal that landed while the + * call was in flight already purged the stored briefing, and this write would + * bring it back. */ async function commitInsightUnderScope( userId: string, @@ -537,8 +547,15 @@ async function commitInsightUnderScope( selfContextAtRead: string | null, promptScopeAtRead: PromptScopeAtRead, locale: SupportedLocale, + servedBy: string | null, data: Prisma.UserUpdateInput, ): Promise { + if (servedBy !== null) { + const late = await aiEgressRefusal("briefing", userId, [servedBy]); + if (late) { + return late.reason === "consent_required" ? "no-consent" : "unavailable"; + } + } const currentSelfContext = await getSelfContextTextForUser(userId, locale); if (currentSelfContext !== selfContextAtRead) { return "profile-scope-changed"; @@ -930,6 +947,7 @@ export async function generateComprehensiveInsight( aboutMe, promptScopeAtRead, locale, + rerolled.providerType, { insightsCachedAt: new Date(), insightsCachedText: rerolled.text, @@ -956,6 +974,7 @@ export async function generateComprehensiveInsight( aboutMe, promptScopeAtRead, locale, + null, { insightsCachedAt: new Date(), // The fingerprint covers the generation locale, so a matching hash @@ -979,6 +998,7 @@ export async function generateComprehensiveInsight( aboutMe, promptScopeAtRead, locale, + null, { insightsCachedAt: new Date(), insightsCachedLocale: locale, @@ -1322,6 +1342,7 @@ export async function generateComprehensiveInsight( aboutMe, promptScopeAtRead, locale, + workingProviderType, { insightsCachedAt: new Date(), insightsCachedText: JSON.stringify(insights), diff --git a/src/lib/insights/status-provider.ts b/src/lib/insights/status-provider.ts index bb0342bca..a5b0ff31d 100644 --- a/src/lib/insights/status-provider.ts +++ b/src/lib/insights/status-provider.ts @@ -321,6 +321,28 @@ export async function runStatusCompletion( return { kind: "error" }; } + // Consent withdrawn while the call was in flight. The withdrawal purged the + // stored text in its own transaction; a reply persisted after it would + // bring that text back under a consent that no longer exists. So the rule + // runs once more, against the provider that actually answered, with a fresh + // receipt read, and a refused reply is dropped as if it had never arrived. + // The tokens stay on the ledger: they were spent upstream. + const late = await aiEgressRefusal(args.capability, userId, [ + workingProvider.providerType, + ]); + if (late) { + annotate({ + action: { name: "insights.status.capability_unavailable" }, + meta: { + cacheAction, + capability: args.capability, + reason: late.reason, + at: "reply", + }, + }); + return { kind: "none", reason: late.reason }; + } + return { kind: "ok", content, diff --git a/src/lib/jobs/__tests__/reaction-line-degradation.test.ts b/src/lib/jobs/__tests__/reaction-line-degradation.test.ts index aed073f31..f9ba8ad97 100644 --- a/src/lib/jobs/__tests__/reaction-line-degradation.test.ts +++ b/src/lib/jobs/__tests__/reaction-line-degradation.test.ts @@ -709,6 +709,40 @@ describe("reaction line — degradation", () => { expectSurfaceStillWorks(); }); + it("consent withdrawn while the call was in flight: writes no line", async () => { + resolveProviderChain.mockResolvedValue([ + { + providerType: "openai", + instance: { + generateCompletion: vi.fn().mockResolvedValue({ + content: "A solid night, deeper than your recent stretch.", + tokensUsed: 1_100, + cachedInputTokens: 100, + }), + }, + }, + ]); + aiEgressRefusal + .mockResolvedValueOnce(null) + .mockResolvedValueOnce( + new AiUnavailableError("reactionLines", "consent_required"), + ); + + const outcome = await runReactionLine(JOB); + + expect(outcome).toEqual({ status: "skipped", reason: "consent_required" }); + expect(aiEgressRefusal).toHaveBeenLastCalledWith("reactionLines", "u1", [ + "openai", + ]); + expect( + updateMany.mock.calls.some( + ([arg]) => + (arg as { data?: { lineEncrypted?: unknown } }).data + ?.lineEncrypted instanceof Uint8Array, + ), + ).toBe(false); + }); + it("a committed line is never regenerated — the unique row is the throttle", async () => { findUnique.mockResolvedValue({ id: "r1", generatedAt: new Date() }); resolveProviderChain.mockResolvedValue([ diff --git a/src/lib/jobs/reaction-line.ts b/src/lib/jobs/reaction-line.ts index a036f0025..5d3084acf 100644 --- a/src/lib/jobs/reaction-line.ts +++ b/src/lib/jobs/reaction-line.ts @@ -710,6 +710,17 @@ export async function runReactionLine( return { status: "skipped", reason: "ungrounded_output" }; } + // Consent withdrawn while the call was in flight: the withdrawal cleared + // stored lines in its own transaction, and this commit would write one + // back. The rule once more, for the provider that answered, fresh. + const late = await aiEgressRefusal("reactionLines", job.userId, [ + chain[0].providerType, + ]); + if (late) { + await finishTerminalAttempt().catch(() => {}); + return { status: "skipped", reason: late.reason }; + } + const committed = await prisma.arrivalReaction.updateMany({ where: { id: row.id,