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.
This commit is contained in:
Marc-André Bombeck committed 2026-09-24 12:15:56 +02:00
1 parent bd58571d46
commit 2c1e0f5bca
6 files changed
+157 -2

No files matched your search

@@ -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", () => {
@@ -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);
+22 -1
View File
@@ -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<InsightCommitResult> {
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),
+22
View File
@@ -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,
@@ -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([
+11
View File
@@ -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,