Skip to content

Commit 79a45fc

Browse files
committed
feat(ai): capstone AI-off via the credential-gate constant (#867)
The capstone credential attests that the learner shipped the program themselves, so it certifies nothing if a tutor could have written it. Until now the AI path had only structural absence (the capstone happens to carry no code block) plus quiz-level aiSuppressed — no code read a capstone constant, so a content edit could silently re-enable AI on the very lesson the credential rests on. Enforcement now keys off the SAME constant as the credential gate: - lib/credentials/capstone-identity.ts — the one definition site, deliberately not server-only so the client can read it. capstone-gate re-exports it, so every existing importer is unchanged. - /api/ai/partner refuses the capstone with 403 + `capstone_ai_off`, before any spend (no assist turn, no ledger write, no Gemini call) and before the codeBlock 404 so the refusal is typed rather than incidental. - /api/lessons/reflect suppresses the AI reply on the capstone; the seal is still returned unconditionally (receipt-first untouched). - The pane renders the AI-free explanation in place of its actions, threaded from the same constant; copy in en / pt-BR / es.
1 parent 3de1ddf commit 79a45fc

19 files changed

Lines changed: 734 additions & 25 deletions

File tree

apps/web/src/app/[locale]/(platform)/courses/[slug]/lessons/[id]/blocks/__tests__/open-ended-block.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ function makeCtx(overrides: Partial<BlockContext> = {}): BlockContext {
3636
setProof: vi.fn(),
3737
setQuizAnswered: vi.fn(),
3838
aiSuppressed: true,
39+
capstoneAiOff: false,
3940
buildUuid: null,
4041
programKeypairSecret: null,
4142
resetBuild: vi.fn(),

apps/web/src/app/[locale]/(platform)/courses/[slug]/lessons/[id]/blocks/__tests__/parsons-block.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ function makeCtx(overrides: Partial<BlockContext> = {}): BlockContext {
4343
setProof: vi.fn(),
4444
setQuizAnswered: vi.fn(),
4545
aiSuppressed: true,
46+
capstoneAiOff: false,
4647
buildUuid: null,
4748
programKeypairSecret: null,
4849
resetBuild: vi.fn(),

apps/web/src/app/[locale]/(platform)/courses/[slug]/lessons/[id]/blocks/__tests__/quiz-block.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ function makeCtx(overrides: Partial<BlockContext> = {}): BlockContext {
7272
setProof: vi.fn(),
7373
setQuizAnswered: vi.fn(),
7474
aiSuppressed: true,
75+
capstoneAiOff: false,
7576
buildUuid: null,
7677
programKeypairSecret: null,
7778
resetBuild: vi.fn(),

apps/web/src/app/[locale]/(platform)/courses/[slug]/lessons/[id]/blocks/code-block.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export function CodeBlock({ block, ctx }: BlockRenderProps) {
5656
isEnrolled={ctx.isEnrolled}
5757
onEnroll={ctx.onEnroll}
5858
aiSuppressed={ctx.aiSuppressed}
59+
capstoneAiOff={ctx.capstoneAiOff}
5960
className="h-full"
6061
/>
6162
</div>

apps/web/src/app/[locale]/(platform)/courses/[slug]/lessons/[id]/blocks/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,15 @@ export interface BlockContext {
3030
* block threads it to ChallengeInterface, which keeps the AI Partner hidden.
3131
*/
3232
aiSuppressed: boolean;
33+
/**
34+
* True on THE graded capstone lesson (#867) — derived from
35+
* `lib/credentials/capstone-identity`, the same constant the credential gate
36+
* uses. Unlike `aiSuppressed` this is permanent and explained: the code block
37+
* threads it to ChallengeInterface, which renders the AI-free rationale where
38+
* the AI Partner would sit. The server refuses independently
39+
* (`/api/ai/partner`) — this is presentation, not the enforcement.
40+
*/
41+
capstoneAiOff: boolean;
3342
/** Latest successful build (for deployable code + the deployed-program card). */
3443
buildUuid: string | null;
3544
programKeypairSecret: number[] | null;

apps/web/src/app/[locale]/(platform)/courses/[slug]/lessons/[id]/lesson-client.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { AuthModal } from "@/components/auth/auth-modal";
1818
import { trackEvent } from "@/lib/analytics";
1919
import { completionErrorKey } from "@/lib/lessons/completion-error";
2020
import { createClient } from "@/lib/supabase/client";
21+
import { isCapstoneLesson } from "@/lib/credentials/capstone-identity";
2122
import { useAuth } from "@/lib/auth/auth-provider";
2223
import { useOnChainEnroll } from "@/hooks/use-on-chain-enroll";
2324
import { bankCompletion, removeBanked } from "@/lib/lessons/progress-bank";
@@ -223,6 +224,16 @@ export function LessonPageClient({
223224
prev[blockKey] === answered ? prev : { ...prev, [blockKey]: answered }
224225
);
225226
}, []);
227+
// AI hard-off on the capstone (#867) — the CLIENT leg of the server refusal
228+
// in /api/ai/partner, derived from the same `capstone-identity` constant as
229+
// the credential gate (never a second hardcoded id). Distinct from
230+
// `aiSuppressed`: quiz suppression is temporary (answer the quiz and the
231+
// tutor returns) and simply HIDES the pane, whereas this is permanent and
232+
// must be EXPLAINED — the pane renders the AI-free rationale in place of its
233+
// actions, so a learner who expected a tutor learns why there isn't one
234+
// rather than finding a blank column.
235+
const capstoneAiOff = isCapstoneLesson(lesson._id);
236+
226237
const aiSuppressed =
227238
hasQuizBlock &&
228239
!isCompleted &&
@@ -401,6 +412,7 @@ export function LessonPageClient({
401412
setProof,
402413
setQuizAnswered,
403414
aiSuppressed,
415+
capstoneAiOff,
404416
buildUuid,
405417
programKeypairSecret,
406418
resetBuild,
@@ -418,6 +430,7 @@ export function LessonPageClient({
418430
setProof,
419431
setQuizAnswered,
420432
aiSuppressed,
433+
capstoneAiOff,
421434
buildUuid,
422435
programKeypairSecret,
423436
resetBuild,
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2+
import { NextRequest } from "next/server";
3+
4+
// AI hard-off on the capstone (#867, unified spec item 33) — the SERVER leg.
5+
//
6+
// RED-PROOF (measured): with this file's route change reverted, 3 of these 4
7+
// tests fail — `expected 200 to be 403` and `spendAssistTurn … called 1 times`.
8+
// No capstone check exists at main, so the request falls straight through and
9+
// is SERVED, spending a turn and hitting Gemini.
10+
//
11+
// That 200 is the point. Today's capstone happens to carry no `code` block, so
12+
// the route 404s there by accident — the "structural absence" this issue
13+
// exists to replace. The fixtures below deliberately give the capstone a code
14+
// block, which is precisely the content edit that would silently re-enable AI
15+
// on the lesson the credential rests on.
16+
17+
vi.mock("server-only", () => ({}));
18+
19+
const getUser = vi.fn();
20+
vi.mock("@/lib/supabase/server", () => ({
21+
createClient: async () => ({ auth: { getUser } }),
22+
}));
23+
24+
const spendAssistTurn = vi.fn();
25+
const refundAssistTurn = vi.fn();
26+
const recordBilledAssist = vi.fn();
27+
const appendAssistLog = vi.fn();
28+
vi.mock("@/lib/ai/assist-budget", () => ({
29+
spendAssistTurn,
30+
refundAssistTurn,
31+
recordBilledAssist,
32+
appendAssistLog,
33+
MAX_PAID_ASSISTS: 4,
34+
}));
35+
36+
const isRateLimited = vi.fn();
37+
const getClientIp = vi.fn(() => "203.0.113.9");
38+
vi.mock("@/lib/rate-limit", () => ({ isRateLimited, getClientIp }));
39+
40+
const checkAiSpend = vi.fn();
41+
const recordAiSpend = vi.fn();
42+
vi.mock("@/lib/ai/spend-ledger", () => ({
43+
checkAiSpend,
44+
recordAiSpend,
45+
degradedMaxTokens: (base: number) => Math.max(256, Math.floor(base / 2)),
46+
}));
47+
48+
const getLessonBySlug = vi.fn();
49+
vi.mock("@/lib/content/queries", () => ({ getLessonBySlug }));
50+
51+
// The REAL identity module — deliberately not mocked. These tests must break if
52+
// the constant moves or changes, which is half the point of the shared-constant
53+
// clause.
54+
import {
55+
CAPSTONE_CREDENTIAL,
56+
CAPSTONE_AI_OFF_CODE,
57+
} from "@/lib/credentials/capstone-identity";
58+
59+
const CODE_BLOCK = {
60+
_type: "code",
61+
key: "c1",
62+
language: "rust",
63+
buildType: "standard",
64+
starter: "fn solve() {}",
65+
solution: "fn solve() { /* the answer */ }",
66+
tests: [
67+
{ id: "v1", description: "visible test", input: "1", expectedOutput: "2" },
68+
],
69+
hints: [],
70+
};
71+
72+
// The capstone WITH a code block: the worst case this change defends against —
73+
// a future content edit that gives the capstone a challenge surface. Structural
74+
// absence would silently serve AI here; the constant-driven check must not.
75+
const CAPSTONE_LESSON = {
76+
_id: CAPSTONE_CREDENTIAL.deployLessonId,
77+
title: "Capstone",
78+
slug: "capstone",
79+
blocks: [{ _type: "prose", key: "p1", src: "Ship it." }, CODE_BLOCK],
80+
};
81+
82+
const ORDINARY_LESSON = {
83+
_id: "lesson-1",
84+
title: "Solve it",
85+
slug: "l-slug",
86+
blocks: [{ _type: "prose", key: "p1", src: "Double it." }, CODE_BLOCK],
87+
};
88+
89+
function makeRequest(body: unknown): NextRequest {
90+
return new NextRequest("http://localhost/api/ai/partner", {
91+
method: "POST",
92+
body: JSON.stringify(body),
93+
headers: { "content-type": "application/json" },
94+
});
95+
}
96+
97+
const BODY = {
98+
lessonSlug: "capstone",
99+
courseSlug: "c-slug",
100+
action: "hint",
101+
code: "let x = 1;",
102+
testSummary: "1/2 passing",
103+
};
104+
105+
const GEMINI_TEXT = JSON.stringify({
106+
type: "hint",
107+
text: "Try a smaller step.",
108+
});
109+
110+
function stubGeminiFetch() {
111+
const fetchMock = vi.fn(async () => {
112+
return {
113+
ok: true,
114+
text: async () => "",
115+
json: async () => ({
116+
candidates: [{ content: { parts: [{ text: GEMINI_TEXT }] } }],
117+
usageMetadata: {},
118+
}),
119+
} as unknown as Response;
120+
});
121+
vi.stubGlobal("fetch", fetchMock);
122+
return fetchMock;
123+
}
124+
125+
beforeEach(() => {
126+
vi.resetModules();
127+
process.env.GEMINI_API_KEY = "test-gemini-key";
128+
process.env.AI_PARTNER_SEAL_SECRET = "test-seal-secret";
129+
getUser.mockReset();
130+
spendAssistTurn.mockReset();
131+
refundAssistTurn.mockReset();
132+
recordBilledAssist.mockReset();
133+
appendAssistLog.mockReset();
134+
isRateLimited.mockReset();
135+
getLessonBySlug.mockReset();
136+
checkAiSpend.mockReset();
137+
recordAiSpend.mockReset();
138+
139+
getUser.mockResolvedValue({ data: { user: { id: "user-1" } }, error: null });
140+
isRateLimited.mockResolvedValue(false);
141+
spendAssistTurn.mockResolvedValue({
142+
allowed: true,
143+
tier: "free",
144+
counts: { free: 1, metered: 0, socratic: 0 },
145+
});
146+
refundAssistTurn.mockResolvedValue(undefined);
147+
recordBilledAssist.mockResolvedValue(undefined);
148+
appendAssistLog.mockResolvedValue(undefined);
149+
checkAiSpend.mockResolvedValue({ decision: "full" });
150+
recordAiSpend.mockResolvedValue(undefined);
151+
});
152+
153+
afterEach(() => {
154+
vi.unstubAllGlobals();
155+
vi.restoreAllMocks();
156+
delete process.env.GEMINI_API_KEY;
157+
delete process.env.AI_PARTNER_SEAL_SECRET;
158+
});
159+
160+
describe("POST /api/ai/partner — capstone AI hard-off (#867)", () => {
161+
it("refuses the capstone lesson with the typed capstone_ai_off code", async () => {
162+
getLessonBySlug.mockResolvedValue(CAPSTONE_LESSON);
163+
stubGeminiFetch();
164+
165+
const { POST } = await import("../partner/route");
166+
const res = await POST(makeRequest(BODY));
167+
const json = await res.json();
168+
169+
expect(res.status).toBe(403);
170+
expect(json).toMatchObject({
171+
code: CAPSTONE_AI_OFF_CODE,
172+
capstoneAiOff: true,
173+
});
174+
});
175+
176+
it("spends nothing: no assist turn, no ledger write, no Gemini call", async () => {
177+
getLessonBySlug.mockResolvedValue(CAPSTONE_LESSON);
178+
const fetchMock = stubGeminiFetch();
179+
180+
const { POST } = await import("../partner/route");
181+
await POST(makeRequest(BODY));
182+
183+
// The refusal is the design, not a failure — it must cost the learner
184+
// nothing and must never reach the platform-funded key.
185+
expect(spendAssistTurn).not.toHaveBeenCalled();
186+
expect(recordBilledAssist).not.toHaveBeenCalled();
187+
expect(recordAiSpend).not.toHaveBeenCalled();
188+
expect(fetchMock).not.toHaveBeenCalled();
189+
// Nothing was spent, so nothing may be refunded either (a refund would
190+
// credit a turn that was never taken).
191+
expect(refundAssistTurn).not.toHaveBeenCalled();
192+
});
193+
194+
it("refuses every action, not just hint", async () => {
195+
getLessonBySlug.mockResolvedValue(CAPSTONE_LESSON);
196+
stubGeminiFetch();
197+
const { POST } = await import("../partner/route");
198+
199+
for (const action of ["hint", "ask", "propose", "review"]) {
200+
const res = await POST(makeRequest({ ...BODY, action }));
201+
expect(res.status, `action=${action}`).toBe(403);
202+
}
203+
});
204+
205+
it("leaves a non-capstone lesson untouched", async () => {
206+
getLessonBySlug.mockResolvedValue(ORDINARY_LESSON);
207+
const fetchMock = stubGeminiFetch();
208+
209+
const { POST } = await import("../partner/route");
210+
const res = await POST(
211+
makeRequest({ ...BODY, lessonSlug: "l-slug", action: "hint" })
212+
);
213+
const json = await res.json();
214+
215+
expect(res.status).toBe(200);
216+
expect(json).toMatchObject({ type: "hint" });
217+
expect(spendAssistTurn).toHaveBeenCalled();
218+
expect(fetchMock).toHaveBeenCalled();
219+
});
220+
});

apps/web/src/app/api/ai/partner/route.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ import type {
3535
ReviewResponse,
3636
CodeEdit,
3737
} from "@/lib/ai/partner-types";
38+
import {
39+
isCapstoneLesson,
40+
CAPSTONE_AI_OFF_CODE,
41+
} from "@/lib/credentials/capstone-identity";
3842
import { serverEnv } from "@/lib/env.server";
3943

4044
const GEMINI_API_KEY = serverEnv.GEMINI_API_KEY;
@@ -340,6 +344,34 @@ export async function POST(request: NextRequest) {
340344
// §10.2). getLessonBySlug applies the normal catalog gate — a lesson not yet
341345
// live has no partner surface either.
342346
const lesson = await getLessonBySlug(courseSlug, lessonSlug);
347+
348+
// AI HARD-OFF ON THE CAPSTONE (#867, unified spec item 33). The capstone
349+
// credential attests that the learner shipped the program themselves (the
350+
// deploy gate, item 14) — so it certifies nothing if a tutor could have
351+
// written it. This is the SERVER leg, keyed off the SAME constant as that
352+
// gate (`lib/credentials/capstone-identity`), so the two can never drift:
353+
// re-shaping the capstone lesson cannot silently re-open the AI path.
354+
//
355+
// Placement is load-bearing on two counts:
356+
// • BEFORE any spend — no assist turn, no ledger entry, no Gemini call. A
357+
// refusal here costs the learner nothing (it is not a failure, it is the
358+
// design), so it must not consume a turn.
359+
// • BEFORE the `codeBlock` 404 — the capstone hosts no `code` block today,
360+
// so a structural 404 is what happens by accident. This returns the
361+
// TYPED reason instead, which is the whole point of the issue: the
362+
// refusal must be intentional and observable, not incidental.
363+
// The copy is neutral, not an error: this is a permanent, explained state.
364+
if (lesson && isCapstoneLesson(lesson._id)) {
365+
return NextResponse.json(
366+
{
367+
error: "AI Partner is off for the capstone",
368+
code: CAPSTONE_AI_OFF_CODE,
369+
capstoneAiOff: true,
370+
},
371+
{ status: 403 }
372+
);
373+
}
374+
343375
const codeBlock = lesson?.blocks.find(
344376
(b): b is CodeBlockData => b._type === "code"
345377
);

0 commit comments

Comments
 (0)