Skip to content

feat(ai): capstone AI-off via the credential-gate constant (#867) - #891

Merged
thomgabriel merged 1 commit into
mainfrom
feat/867-capstone-ai-off-30-07-2026
Jul 30, 2026
Merged

feat(ai): capstone AI-off via the credential-gate constant (#867)#891
thomgabriel merged 1 commit into
mainfrom
feat/867-capstone-ai-off-30-07-2026

Conversation

@thomgabriel

Copy link
Copy Markdown
Collaborator

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 code block, so /api/ai/partner 404'd there by accident — plus quiz-level aiSuppressed. 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.ts now holds CAPSTONE_CREDENTIAL + isCapstoneCourse / isCapstoneLesson / CAPSTONE_AI_OFF_CODE. It is deliberately not server-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.ts re-exports it and keeps the gate's database logic, so every existing importer is unchanged.

Server enforcement (the leg that matters). /api/ai/partner refuses a capstone lesson with 403 { code: "capstone_ai_off", capstoneAiOff: true }. Placement is load-bearing on two counts:

  • before any spend — no assist turn, no billed-assist record, no spend-ledger write, no Gemini call. The refusal is the design, not a failure, so it costs the learner nothing (and nothing is refunded, since nothing was taken).
  • before the codeBlock 404 — so the refusal is typed and intentional, not the incidental 404 this issue exists to replace.

Client. ChallengeInterface gains capstoneAiOff, derived in lesson-client from isCapstoneLesson(lesson._id) and threaded through BlockContextcode-block. Unlike aiSuppressed (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 in en / pt-BR / es.

Ruling: /api/lessons/reflect

The capstone does carry an openEnded block ("What You've Built — and Writing It Up"), and maybeGenerateReflectionReply is 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/partner change reverted (git stash push on that one file), partner-capstone-off.test.ts goes 3 of 4 red:

× refuses the capstone lesson with the typed capstone_ai_off code
    AssertionError: expected 200 to be 403
× spends nothing: no assist turn, no ledger write, no Gemini call
    AssertionError: expected "vi.fn()" to not be called at all, but actually been called 1 times
× refuses every action, not just hint
    AssertionError: action=hint: expected 200 to be 403

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.tsthe anti-fork test: reference equality between capstone-gate's re-export and the identity module (a duplicate literal would pass toEqual and defeat the point), plus a source walk over apps/web/src that fails on any file hardcoding either id. One allow-listed exception, documented in the test: lib/courses/learner-segment.ts names 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 to CAPSTONE_CREDENTIAL would be the actual bug.

Shape note (EVM / C1 future)

CAPSTONE_CREDENTIAL stays 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 through isCapstoneCourse / isCapstoneLesson rather 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 pass
  • apps/web full suite — 244 files, 2217 tests, all passing
  • pnpm lint — no errors (only the repo's pre-existing import/order warnings)

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.
@vercel

vercel Bot commented Jul 30, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
superteam-academy-web Ready Ready Preview Jul 30, 2026 9:27pm

Request Review

@claude

claude Bot commented Jul 30, 2026

Copy link
Copy Markdown

Claude finished @thomgabriel's task in 2m 16s —— View job


Review: capstone AI-off via the credential-gate constant (#867)

Todo list

  • Read CLAUDE.md and docs/TASK-CODES.md for conventions
  • Check out branch, gather diff vs origin/main
  • Verify diff meets issue AI hard-off in the capstone via the credential-gate constant (item 33) #867's "Done when" criteria
  • Review server enforcement (/api/ai/partner, /api/lessons/reflect)
  • Review shared-constant definition (capstone-identity.ts / capstone-gate.ts)
  • Review client-side changes (challenge-interface.tsx, lesson-client.tsx, code-block.tsx)
  • Check i18n message files
  • Review new tests for correctness/coverage
  • Run typecheck/lint/tests — could not run, see note below
  • Post final review findings

Blocking findings

None. 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 (gh issue view was blocked — no outbound network in this run). Judging from the PR's own description of the spec item (shared-constant clause, server hard-off, client explanation, reflection ruling), the diff matches every claim made:

  • Single definition site: capstone-identity.ts (apps/web/src/lib/credentials/capstone-identity.ts:34-53) holds CAPSTONE_CREDENTIAL/isCapstoneCourse/isCapstoneLesson/CAPSTONE_AI_OFF_CODE; capstone-gate.ts:13-17 re-exports by reference (not a copy) — confirmed by capstone-identity.test.ts:22-28, which asserts reference equality (toBe, not toEqual), so a forked literal would fail the test.
  • Anti-fork source walk (capstone-identity.test.ts:30-73) greps src/**/*.{ts,tsx} for either literal id outside the one allowed file, with a single documented exception (lib/courses/learner-segment.ts) that I independently verified is a different semantic (segment-2 entry course, not "is this the graded capstone" — apps/web/src/lib/courses/learner-segment.ts:78-82) — not a real fork.
  • Server enforcement placed correctlyapps/web/src/app/api/ai/partner/route.ts:364-373: the capstone check runs after auth/rate-limit/input-validation but before spendAssistTurn and before the codeBlock 404, so a capstone hit costs nothing and returns the typed 403 { code: "capstone_ai_off" } instead of an incidental 404. Verified against partner-capstone-off.test.ts, which asserts zero calls to spendAssistTurn/recordBilledAssist/recordAiSpend/fetch/refundAssistTurn on the capstone path, and checks all four actions (hint/ask/propose/review) are refused.
  • Reflect route rulingapps/web/src/app/api/lessons/reflect/route.ts:185-201: capstoneAiOff is computed from the already-validated lessonId (confirmed equal to lesson._id via getLessonByIdForGrading's l._id === lessonId match, lib/content/queries.ts:223), the seal (sealAttestation) is still computed unconditionally above, and only the best-effort reply is skipped — receipt-first (AIE-21) is intact.
  • Client: capstoneAiOff threads cleanly through a single BlockContext construction site (lesson-client.tsx:412/430) → code-block.tsx:59ChallengeInterface. aiVisible = !aiSuppressed && !capstoneAiOff (challenge-interface.tsx:194) gates the AiPartnerPane's mount, not just its CSS visibility, so the pane is genuinely absent from the DOM/tab order on the capstone — matches the WCAG 4.1.2 claim and the "unmounted, not hidden" test assertion in challenge-interface-capstone-ai-off.test.tsx:74-79.
  • i18n: capstoneAiOffTitle/capstoneAiOffBody added identically (same keys) across en.json, es.json, pt-BR.json.
  • Type-safety of the new required BlockContext.capstoneAiOff field: the only three test files that hand-construct a BlockContext literal (quiz-block.test.tsx, parsons-block.test.tsx, open-ended-block.test.tsx) were all updated with capstoneAiOff: false — no other construction site exists, so nothing was missed.

Non-blocking observations

  • The reflect route derives capstoneAiOff from the raw request lessonId rather than lesson._id (route.ts:185). This is safe today only because getLessonByIdForGrading guarantees lesson._id === lessonId on a hit (lib/content/queries.ts:217-227) — worth a one-line comment noting that invariant, since a future refactor of that lookup (e.g. matching on slug-with-fallback) could silently decouple them. Not urgent — this PR's tests would still catch it.
  • capstone-identity.ts is intentionally not server-only, which is correct per the design rationale (client needs the same constant), but it's worth double-checking in CI that no future edit to that file starts importing anything server-only transitively — the module docstring already flags this ("no I/O, no secrets"), which is the right guardrail.

What I couldn't verify

No network access in this sandbox — I could not pnpm install/run pnpm typecheck, pnpm lint, or the vitest suite locally to confirm the PR's claimed "244 files, 2217 tests, all passing." I read all four new/changed test files in full and they exercise the right assertions (red-proof reasoning in partner-capstone-off.test.ts is sound and the described revert-one-file 3-of-4-red claim is consistent with the code). CI should be treated as the authoritative gate here, per this repo's review policy — this review is advisory only.

Priority-area scan (RLS, on-chain, secrets, client-trust boundaries): not implicated — this PR touches no supabase/schema.sql, no onchain-academy/**, no env vars, and the two capstone ids are content identifiers, not secrets. No forgeable-progress path introduced.

@thomgabriel
thomgabriel merged commit 08e14cc into main Jul 30, 2026
26 checks passed
@thomgabriel

Copy link
Copy Markdown
Collaborator Author

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

AI hard-off in the capstone via the credential-gate constant (item 33)

1 participant