mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
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:
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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user