PRD — 00 Critical Foundations
Status: Approved for implementation · Owner: Osama · Supersedes ambiguities in: specs/00-critical-foundations.md
Companion reference: CONTEXT.md (vocabulary, table/column map, RLS posture). Read it first.
Every decision below was resolved in a design session on 2026-07-08 and is normative — an implementing engineer should not re-litigate them, only flag genuine blockers.
1. Problem
The monetization boundary lives in the browser:
supabase/functions/medical-notes/index.ts trusts client-sent isPro/isAnonymous/userId/preferredModel. Replaying with "isPro": true routes to Claude Haiku free, forever.
- Daily quota (
usage_records) is incremented by the client, and RLS policy "Users can update own usage" lets any user reset their own count to 0. MAX_DAILY_SHEETS = 5 is decorative.
- QBank ships the answer key:
fetchQuestions() selects correct_option, explanation, teaching_point for all matching rows before the user answers anything. Attempts data is cheat-poisoned; all rows ship to the client (breaks at scale).
Features 02 (Persona Tiers) and 03 (On-Demand QBank) multiply the cost of each flaw. This workstream must land first.
2. Goals
- All entitlement decisions (tier, model routing, quota) computed server-side from a verified JWT.
- Quota is exact under concurrency, burns only on successful generation, resets at UTC midnight.
- No QBank network response contains answer fields before the corresponding question is answered by that user in that session.
- One QBank engine, one sheet-prompt schema, one typed edge-call helper.
3. Non-goals (explicitly out of scope — do not build)
| Gap |
Rationale |
Disposition |
Citation quota enforcement (citation_usage client writes, unauthenticated get-citations) |
Burns NCBI rate limits, not OpenRouter spend |
Follow-up ticket; document, don't fix |
| Server-side QBank session resume after localStorage loss |
Same UX as today; server rows make it possible later |
Later |
Rewriting historical user_attempts rows |
Cheat-poisoned data stays; flag in analytics if needed |
Never |
Server-verified time_taken_ms |
Analytics, not money |
Later, if ever |
| User-local quota reset timezones |
Client-asserted tz = new abuse vector |
Rejected |
| Stripe, new model providers, surfacing pro codes, strict-mode TS |
Hard constraints (CLAUDE.md) |
Never |
4. Requirements
Fix 1 — Server-side entitlement (PR1, CRITICAL)
Client — new helper src/lib/edge.ts → callMedicalNotes(params):
- Calls
supabase.auth.getSession() per request (supabase-js auto-refreshes stale tokens), sends session.access_token as the Authorization: Bearer header. The publishable-key Bearer is removed.
- All four call sites route through it:
SheetGenerator.tsx (:380), FlashcardsGenerator.tsx (:124), OutputSection.tsx (:636), StudyMode.tsx (:376).
- Request body = content params only:
notes, difficulty, focus, length, examMode, cardsOnly, cardCount, focusCard, explainMode, enhanceMode, itemText, sectionKey, sectionItems, enhanceTopic. The fields isPro, isAnonymous, userId, preferredModel are deleted from the contract.
- Streaming read stays in the helper or call sites as-is;
x-model-used / x-is-premium header handling unchanged.
Server — medical-notes/index.ts:
- Verify:
adminClient.auth.getUser(jwt) from the Bearer. Invalid/missing → 401 {"error":"invalid_token"} before any streaming. No retry logic server-side.
- Anon vs signed-in:
user.is_anonymous claim from the verified user object (anon users hold real JWTs — a missing token is a broken client, never a legitimate anon).
- Entitlement: one service-role SELECT per request —
is_pro, pro_expires_at, premium_used, preferred_model FROM profiles WHERE id = user.id. No caching. This replaces the two existing profiles round-trips (index.ts:437-461).
- Tier:
pro if is_pro && (pro_expires_at IS NULL || pro_expires_at > now()); else hook if premium_used < (1 anon / 3 free); else free. Model routing logic otherwise unchanged (pro honors profiles.preferred_model; hook → Claude Haiku + increment premium_used; free → GPT-OSS).
- Client UX on 401: toast "Session expired — refresh the page."
Deploy: edge fn + Vercel together. Accepted: stale open tabs 401 (with toast) for the minutes until users refresh.
Fix 2 — Server-side quota (PR2, CRITICAL — depends on PR1)
New migration (additive: new function + policy swap, no column/table drops):
consume_usage(p_kind text, p_cap int) → jsonb — SECURITY DEFINER, EXECUTE revoked from anon/authenticated (service-role/edge-fn only). Atomically: upsert row for (user, p_kind, current_date) with count = count + 1 only where count < p_cap; return {allowed: bool, count: int}. Single statement / row-lock semantics — two parallel requests at count=4 must resolve to exactly one allowed.
refund_usage(p_kind text) (or a decrement arm of the same function) for the failure path.
- Drop policies
"Users can insert own usage" and "Users can update own usage" on usage_records. Keep "Users can view own usage" (UI display).
Server — medical-notes/index.ts, order of operations:
- Verify JWT (Fix 1) → 401.
- If tier ≠ pro:
consume_usage(kind, 5) where kind = 'sheet' | 'cards' (from cardsOnly). Denied → 429 {"error":"quota_exceeded"} pre-stream, friendly client copy. (explainMode/enhanceMode calls: not quota'd — unchanged from today.)
- Call OpenRouter. Non-2xx or network failure → refund the unit, return
502. Failures never burn quota.
- Stream response.
- Caps are server constants; reset boundary is UTC midnight (
current_date, matching all existing rows — zero data migration).
Client:
- Delete
incrementSheet() / incrementCards() calls from SheetGenerator.tsx (:377), FlashcardsGenerator.tsx, StudyMode.tsx.
use-usage-limit.ts becomes read-only (keep MAX_DAILY_* for display; remove increment). Add "resets in Nh" copy computed from UTC midnight.
- Handle 429 with a distinct message (not the generic error path).
Deploy order: Vercel first (stop client increments) → edge fn (server consume) → RLS migration last. Accepted: cached old clients throw on incrementSheet() after the migration until refreshed.
Fix 3 — QBank answer-key removal (PR3, HIGH — independent of PR1/PR2)
New migration — additive columns + four SECURITY DEFINER RPCs (pattern precedent: redeem_pro_code) + column REVOKE:
ALTER TABLE qbank_sessions
ADD COLUMN status text NOT NULL DEFAULT 'completed', -- old rows stay valid
ADD COLUMN question_ids uuid[];
REVOKE SELECT (correct_option, explanation, teaching_point)
ON questions FROM anon, authenticated;
Note: questions/qbank_sessions/user_attempts were dashboard-created (no migrations in repo — see CONTEXT.md "Known messes"). Write these changes as a fresh migration; verify live constraint state before applying.
start_qbank_session(p_domains text[], p_limit int, p_system text) → json
Samples server-side: ORDER BY random() LIMIT least(p_limit, 40), is_active = true, filtered by domains/system. Inserts qbank_sessions row up front with status='active', question_ids in shuffled order, and placeholders for the live NOT NULL columns (ended_at = started_at, score = 0, total = 0, total_time_ms = 0). Returns session_id + sanitized questions: stems, options a–e, subject/domain/topic/difficulty/competency, media (via question_media/media join) — no answer fields.
submit_answer(p_session uuid, p_question uuid, p_selected text, p_time_ms int) → json
Guards (all required — this is the anti-oracle boundary): session owned by auth.uid() ∧ status = 'active' ∧ p_question = ANY(question_ids) ∧ no existing attempt for (session, question). Grades server-side, inserts the user_attempts row (per-answer, replacing today's batch-at-end), returns {is_correct, correct_option, explanation, teaching_point}.
end_qbank_session(p_session uuid) → json — owner guard; sets status='completed', real score/total/ended_at/total_time_ms (computed from attempts server-side).
get_session_review(p_session uuid) → json — owner guard; returns attempts + full question data (including answer fields) for that session. This is the sanctioned path for the ?session=<id> deep-link.
Client:
QBankContext.tsx: fetchQuestions() deleted → startSession calls the RPC; submitAnswer becomes async (awaits RPC, caches returned grading in state + localStorage); endSession calls the RPC (navigation-after-DB-write behavior preserved); localStorage payload sb_qbank_session becomes a cache of server state — stores session_id, sanitized questions, and graded results as they arrive. 24h TTL + resume banner behavior unchanged; cache loss = session lost (same as today).
QBankSession.tsx: answer click awaits submit_answer (add a pending state on the option buttons); type imports unchanged.
QBankSummary.tsx: replace the embedded user_attempts → questions(...) join (:73-99) with get_session_review.
qbank-count query: select("*", {count:"exact", head:true}) → select("id", …) — a * select 403s under column-level grants. Landing meta queries (subject, domain, id) are unaffected.
- React Query invalidation keys unchanged (
qbank-count, qbank-domain-meta, qbank-systems, qbank-meta, qbank-sessions).
Engine consolidation (same PR): src/hooks/use-qbank.ts stripped to shared types only (Question, QuestionMedia, OptionKey, SessionAnswer, SessionState) — all three consumers already import types from that path, so zero import churn. DoD: the file exports no functions.
Back-compat: existing user_attempts rows keep client-computed is_correct (no rewrite). time_taken_ms remains client-reported. flagged_questions flow unchanged. Abandoned 'active' session rows are harmless; a new start creates a new session.
Deploy order: Vercel first, REVOKE migration last (old clients' fetchQuestions breaks the moment the REVOKE lands).
PR4 — Prompt dedup (chore, in scope for 00; sequenced last)
gptOss* and haiku* sheet prompts in medical-notes/index.ts are ~95% identical. Extract one shared task-spec/JSON-schema block + thin per-model-family adapters. Pure refactor, no behavior change (same prompts byte-for-byte where they already agree; diffs limited to the genuine model-family deltas).
- This is step 1 of feature 02 (Persona Tiers). DoD: the sheet output schema exists exactly once in the function.
- Sequenced after PR1/PR2 only to avoid merge conflicts in the same file.
5. Acceptance criteria
curl medical-notes with missing/garbage Bearer → 401 {"error":"invalid_token"}. Valid anon token → 200.
{"isPro": true} in the body as a free user → x-model-used shows gpt-oss (field has no effect; also true for userId/preferredModel injection).
- Browser console:
supabase.from("usage_records").update({count: 0}) → RLS error.
- Free user's 6th sheet of the (UTC) day → 429 with friendly message; UI shows 5/5 and reset countdown.
- Kill the OpenRouter path mid-request on a fresh slot → count unchanged (refund). Scripted parallel pair at count=4 → exactly one 429.
- New QBank session: no
correct_option/explanation/teaching_point in any network response pre-answer. Console supabase.from("questions").select("correct_option") → permission denied.
- Answering returns grading + explanation via RPC; summary renders;
?session=<id> in a fresh tab renders via get_session_review; landing count/domain chips still render.
submit_answer with a question not in the session, an already-answered question, or another user's session → error (oracle guard).
use-qbank.ts exports types only. Sheet prompt schema appears once.
npm run test / lint / build green; manual pass of sheet, flashcards, explain, and enhance flows — all through callMedicalNotes.
6. Rollout summary
| PR |
Contents |
Depends on |
Deploy order within PR |
| 1 |
JWT verify + server entitlement + callMedicalNotes |
— |
edge fn + Vercel together |
| 2 |
consume_usage RPC + policy swap + client increment removal |
PR1 |
Vercel → edge fn → migration |
| 3 |
QBank RPCs + additive cols + column REVOKE + engine consolidation |
— |
Vercel → migration (REVOKE last) |
| 4 |
Prompt dedup |
merge-order only |
edge fn only |
7. Hard constraints (inherited, non-negotiable)
Schema changes additive only · no Stripe · OpenRouter only (no direct Anthropic/OpenAI/Gemini clients) · pro codes stay hidden · permissive TS config stays.
PRD — 00 Critical Foundations
Status: Approved for implementation · Owner: Osama · Supersedes ambiguities in:
specs/00-critical-foundations.mdCompanion reference:
CONTEXT.md(vocabulary, table/column map, RLS posture). Read it first.Every decision below was resolved in a design session on 2026-07-08 and is normative — an implementing engineer should not re-litigate them, only flag genuine blockers.
1. Problem
The monetization boundary lives in the browser:
supabase/functions/medical-notes/index.tstrusts client-sentisPro/isAnonymous/userId/preferredModel. Replaying with"isPro": trueroutes to Claude Haiku free, forever.usage_records) is incremented by the client, and RLS policy"Users can update own usage"lets any user reset their own count to 0.MAX_DAILY_SHEETS = 5is decorative.fetchQuestions()selectscorrect_option,explanation,teaching_pointfor all matching rows before the user answers anything. Attempts data is cheat-poisoned; all rows ship to the client (breaks at scale).Features 02 (Persona Tiers) and 03 (On-Demand QBank) multiply the cost of each flaw. This workstream must land first.
2. Goals
3. Non-goals (explicitly out of scope — do not build)
citation_usageclient writes, unauthenticatedget-citations)user_attemptsrowstime_taken_ms4. Requirements
Fix 1 — Server-side entitlement (PR1, CRITICAL)
Client — new helper
src/lib/edge.ts→callMedicalNotes(params):supabase.auth.getSession()per request (supabase-js auto-refreshes stale tokens), sendssession.access_tokenas theAuthorization: Bearerheader. The publishable-key Bearer is removed.SheetGenerator.tsx(:380),:124),FlashcardsGenerator.tsx(OutputSection.tsx(:636),:376).StudyMode.tsx(notes, difficulty, focus, length, examMode, cardsOnly, cardCount, focusCard, explainMode, enhanceMode, itemText, sectionKey, sectionItems, enhanceTopic. The fieldsisPro,isAnonymous,userId,preferredModelare deleted from the contract.x-model-used/x-is-premiumheader handling unchanged.Server —
medical-notes/index.ts:adminClient.auth.getUser(jwt)from the Bearer. Invalid/missing →401 {"error":"invalid_token"}before any streaming. No retry logic server-side.user.is_anonymousclaim from the verified user object (anon users hold real JWTs — a missing token is a broken client, never a legitimate anon).is_pro, pro_expires_at, premium_used, preferred_model FROM profiles WHERE id = user.id. No caching. This replaces the two existing profiles round-trips (index.ts:437-461).proifis_pro && (pro_expires_at IS NULL || pro_expires_at > now()); elsehookifpremium_used <(1 anon / 3 free); elsefree. Model routing logic otherwise unchanged (pro honorsprofiles.preferred_model; hook → Claude Haiku + incrementpremium_used; free → GPT-OSS).Deploy: edge fn + Vercel together. Accepted: stale open tabs 401 (with toast) for the minutes until users refresh.
Fix 2 — Server-side quota (PR2, CRITICAL — depends on PR1)
New migration (additive: new function + policy swap, no column/table drops):
consume_usage(p_kind text, p_cap int) → jsonb— SECURITY DEFINER,EXECUTErevoked fromanon/authenticated(service-role/edge-fn only). Atomically: upsert row for(user, p_kind, current_date)withcount = count + 1only wherecount < p_cap; return{allowed: bool, count: int}. Single statement / row-lock semantics — two parallel requests at count=4 must resolve to exactly one allowed.refund_usage(p_kind text)(or a decrement arm of the same function) for the failure path."Users can insert own usage"and"Users can update own usage"onusage_records. Keep"Users can view own usage"(UI display).Server —
medical-notes/index.ts, order of operations:consume_usage(kind, 5)wherekind='sheet'|'cards'(fromcardsOnly). Denied →429 {"error":"quota_exceeded"}pre-stream, friendly client copy. (explainMode/enhanceModecalls: not quota'd — unchanged from today.)502. Failures never burn quota.current_date, matching all existing rows — zero data migration).Client:
incrementSheet()/incrementCards()calls fromSheetGenerator.tsx(:377),FlashcardsGenerator.tsx,StudyMode.tsx.use-usage-limit.tsbecomes read-only (keepMAX_DAILY_*for display; removeincrement). Add "resets in Nh" copy computed from UTC midnight.Deploy order: Vercel first (stop client increments) → edge fn (server consume) → RLS migration last. Accepted: cached old clients throw on
incrementSheet()after the migration until refreshed.Fix 3 — QBank answer-key removal (PR3, HIGH — independent of PR1/PR2)
New migration — additive columns + four SECURITY DEFINER RPCs (pattern precedent:
redeem_pro_code) + column REVOKE:Note:
questions/qbank_sessions/user_attemptswere dashboard-created (no migrations in repo — see CONTEXT.md "Known messes"). Write these changes as a fresh migration; verify live constraint state before applying.start_qbank_session(p_domains text[], p_limit int, p_system text) → jsonSamples server-side:
ORDER BY random() LIMIT least(p_limit, 40),is_active = true, filtered by domains/system. Insertsqbank_sessionsrow up front withstatus='active',question_idsin shuffled order, and placeholders for the live NOT NULL columns (ended_at = started_at,score = 0,total = 0,total_time_ms = 0). Returnssession_id+ sanitized questions: stems, options a–e, subject/domain/topic/difficulty/competency, media (viaquestion_media/mediajoin) — no answer fields.submit_answer(p_session uuid, p_question uuid, p_selected text, p_time_ms int) → jsonGuards (all required — this is the anti-oracle boundary): session owned by
auth.uid()∧status = 'active'∧p_question = ANY(question_ids)∧ no existing attempt for(session, question). Grades server-side, inserts theuser_attemptsrow (per-answer, replacing today's batch-at-end), returns{is_correct, correct_option, explanation, teaching_point}.end_qbank_session(p_session uuid) → json— owner guard; setsstatus='completed', realscore/total/ended_at/total_time_ms(computed from attempts server-side).get_session_review(p_session uuid) → json— owner guard; returns attempts + full question data (including answer fields) for that session. This is the sanctioned path for the?session=<id>deep-link.Client:
QBankContext.tsx:fetchQuestions()deleted →startSessioncalls the RPC;submitAnswerbecomes async (awaits RPC, caches returned grading in state + localStorage);endSessioncalls the RPC (navigation-after-DB-write behavior preserved); localStorage payloadsb_qbank_sessionbecomes a cache of server state — storessession_id, sanitized questions, and graded results as they arrive. 24h TTL + resume banner behavior unchanged; cache loss = session lost (same as today).QBankSession.tsx: answer click awaitssubmit_answer(add a pending state on the option buttons); type imports unchanged.QBankSummary.tsx: replace the embeddeduser_attempts → questions(...)join (:73-99) withget_session_review.qbank-countquery:select("*", {count:"exact", head:true})→select("id", …)— a*select 403s under column-level grants. Landing meta queries (subject,domain,id) are unaffected.qbank-count,qbank-domain-meta,qbank-systems,qbank-meta,qbank-sessions).Engine consolidation (same PR):
src/hooks/use-qbank.tsstripped to shared types only (Question,QuestionMedia,OptionKey,SessionAnswer,SessionState) — all three consumers already import types from that path, so zero import churn. DoD: the file exports no functions.Back-compat: existing
user_attemptsrows keep client-computedis_correct(no rewrite).time_taken_msremains client-reported.flagged_questionsflow unchanged. Abandoned'active'session rows are harmless; a new start creates a new session.Deploy order: Vercel first, REVOKE migration last (old clients'
fetchQuestionsbreaks the moment the REVOKE lands).PR4 — Prompt dedup (chore, in scope for 00; sequenced last)
gptOss*andhaiku*sheet prompts inmedical-notes/index.tsare ~95% identical. Extract one shared task-spec/JSON-schema block + thin per-model-family adapters. Pure refactor, no behavior change (same prompts byte-for-byte where they already agree; diffs limited to the genuine model-family deltas).5. Acceptance criteria
curlmedical-notes with missing/garbage Bearer →401 {"error":"invalid_token"}. Valid anon token → 200.{"isPro": true}in the body as a free user →x-model-usedshows gpt-oss (field has no effect; also true foruserId/preferredModelinjection).supabase.from("usage_records").update({count: 0})→ RLS error.correct_option/explanation/teaching_pointin any network response pre-answer. Consolesupabase.from("questions").select("correct_option")→ permission denied.?session=<id>in a fresh tab renders viaget_session_review; landing count/domain chips still render.submit_answerwith a question not in the session, an already-answered question, or another user's session → error (oracle guard).use-qbank.tsexports types only. Sheet prompt schema appears once.npm run test/lint/buildgreen; manual pass of sheet, flashcards, explain, and enhance flows — all throughcallMedicalNotes.6. Rollout summary
callMedicalNotesconsume_usageRPC + policy swap + client increment removal7. Hard constraints (inherited, non-negotiable)
Schema changes additive only · no Stripe · OpenRouter only (no direct Anthropic/OpenAI/Gemini clients) · pro codes stay hidden · permissive TS config stays.