Status: Current. Verify implementation details against the linked source files.
DebateLab is a browser application. The client uses Supabase for sessions, persisted user data, database RPCs, and Edge Functions. Edge Functions keep privileged credentials server-side and integrate with AI providers, research services, Stripe, and Google APIs.
Browser or PWA
-> React routes, feature components, stores
-> Supabase Auth, PostgreSQL, RPCs, Edge Functions
-> AI providers, research, Stripe, Google Docs
src/bootstrap.ts imports the application entry immediately, then schedules Jam after window load and idle time. src/main.tsx mounts the application before starting observability and service-worker registration. Vercel browser telemetry is loaded behind a separate Suspense boundary.
src/App.tsx owns routes and top-level providers. Pages are lazy loaded. Public routes cover marketing, legal pages, login, sources, and shared content. /app/* routes pass through RequireAuth and include the main tools, the Current Debate dashboard (/app/debate), history, library, timer, sparring, flowchart, settings, and administration. The header holds only Home, the Current debate menu, Library, History, and the language, theme, and Settings controls; tools are launched from Home and the dashboard.
Collaborative Workspace (/app/workspaces/*, the workspace-invites function, and services/collaboration/) is hidden and on hold. Its routes register only when VITE_COLLABORATION_URL is set, which production does not do.
AuthProvider, TanStack Query, theme and motion providers, toast systems, the offline indicator, and the route error boundary wrap the routed application.
Styles are Tailwind CSS 4, configured in src/index.css (@theme, @custom-variant, @utility) and built by @tailwindcss/vite; there is no tailwind.config. Tailwind 4 needs Safari 16.4, Chrome 111 or Firefox 128. Older browsers lose cascade layers, and translucent colours such as bg-primary/10 render solid where color-mix() is missing.
The visual design (the DebateOwl style) comes from the design tokens in src/styles/tokens.css: plume blue, beak gold, mint for success (always with a check mark), coral, and blue-tinted ink, with dark values on .dark. src/index.css maps the shadcn roles and the palette families the app names directly onto them, and defines the tactile button (.btn, a ledge that presses down) and pressable tiles (.tile). Headings and buttons use Fredoka, body text Nunito, both self-hosted through Fontsource with metric-matched fallbacks. The owl's poses are hand-traced SVGs in src/assets/owl/, one per screen that shows an owl (see the pose list in Owl.tsx), drawn by src/components/brand/Owl.tsx (Owl, OwlAvatar, OwlMark), which crossfade between poses and respect reduced motion. The theme defaults to light; Dark and System are a choice in Settings and the header.
The repository is an npm workspace. The web app is the root package and still lives in src/. packages/core/ (@debatelab/core) holds platform-neutral TypeScript the iOS app will share: languages, input validation, the Zod schemas for AI requests and results, Crossfire and Sparring logic, and the Edge Function error reader. Its code may not use DOM globals, React, import.meta, or Deno specifiers; its tsconfig has no DOM library and ESLint enforces the rest. Each moved module leaves a one-line re-export at its old src/lib/ path, so web imports and test mocks are unchanged. The stores, the AI client and streaming, the Supabase client, and the i18n catalogs stay in src/ for now.
src/lib/stores/session-store.ts owns active AI results, conversations, saved work, sources, folders, flowcharts, autosave state, and persisted form state. It persists selected local state and hydrates authenticated rows from Supabase.
src/lib/stores/debate-store.ts owns the signed-in user's debates, the Current Debate pointer, session-only one-off mode, and the shared Switch and Start dialogs. Every source and every automatically recorded result carries a debateId; one-off work and unsorted legacy rows have none. The Library, History, the AI source context, and discovered-source autosave all read from one debate at a time, so material never crosses debates. Motion-bearing tools confirm a motion that differs from the Current Debate (switch, create, or use once) before submitting; with no Current Debate they instead offer to start one from the typed motion. The first-run tour starts with the Current Debate card (the Start debate step), then points at Library and Generate. When the one-time welcome is due, that step opens as the welcome closes. The app blocks with Continue, Start new, or Switch after five days without work in the Current Debate.
src/lib/stores/settings-store.ts owns language, theme, feature provider settings, prompts, and research preferences. It persists locally and synchronizes authenticated settings.
src/lib/stores/sparring-store.ts owns active sparring rounds in session storage and hydrates completed sessions from Supabase.
src/lib/supabase/AuthProvider.tsx coordinates store hydration and reset across authentication changes. Store code is not the database schema source of truth; migrations are.
The Library keeps one row per web page per debate. src/lib/source-url.ts normalizes URLs (host without www., no scheme, fragment, tracking parameters or trailing slash). addSource in the session store refuses a page the debate already holds, checking the loaded Library and then the debate's rows on the server, so manual adds, URL imports and research autosave all go through the same check; Undo is exempt. Autosave in src/lib/ai/sources.ts refreshes the existing row's research metadata instead, and its batches run one at a time within a tab. Two tabs inserting the same page at the same moment can still both pass the check; only a unique index would stop that, and existing duplicate rows on production keep one from being added.
A new source shows in the Library at once as a pending- row and is swapped for the saved row by id. Every write that turns sources on or off (the switch, a folder's Activate/Deactivate, the source editor) joins a per-source queue in the session store: the server applies them in click order, the newest decides what shows, a failure falls back to the last value the server accepted, and a server reload does not undo a write made while it ran.
src/hooks/use-subscription.ts estimates today's remaining AI requests from the account's ai_requests rows (refused calls and jev_* helper rows excluded) and its own rate_limits override. Global rate_limits rows are server-only, so the estimate only warns (Sparring setup) and informs (Settings); it is null, never blocking, when any read fails. The ai function's 402 stays the decision, and every tool shows it with the same daily-limit dialog.
packages/core/src/languages.ts is the shared catalog for English, Spanish, and Hungarian language codes, labels, and Intl locales. src/i18n.ts owns the locale resources and applies the selected interface language to i18next, document metadata, and <html lang>. Logged-out startup preserves local language preferences; authenticated hydration accepts only catalog languages.
Interface language and AI-output language are separate settings. Interface language controls rendered copy, accessible names, generated app boilerplate, dates, numbers, relative time, PDF chrome, and Web Speech's recognition locale for dictation. Voice rounds recognise speech in the AI-output language instead, because that is the language the round is held in. AI-output language is sent through every structured, streaming, Crossfire, and Sparring request path and becomes an explicit backend prompt instruction. Existing user/provider content and previously saved titles are not rewritten when either setting changes.
src/lib/supabase/env.ts resolves production or staging client configuration. Public URLs and anon keys are browser configuration. Do not copy their values into docs. Admins switch projects from Settings; see operations.md.
src/lib/supabase/client.ts creates the client with persistent sessions, automatic refresh, PKCE, and environment-specific storage keys. Its fetch notices when a signed-in request is refused (401 from REST or Functions, 401 or 403 from Auth's user lookup). session-expiry.ts then forces a token refresh outside that request: if Auth refuses the refresh too, the session was revoked, so the browser signs out locally and the login page says the session expired. A network failure, 5xx or rate limit keeps the session.
Client-side environment selection is a developer convenience, not authorization. Edge Functions and database policies must enforce access independently.
src/lib/ai/client.ts is the browser AI boundary: app code imports only from it. The request transport is request.ts (streaming Ask uses streaming.ts, live generation live.ts), and each feature has a file in features/, mirroring supabase/functions/ai/features/.
- A feature page validates input and calls a feature wrapper.
- The client reads the current Supabase session.
- It invokes
aiorai-streamwith selected controls, the Current Debate context as IDs only, and a bounded recent Ask history when needed. - The Edge Function validates authentication, loads the requested debate's material for the authenticated user and that debate only, then enforces input bounds, supported model and control combinations, access limits, and feature-specific rules.
- The backend optionally gathers research, builds prompts, calls an allowlisted model through OpenRouter, validates output, and records the provider-reported usage and charge.
- The client validates or transforms the response and updates the relevant store.
supabase/functions/ai/index.ts serves the structured tools and practice modes. It is the HTTP entrypoint and dispatcher: authentication, validation, entitlements, model routing, source ranking, and usage accounting run there once, then one handler per feature in supabase/functions/ai/features/ builds the prompt and shapes the output. supabase/functions/ai-stream/index.ts serves plain Ask over server-sent events. Each feature's task prompt and user-prompt builder lives in supabase/functions/_shared/prompts/ beside one module of shared rules.
Every structured answer is shown while the model writes it. A page passes a live callback to its feature wrapper, and the request adds stream: true. Every check before the feature handler (authentication, input, debate context, quota, model routing, source ranking) still answers with plain JSON and its real status. After that, supabase/functions/ai/stream.ts answers with server-sent events: phase (research, writing, checking), delta with the model's raw text, reset when a JSON repair retry starts the answer again, and one final result holding the status and body a plain request returns. Only the call whose output the user sees streams; research sub-calls and Jev decisions do not. The joined text is parsed exactly as a non-streamed reply, and streamed usage is billed the same way, marked incomplete if the stream breaks. On the client, src/lib/ai/live.ts reads the stream, packages/core/src/ai/partial-json.ts turns the JSON written so far into a draft, and the page renders it with the result's own labels. A JSON body is accepted too, so the client works against a server from before live generation.
A streamed run keeps its worker alive with EdgeRuntime.waitUntil (_shared/background.ts) from the first event until the HTTP layer has read the last one. Supabase otherwise counts the response as sent once its headers are out and may retire the worker mid-run (EarlyDrop). Streamed Ask (ai-stream) is held the same way until the browser has read its final event. A run still unanswered about 142 s after its request arrived is aborted and gets a retryable timeout result instead of a connection cut at the 150 s wall clock; when the aborted run answers within 2 s, that answer is sent instead, so it says charged: false when the units went back. Storing the result and waiting for the browser to read it are cut short to finish before the wall clock. The request sends its id as resultKey. The function then writes a row to ai_run_results when the run starts, and stores the result there before sending it (ai/run-results.ts; one row per user and feature, readable only by its owner). When the stream drops, stalls (no keepalive for 45 s), or delivers a result it cannot read, src/lib/ai/run-recovery.ts polls that row until the run's result appears, then resolves or fails exactly as if the event had arrived. A row still without a status long after it started is a run its worker never finished. A browser Stop, reload, or closed tab still cancels the run. The server cannot tell those from a lost connection, so it stops spending as soon as the connection goes.
A reply that hits its token cap is written again once with twice the cap, after a reset. The Generate cap (12,000 tokens) leaves room for a full comprehensive case on Claude Haiku, so a long case finishes on its first attempt. Claude rejects an assistant prefill, so a cut-off reply cannot be continued where it stopped. An R2 case is the longest reply (the speech and 10-12 full answers), and one call wrote it in up to 124 s of the 130 s budget, which cannot grow because both Supabase projects are on the Free plan and stop a worker at 150 s. So ai/r2-case.ts asks for a short plan first (our two arguments and the claims to answer, at low effort), then writes four parts at the same time at the student's model and effort: part 1 the speech and its share of the answers, streamed to the browser, the others only answers. Each part has its own smaller cap (caseWriterPart), which also keeps its reasoning budget small enough to finish well inside the budget. One failed part fails the case, stops the others, and bills every call. Sparring calls send a strict json_schema (sparringOutputSchema in ai/sparring-persistence.ts). Anthropic's structured outputs take no numeric or array-size limits, so the 0-25 range, the four categories, and the 12-item lists are enforced by the parser. When a reply parses but has the wrong shape, the log records only the failing field path, never the model's text. The call is then retried once with a repair prompt that names the field. A second failure answers invalid_output (502, logged as invalid_output), which the browser shows as a malformed answer. The judge also retries once when Claude is briefly unavailable or its stream breaks. The round has already paid, so retries cost the student nothing.
For a validated Crossfire next turn, the server returns the reply first with a short-lived signed sidecar token. The client then requests a Jev classification of only the newest completed question-answer exchange. The token is atomically claimed through its own one-use counter, separate from the product's daily AI quota. The optional directness, concession, and follow-up signal is attached by turn ID to the answer; a missing, slow, replayed, forged, or failed sidecar never delays or invalidates the main reply. Draft version 1 remains compatible because turn signals are optional.
Sparring and Crossfire can be spoken. The setup screen offers a voice switch only where the browser has the Web Speech recogniser and the microphone is not blocked; otherwise it shows a one-line reason and the round is typed as before. The first time the switch is turned on, a notice says where audio and text go, and src/lib/voice/availability.ts remembers the acknowledgement in that browser.
The student's speech never reaches DebateLab as audio. src/hooks/use-speech-capture.ts feeds the microphone to the browser's recogniser (in Chrome that sends audio to Google), restarts it when the browser ends a session on silence, and puts the recognised words into the same draft a typed turn uses. It also times the take, and packages/core/src/voice/delivery.ts turns that into measured facts: speaking time, words per minute, filler words from a per-language list, and silences of three seconds or more. The facts ride on the user's speech or turn as an optional delivery field, so sessions and drafts from before voice rounds still load. The sparring scorecard and the Crossfire debrief show them as numbers. The sparring judge receives them with a prompt that allows it to quote those numbers and comment on structure, and forbids any claim about tone, confidence, or volume; typed rounds keep the typed prompt unchanged. The Crossfire debrief is never asked to judge delivery.
A voice request adds a top-level voice: true beside input, where a server from before voice rounds ignores it. The opponent prompt then allows a short allowlist of inline audio tags such as [sighs] and [sarcastic]. supabase/functions/_shared/voice-tags.ts splits the model's reply in two: the clean text, with every tag removed, is what the response's output carries, what is signed, shown, saved, and read by the fallback voice; the spoken text keeps allowlisted tags, turns [pause] into the ellipsis Eleven v4 documents for pauses, rations tags to at most one per sentence and never two sentences running, and is cut into segments of at most 1,000 characters. Up to 12 segments are returned in a top-level voice.segments list, each with a 30-minute HMAC token from _shared/voice-token.ts that binds the text to the user and the feature; the verifier allows 30 seconds of clock difference between the two functions. Text past the twelfth segment comes back unsigned and without tags in voice.overflow, which only the browser voice reads, so a long reply is heard in full without raising the ElevenLabs cost of one reply. The spoken text lives only in memory in the browser.
src/lib/voice/opponent-voice.ts plays a reply segment by segment through the voice Edge Function, fetching one segment ahead and decoding with Web Audio. The function authenticates the caller, verifies the token so it speaks only text the server wrote, and returns voice_unavailable when ELEVENLABS_API_KEY is not set, before it reserves anything. It then checks the account, reserves one clip from the per-user daily voice cap through consume_daily_quota under the voice key (60 clips for paid access, 12 on a trial, overridable with a daily_voice_clips row in rate_limits), calls ElevenLabs (eleven_v4 for Sparring, eleven_v4_turbo for Crossfire), and logs an ai_requests row with the billed characters. Every attempt keeps its clip, including one ElevenLabs refuses, so replayed tokens cannot reach the provider without limit while it fails. Whenever a segment cannot be had, because the key is missing, the cap is reached, or the call fails, the browser's speechSynthesis reads the rest of the reply from the clean text and the round continues. After any refusal from the function other than an invalid token, the browser stops asking for the rest of that round and tries ElevenLabs again when the next round starts; a network error is retried on the next reply. In Sparring each of the student's turns opens with a stopped clock, so it waits while the opponent speaks; it runs once the student presses Start speaking, starts the microphone, or starts typing. Starting to speak stops the opponent's audio.
vercel.json allows the microphone for the app's own origin only (microphone=(self)); dictation and voice rounds both depend on it.
A configured mock provider bypasses Supabase for local and test behavior. It is not authenticated backend evidence.
Shared research, source ranking, URL validation, and prompt construction live under supabase/functions/_shared/.
Research makes two small model calls of its own: Ask decides whether a question needs web search, and deep research writes follow-up search queries. Both run on BACKGROUND_MODEL (Claude Haiku 5.5 at low effort) in supabase/functions/_shared/model-catalog.ts, not on the model the user picked; only the answer uses that model. Their usage is still added to the parent request's row.
The import endpoint validates public HTTP URLs, resolves redirect targets, blocks private networks, and enforces response timeout, content type, and size limits. Research paths bound source count and content before prompt construction.
supabase/functions/_shared/prompt-budget.ts holds the limits that set how many input tokens a request pays for: characters kept per fetched page, search snippets per research prompt, earlier Ask messages, and the Ask context cap. A research prompt lists each source once: a page read in full is not repeated as a search snippet, and a URL that several queries found appears a single time. For anthropic/* models the system prompt carries a cache_control breakpoint, so a repeat of the same prefix within five minutes is billed at the cache-read price; a prompt below the model's minimum cacheable length is sent uncached at the normal price.
Every AI request in a Current Debate carries a debateContext of IDs and preferences, never text: the debate, the side the request prepares, the Library source IDs, and the saved results to read. src/lib/debate/context.ts chooses the default with the rules in supabase/functions/_shared/debate-context.ts: the debate's active sources, pinned results first, then up to three of the newest results of the types that feature uses, for the requested side. A compact line above each tool's primary button shows that selection, and Review changes it for one request, or for a whole Crossfire or Sparring round, where Review appears only before the round starts; pins persist in debate_context_pins. supabase/functions/_shared/debate-context-loader.ts loads the motion, settings, instructions, sources, and result digests with every query filtered by the caller and the one debate, before quota or provider work. The prompt renders that material in delimited blocks followed by rules that it cannot override. Live sparring stays transcript-only: the scorecard sees only the motion and settings. Crossfire omits free-form instructions. Whole results are dropped when their budget is spent, and a Crossfire round that would pass its 100,000-character prompt budget drops its saved results. In both cases meta.debateContext reports what reached the model and what was set aside. A debate deleted on another device returns 404, and the browser reloads its debate list. One-off requests send no debate context. The retired per-user custom system prompt is no longer sent or honored; operator prompts in global_prompts remain. Jev scores relevance, importance, and evidence strength after the existing deterministic quality floor, while failures preserve the prior order. Generate also records a bounded source-use snapshot in the saved case output. Semantic counts and the latest case-use flag live in the existing source metadata JSON, so the Library can default to Smart ordering without a schema migration.
Ordinary Ask remains stateless while a conversation is in progress. The browser sends only the newest eight messages within a UTF-8 byte budget. Inside a debate, each finished exchange is recorded into one chat for that debate; in one-off mode, pressing Save persists the chat and its messages; Workspace threads use their separate durable server-side context path. Legacy local-only sources are not sent to the authenticated AI endpoint because they have no persisted UUID to authorize and load.
supabase/migrations/ owns schema history, RLS policies, functions, triggers, subscriptions, usage accounting, sharing, sparring, announcements, and demo access.
The browser uses user-scoped Supabase access. Privileged Edge Function operations use server credentials only after handler-level checks. Shared backend auth helpers live in supabase/functions/_shared/auth.ts.
Do not infer current schema from old README examples or store query names. Replay migrations and run pgTAP tests for database proof.
Settings offers three controls. "Download my data" calls the account Edge Function, which returns export_account_data, which holds the auth profile, every row whose ownership column (user_id, owner_id, author_id, created_by, or recipient_id) references the user, chat messages, published announcements addressed to the user, and announcements they wrote. Columns that only mention the user, such as actor_id or resolved_by, never select a row, and workspace rows are exported only while the user is still a member, as their read policies require. It leaves out credential and integrity columns and admin catalog work (promo codes, curated sources, global prompts, demo codes). Admin grants appear as grant type and dates only, because RLS hides their notes and author from the user. "Delete all my data" calls clear_current_user_data and keeps the account. "Delete account" asks the user to type their email (or DELETE for an account without one), then calls the account function.
Deletion runs in a fixed order, and a failure at any step leaves the account in place:
- Stop Stripe billing for every customer in
subscriptions, instripe_customers, or found by a Stripe search onmetadata.user_id. The function expires open Checkout sessions, then cancels live subscriptions immediately, without a refund. If Stripe cannot be reached, nothing is deleted.create-checkoutrecords the customer instripe_customersbefore it opens a session, because Stripe search can lag a new customer by up to a minute. A Checkout session opened in another tab during the few seconds deletion takes is not stopped. - Remove Storage objects whose owner is the user through the Storage API, because Storage forbids deleting objects with SQL.
source_files.storage_keyis user-writable, so it never selects an object for deletion. - Call
prepare_account_deletion, which deletes the workspaces the user owns and the user's own sources, threads, messages, and results in other workspaces. Those foreign keys stayRESTRICTso that a raw delete of an auth user cannot remove shared work by accident. - Delete the auth user with the Auth admin API. Every other foreign key into
auth.userscascades or sets null, and theon_auth_user_deletedtrigger clears the user from announcements, which have no foreign key.
Admin-authored rows such as promo codes, curated sources, announcements, and grants to other users survive with the author cleared. Stripe keeps its own invoices and payments for tax and accounting. A new foreign key to auth.users must cascade, set null, or be cleared by prepare_account_deletion. supabase/tests/account_deletion_test.sql fails if its fixture has no row in that table or if any row still mentions the deleted user.
Supabase Auth owns user sessions. Protected routes provide a client navigation boundary, but server authorization is decisive.
- Google OAuth, six-digit email OTP, and demo-code token-hash handoffs all finish as ordinary Supabase sessions and use the same store hydration path.
- Google and any emailed sign-in link return to
/auth/callback(with?next=/app/...for a deep link; only/apppaths are accepted).AuthProviderexchanges the code there and keepsloadingtrue until the exchange settles. The client setsexperimental.appendPkceFlowIdToRedirects, so each sign-in keeps its own PKCE verifier and its redirect carriessb_flow_id; another tab's sign-in or a failed resend no longer breaks a pending link. Anerror/error_codefrom Auth, or a failed exchange, opens/loginwith a localized message. Links that still point at/?code=or/app?code=are moved onto the callback route before the first render (src/lib/auth-callback.ts). Each project's Auth redirect allowlist needs<site origin>/auth/callback**; a redirect to the Site URL's own host passes without it. - Sign-out ends only this device's session (
scope: 'local'). After a sign-out,/loginreturns only the same account to the page it left; another account starts on/app. The onboarding tour is tracked per account on each browser. The one-time DebateOwl welcome (src/lib/debateowl-intro.ts) opens once per browser inside the signed-in app, is remembered in localStorage (debateowl_intro_seen_v1), and closing it continues straight into the tour's Start debate step. If that chunk fails twice, the app logs once, remembers the welcome, and continues without the tour.?intro=showopens the welcome again; a finished tour stays finished. - RLS owns row visibility and mutation rights.
- Shared backend helpers validate bearer tokens and admin roles.
- Admin endpoints verify server-side roles.
- Stripe webhook requests use Stripe signatures rather than user sessions.
- Demo-code login is a pre-session flow with hashing, fail-closed rate limits, bounded use, and a rule that demo access cannot grant administrator status. Existing demo accounts are reusable only when server-controlled Auth
app_metadatabinds the user to that code.
Checkout and portal functions create Stripe sessions after authenticated ownership and redirect validation. The webhook updates subscription state and records event processing for idempotency. AI handlers record the provider-reported usage and charge for each request.
resolveBillingState in supabase/functions/_shared/billing-state.ts decides an account's plan from user_settings and its subscriptions rows. The AI functions (through entitlements.ts) and the Settings card (through src/hooks/use-subscription.ts) both call it, so they agree on the state: trial, extended access (an expired trial or the extra_limit promo with daily_limit_override above 0), Pro, Pro set to cancel, payment failed, legacy free, trial ended, or subscription ended. A failed renewal keeps Pro for 14 days from the first failed payment while Stripe retries (PAYMENT_GRACE_DAYS, counted from subscriptions.payment_failed_at, which the webhook sets on the first invoice.payment_failed and clears on recovery); the webhook no longer downgrades the account on a failed invoice. An account without access is refused with trial_ended, payment_failed, or subscription_ended (older builds sent trial_expired for all three, and the client still accepts it). Settings offers Manage subscription, never a second Checkout, while a Stripe subscription can still bill.
All model requests use the single OpenRouter backend route and OPENROUTER_API_KEY. The shared ten-model catalog, its defaults (Claude Haiku 5.5 for Basic, Claude Sonnet 5.5 for Pro; Claude Opus 5.5 runs only when picked), Pro-only entries, retired-ID successors, and request controls live in supabase/functions/_shared/model-catalog.ts. Claude models run only on DebateLab's Anthropic key (OpenRouter BYOK), with no fallback provider; see supabase/README.md. Each feature's reply length cap lives in supabase/functions/_shared/output-caps.ts; max_tokens adds room for the reasoning effort on top. A JSON reply cut off at its cap is retried once with twice the budget, and a streamed Ask answer cut off there ends with a notice under it. OpenRouter usage remains raw in the audit row, and the reported charge is unknown when the provider does not return a complete cost. For a call billed to our own provider key (BYOK, which the Anthropic models use), OpenRouter's cost is only its fee, so the logged cost adds cost_details.upstream_inference_cost, and the weekly cap counts the same full charge. Exa searches and page reads report their charge as costDollars.total; it joins the request's row, with one raw_usage.calls entry per Exa call. The opponent's voice is the one exception to the OpenRouter route: the voice function calls ElevenLabs with ELEVENLABS_API_KEY. ElevenLabs reports billed characters, not dollars, so supabase/functions/_shared/cost.ts prices those rows from a dated list-price snapshot; promotions and plan credits are not modelled, so a voice row can overstate the charge.
Every model call asks for JSON. Calls whose shape the server checks in code (the Crossfire next turn and debrief, the Ask search decision, and research follow-up queries) send a strict JSON Schema, so OpenRouter routes them only to endpoints that enforce it; the other features use plain JSON mode. supabase/functions/ai/provider.ts fixes common slips in a reply locally (markdown fences, prose around the object, trailing commas, raw line breaks in strings) before it pays for one repair retry, and a reply the provider cut off at its cap is retried once with twice the budget, not with the repair prompt.
Auto Fast and Auto Pro are virtual selections, not provider model IDs. Auto routes only to Claude, which runs on DebateLab's Anthropic key (BYOK in OpenRouter); other families bill OpenRouter credits, so they run only when a student picks one by hand. Auto Fast is available to Basic accounts and resolves to Claude Haiku 5.5. Auto Pro remains Pro-only and resolves to Claude Sonnet 5.5; it never routes or falls back to Opus. With one candidate per pool the Edge Functions skip the TypeSafe Jev routing call; a pool with more than one candidate (all anthropic/, which src/test/ai/anthropic-only-guard.test.ts enforces) is resolved through Jev on OpenRouter before calling the concrete writing model. Provider calls, usage rows, and response metadata record the concrete resolved model, while response metadata also retains the requested automatic selection. A Jev router call uses the same OpenRouter key and does not consume a second daily quota unit.
The daily AI quota counts units, not requests. checkRateLimit in supabase/functions/_shared/entitlements.ts reserves the requested model's weight through consume_ai_quota (Claude Opus 5.5 counts as 5, Sonnet as 2, cheap models as 1; an Auto selection counts as its most expensive candidate because the quota is reserved before routing. The Auto weights are derived from the candidate pools, so Auto Pro counts as Sonnet's 2 while Sonnet is its only candidate). The same call refuses the request with weekly_budget_reached once the user's weekly AI spend reaches the weekly budget ($2 by default, calendar week from Monday 00:00 UTC). An admin can change one account's budget in user_settings, which the user cannot write: weekly_budget_override_usd replaces the $2 and weekly_budget_unlimited = true skips the weekly check; the daily quota still applies. The quotaCheck peek uses the same per-account budget. Weekly spend is the sum of ai_requests.cap_cost_usd: each request's full logged charge, including Claude calls billed to our own BYOK Anthropic key, plus Exa and ElevenLabs charges. A BYOK response without the upstream charge counts OpenRouter's fee, the part that is known. When part of a request's cost is unknown, the known part counts; rows with no known cap spend, and rows logged before the column existed, do not count. Cost is logged after the provider answers, so the cap can be passed by about one request. The weights, the budget, and the tier defaults live in supabase/functions/_shared/usage-limits.ts. Voice, import, share, and Crossfire signal quotas keep calling consume_daily_quota, one unit per call, and are not gated by the weekly budget (voice spend still counts toward it).
Crossfire and Sparring are charged per round, not per call. The round's first call reserves the model's units through claim_ai_session_quota (20261022220000_ai_quota_sessions.sql), keyed by the Sparring sessionId or the Crossfire round id the client sends as quotaSession beside input; later turns, the debrief, and the judge in that round reserve nothing. A round past its call cap or lifetime, or switched to a heavier model, pays again (SESSION_QUOTA in usage-limits.ts), so a reused id cannot buy unlimited AI. A Crossfire request without quotaSession (an older client) is charged per call. Before a round starts, both pages send a quotaCheck request, which reserves nothing and answers with the round's cost and today's count, so a round the server would refuse never starts.
A run that produces nothing costs nothing. When an ai request fails on the server (a provider failure, a reply cut off at its cap, an answer that failed validation, the time budget running out) and its answer was not saved, the function gives its units back, however much of it had streamed. When the browser goes away (reload, closed tab, Stop, a dropped connection; the server cannot tell these apart), the run is refunded only before REFUND_MAX_DELIVERED_CHARS of streamed output reached the student and before the model finished its answer. Otherwise a student could read most of an answer and press Stop to get the unit back. For the plain-text Ask stream the streamed text is the answer, so ai-stream refunds only before REFUND_MAX_DELIVERED_CHARS reached the reader, whatever ended the run. A refund goes through refund_daily_quota, or refund_ai_session_quota for the call that paid for a round, at most once per run, and the answer carries charged: false, which the client shows as "You were not charged". The weekly cap still counts what the run cost. The usage line in Settings and on every tool page reads the same counter through ai_usage_today() (the caller's own units today), so per-round charging and refunds show there, and it is read again after each AI call (refreshAIUsage in src/lib/query-client.ts). When the counter cannot be read (an older database), the line is hidden rather than estimated from ai_requests rows. ai_requests.error_code holds a short code (provider_error, timeout, aborted, claude_unavailable, the refusal codes); logUsage maps anything else to one of those and sends the raw text, truncated, to the function log only.
Client startup loads Sentry and PostHog asynchronously when configured. Product events and exceptions use the privacy-filtering facade in src/lib/observability.ts, so SDK loading or failure cannot block rendering. Sentry events carry the build's commit SHA as the release and the Vercel environment name. Route crashes keep the original error type and stack frames but replace the message, which can carry user text. Vercel Analytics and Speed Insights run only in the web build and load outside the application render boundary.
The facade accepts only the event names and property values listed in it. ai_request_failed says why a request failed: tool, error_code (one of limit_reached, model_restricted, auth_expired, timeout, network, aborted, provider_error, invalid_response, server_error, unknown, mapped from the client's AI error classes in src/lib/ai/failure-reason.ts), http_status, stage (request, stream, parse), streaming and duration_ms. The same file decides each failure's next step: the plan dialog, Sign in again, or Retry. The activation events in src/lib/activation-events.ts are signup_completed (method), trial_started, paywall_shown (reason, tool), first_ai_success (tool) and welcome_completed. The once-per-account ones remember what they sent in this browser's local storage. first_ai_success is sent only for an account made in the last seven days, so an older account's next answer is not counted as its first. PostHog keeps its own state in memory and is never told who the user is, so it cannot join one student's events across page loads.
Every React.lazy component and route loader goes through loadChunk in src/lib/lazy-chunk.ts. After a deploy removes an old file, the page reloads once per path in ten minutes (the time is kept in sessionStorage and shared with the vite:preloadError listener, so a file still missing after the reload stays an error while a later deploy can reload again) instead of crashing on a module that loaded as nothing.
CI and staging must not upload authenticated browser captures. Logging must not expose tokens, provider keys, user content, or protected staging values.
The service worker (public/sw.js) and manifest provide installable web assets and an offline app. At install it precaches the landing page, app-shell.html, the static identity assets, and a list the build writes into the worker (serviceWorkerPrecache in vite.config.ts): the shell's scripts and styles, the signed-in layout, the Home, Library, History, Timer and Settings pages, the Spanish and Hungarian strings, the Latin subsets of Nunito and Fredoka, and the owl poses those pages show. After one online visit those pages open offline and the language can be switched there. Other route chunks enter the runtime cache when a user visits them or hovers their link; on first registration the client also reports same-origin /assets/ resources that loaded before the worker took control. Offline, app routes (/app, /login, /share, the paths vercel.json rewrites to the shell) get the precached shell, and a public page gets only its own HTML, stored under its own path when visited. /auth/callback, Supabase paths and /_vercel/ never touch a cache. Each build's caches carry the build's id and the next build's worker deletes them on activation. Updates wait for the normal service-worker lifecycle instead of reloading an active page. Offline support does not make other unvisited routes, authenticated Supabase, or AI operations available without a network.
Only English strings ship in the entry. src/i18n.ts fetches the Spanish or Hungarian catalogs when that language is chosen, and the first render waits for them.
Vercel builds dist/ with Node 22 and serves it. vercel.json owns the app-route rewrites, the 404 for everything else, cache policy, crawler restrictions, response security headers, and which Git branches deploy. A push to main deploys production; any other branch does not until someone requests a preview (operations.md). Supabase migrations and Edge Functions deploy separately. GitHub workflow files own quality, live-provider, staging, and canary automation.
Read operations.md before changing deployment behavior and testing.md before claiming a boundary works.