feat(ai): capstone AI-off via the credential-gate constant (#867) - #891
Conversation
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.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Claude finished @thomgabriel's task in 2m 16s —— View job Review: capstone AI-off via the credential-gate constant (#867)Todo list
Blocking findingsNone. This is a clean, well-scoped change. Verification of "Closes #867"I don't have network access in this sandbox to pull the live issue #867 body for a line-by-line cross-check (
Non-blocking observations
What I couldn't verifyNo network access in this sandbox — I could not Priority-area scan (RLS, on-chain, secrets, client-trust boundaries): not implicated — this PR touches no |
|
Gate: merged. Red-proof replayed (3/4 red at main in partner-capstone-off.test.ts, 4/4 green at head); CI green; review clean — its two advisory notes (the lessonId==lesson._id invariant comment in the reflect route, and keeping capstone-identity.ts free of server-only transitive imports) are recorded here as dispositions: both are guarded by existing tests per the reviewer, no follow-up filed. |
Closes #867 — unified spec item 33 (epic #838, last open item), with item 14's shared-constant clause.
Why
The capstone credential attests that the learner shipped the program themselves (the deploy gate, item 14). It certifies nothing if a tutor could have written it.
Before this PR the AI path had only structural absence — the capstone happens to carry no
codeblock, so/api/ai/partner404'd there by accident — plus quiz-levelaiSuppressed. No code read a capstone constant in the AI path. A content edit adding a challenge surface to the capstone would have silently re-enabled the AI Partner on the one lesson the credential rests on.What
One definition site.
lib/credentials/capstone-identity.tsnow holdsCAPSTONE_CREDENTIAL+isCapstoneCourse/isCapstoneLesson/CAPSTONE_AI_OFF_CODE. It is deliberately notserver-only— the lesson client renders the AI-free state from the same constant, and a client-importable definition is what stops that becoming a second hardcoded id.capstone-gate.tsre-exports it and keeps the gate's database logic, so every existing importer is unchanged.Server enforcement (the leg that matters).
/api/ai/partnerrefuses a capstone lesson with403 { code: "capstone_ai_off", capstoneAiOff: true }. Placement is load-bearing on two counts:codeBlock404 — so the refusal is typed and intentional, not the incidental 404 this issue exists to replace.Client.
ChallengeInterfacegainscapstoneAiOff, derived inlesson-clientfromisCapstoneLesson(lesson._id)and threaded throughBlockContext→code-block. UnlikeaiSuppressed(temporary, silent) this state is permanent, so the pane is replaced by an explanation rather than left as an empty column: "The capstone is completed without the AI Partner — that's what makes the credential yours." The pane is unmounted, not CSS-hidden (no controls in the tab order, WCAG 4.1.2). Copy inen/pt-BR/es.Ruling:
/api/lessons/reflectThe capstone does carry an
openEndedblock ("What You've Built — and Writing It Up"), andmaybeGenerateReflectionReplyis lesson-scoped, so this route was in scope.Ruling implemented: the reply is NOT generated on the capstone; the seal is.
The argument for the other side is real — the reflection is a write-up of a deploy that already happened, so the reply is post-completion enrichment, not challenge help, and could defensibly stay on. It is suppressed anyway because "AI-free" is only checkable if it means no AI turn on that lesson, not "no AI turn that someone judged to be help". A per-surface judgement call is exactly the kind of thing that drifts; a flat invariant keyed off the credential constant does not.
The cost is zero: receipt-first (AIE-21) is untouched — the seal is computed and returned unconditionally, so the capstone lesson stays completable, XP and the credential path are unaffected, and the only thing lost is an optional paragraph of feedback. Covered by
reflect-capstone-off.test.ts(seal present,reply: null, generator never invoked).Red-proof
Measured, not asserted. With only the
/api/ai/partnerchange reverted (git stash pushon that one file),partner-capstone-off.test.tsgoes 3 of 4 red:A 200, not a 404: the fixture deliberately gives the capstone a code block, which is precisely the content edit that would silently re-enable AI today. At main the request is served — a turn spent and Gemini called on the capstone.
Tests
partner-capstone-off.test.ts— typed 403 on the capstone; nothing spent (assist / billed / ledger / fetch / refund all untouched); all four actions refused; non-capstone lesson serves a normal 200.reflect-capstone-off.test.ts— the ruling above, both directions.challenge-interface-capstone-ai-off.test.tsx— pane unmounted, explanation rendered, ordinary lesson and omitted-prop default unaffected.capstone-identity.test.ts— the anti-fork test: reference equality betweencapstone-gate's re-export and the identity module (a duplicate literal would passtoEqualand defeat the point), plus a source walk overapps/web/srcthat fails on any file hardcoding either id. One allow-listed exception, documented in the test:lib/courses/learner-segment.tsnames C3 as segment 2's entry rung (Deploy and freeze a devnet reference vault for course authoring #599/Author C1 — solana-for-web-devs (8 lessons) — blocked on #599 (reference vault) #673) — a personalization routing table answering a different question; coupling it toCAPSTONE_CREDENTIALwould be the actual bug.Shape note (EVM / C1 future)
CAPSTONE_CREDENTIALstays single-valued — only C3 mints a deploy-gated credential today. The multi-course future is documented in the module: it becomes a readonly array or a content-schema flag, with the two predicates turning into membership tests. Every consumer already goes throughisCapstoneCourse/isCapstoneLessonrather than comparing the constant inline, so that change is local to one file. No registry invented for one entry.Verification
pnpm typecheck— 6/6 tasks passapps/webfull suite — 244 files, 2217 tests, all passingpnpm lint— no errors (only the repo's pre-existingimport/orderwarnings)