diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index d13f94abf..8c765cf29 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -32,17 +32,22 @@ jobs:
- name: Install dependencies
run: npm ci
- - name: Lint
- run: npm run lint
+ - name: Install Playwright Chromium
+ run: npx playwright install --with-deps chromium
- - name: Typecheck
- run: npm run typecheck
+ - name: Verify
+ run: npm run verify
- - name: Test
- run: npm test -- --coverage
-
- - name: Build
- run: npm run build
+ - name: Upload browser failure evidence
+ if: failure()
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+ with:
+ name: playwright-failure-evidence-${{ github.run_id }}
+ path: |
+ test-results
+ playwright-report
+ if-no-files-found: ignore
+ retention-days: 7
- name: Set up QEMU
if: env.DOCKER_PUSH == 'true'
diff --git a/.gitignore b/.gitignore
index 049ad9846..4a594fa00 100644
--- a/.gitignore
+++ b/.gitignore
@@ -34,7 +34,9 @@ coverage/
# Playwright artifacts
test-results/
+test-results-staging/
playwright-report/
+playwright-report-staging/
playwright/.cache/
# Deploy tool state
@@ -56,14 +58,16 @@ deno.lock
tasks.txt
humanizer.md
-# Local notes/docs, except the project README
+# Local notes/docs, except project-maintained records
*.md
*.txt
*.info
!README.md
+!AGENTS.md
!**/README.md
!docs/pricing-snapshots/*.md
!plans/**/*.md
+!security/reviews/*.md
.vercel
.env*
.worktrees/
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 000000000..2fead68e1
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,149 @@
+# DebateLab Agent Guide
+
+This file is the verification contract for coding agents working in DebateLab. Read it before changing code.
+
+## Runtime and package manager
+
+- Node.js: 22.x
+- Package manager: npm (`package-lock.json` is canonical)
+- Web app dev port: 8080 by default
+- Local Supabase port used by existing tooling: 54321
+- Production web app: `https://debate.pi-stack.org`
+- Frontend: Vite + React + TypeScript
+- Backend source: Supabase Edge Functions and SQL migrations under `supabase/`
+
+## Canonical verification
+
+After `npm ci`, install the browser once on a new machine:
+
+```bash
+npx playwright install chromium
+```
+
+On Linux CI or a fresh Linux host where system browser dependencies are missing:
+
+```bash
+npx playwright install --with-deps chromium
+```
+
+Then the canonical branch verification command is:
+
+```bash
+npm run verify
+```
+
+`npm run verify` must run, in order:
+
+1. ESLint
+2. TypeScript typechecking
+3. Vitest with the configured coverage thresholds
+4. Production Vite build
+5. Playwright deterministic browser journeys
+
+Useful narrower commands:
+
+```bash
+npm run verify:core
+npm run lint
+npm run typecheck
+npm test
+npm run test:coverage
+npm run build
+npm run test:e2e
+npm run test:e2e:ui
+```
+
+Do not report a change as verified if the relevant command did not execute. A GitHub Actions job that failed before obtaining a runner, with no executed steps, is infrastructure failure rather than a failed test result.
+
+## Verification ladder
+
+Use the smallest useful rung while developing, then run the full gate before considering the work complete:
+
+1. Static checks for edited TypeScript/React.
+2. Focused Vitest tests for the changed behavior.
+3. Full coverage-gated Vitest suite.
+4. Production build.
+5. Deterministic Playwright browser journeys.
+6. Live authenticated staging checks only when explicitly authorized and staging credentials already exist.
+7. Manual browser inspection only for visual judgment that automated assertions cannot establish.
+
+Every bug fix should gain a failing regression test first when practical. Never weaken an assertion or coverage threshold merely to make a branch green.
+
+## Browser failure evidence
+
+Playwright retains trace, screenshot, and video for failures. CI uploads `test-results/` and `playwright-report/` as a short-lived artifact when verification fails.
+
+When describing verification evidence, name the command or CI run. Do not say that a browser flow passed based only on code review.
+
+## Authentication and environment model
+
+Supabase credentials are selected in `src/lib/supabase/env.ts`.
+
+- Production project ref: `uziwdtkzofptihahfooo`
+- Staging project ref: `brifeyypjauyusmkptkx`
+- Runtime storage key: `debate-supabase-env`
+- Auth storage is environment-specific.
+
+The normal Playwright suite uses a deliberately fake localStorage session and mocks network behavior where a product journey needs backend output. That suite is safe for pull requests because it receives no privileged credential and does not mutate Supabase.
+
+A real staging-authenticated suite, when present, is a separate opt-in verification path. It must never run on untrusted pull-request code with staging secrets.
+
+## Supabase deployment boundary
+
+Source files under `supabase/functions/` and `supabase/migrations/` may be reviewed and changed when a task requires it, but source control and deployment are separate operations.
+
+**Do not perform any of these unless the user explicitly authorizes that exact operation:**
+
+- `supabase db push`
+- applying a migration to local/staging/production through a connected remote project
+- `supabase functions deploy`
+- `supabase secrets set`
+- linking or relinking the repository to a different remote project for deployment
+- modifying production or staging rows as a substitute for a test fixture
+
+For the `feature/sparring-verification-retention-loop` work, all Supabase deployment and mutation is explicitly out of scope. Keep migrations/functions un-applied and use deterministic mocks for executable CI.
+
+The existing `demo-code-login` endpoint is stateful: it reserves a demo-code use and may provision/update a user. Do not invoke it merely to prove a branch works when remote Supabase mutation is not authorized.
+
+## Secrets
+
+Never commit, print, paste into a PR comment, or expose in test artifacts:
+
+- service-role keys
+- provider API keys
+- Stripe secrets
+- demo-code pepper values
+- plaintext reusable demo access codes
+
+Public Supabase anon credentials are browser credentials, not server secrets, but still do not invent or rotate them casually.
+
+## AI product rules
+
+DebateLab is a debate-preparation and training product. User-facing AI behavior should preserve these invariants:
+
+- Do not fabricate evidence, citations, statistics, quotations, or source metadata.
+- Weak research sources are fallback context, not citation-quality evidence.
+- Controls must be honest: a visible toggle must actually change the request or deterministic post-processing behavior.
+- Typed Sparring cannot honestly judge vocal delivery, eye contact, speaking pace, or other audio-only qualities.
+- Prefer transcript-specific feedback over unexplained numeric scores.
+- Active Source Library items are factual grounding context; make that behavior visible rather than pretending a disabled control exists.
+
+## Sparring architecture
+
+Sparring deliberately avoids adding another feature branch to the large `supabase/functions/ai/index.ts` handler.
+
+- Schema: `src/lib/ai/schemas/sparring.ts`
+- AI orchestration: `src/lib/ai/sparring-client.ts`
+- Local persisted history: `src/lib/stores/sparring-store.ts`
+- UI: `src/components/features/sparring/` and `src/pages/SparringPage.tsx`
+- Export: `src/lib/sparring-export.ts`
+
+The first version is a four-speech loop: user opening, AI response, user rebuttal, AI response, then a qualitative scorecard. Completed sessions are local-first and must be cleared at auth boundaries so one browser account cannot inherit another account's transcripts.
+
+## Roadmap authority
+
+`plans/README.md` is the roadmap index, but the source tree is authoritative. Before implementing a plan, check whether the described files/behavior already exist. When a plan is substantially implemented, update the index so future agents do not repeatedly rebuild shipped work.
+
+## Pull requests and merging
+
+A draft PR may be used to obtain CI evidence. Do not merge a PR unless the user explicitly requests a merge. Do not enable auto-merge as a substitute for explicit authorization.
diff --git a/e2e/authed-pages.spec.ts b/e2e/authed-pages.spec.ts
index 5b0fa3ae8..d4f94af4d 100644
--- a/e2e/authed-pages.spec.ts
+++ b/e2e/authed-pages.spec.ts
@@ -9,6 +9,7 @@ const AUTH_ROUTES = [
{ path: '/app/brainstorm', name: 'brainstorm' },
{ path: '/app/factoids', name: 'factoids' },
{ path: '/app/ask', name: 'ask' },
+ { path: '/app/sparring', name: 'sparring' },
{ path: '/app/library', name: 'library' },
{ path: '/app/history', name: 'history' },
{ path: '/app/timer', name: 'timer' },
diff --git a/e2e/sparring-flow.spec.ts b/e2e/sparring-flow.spec.ts
new file mode 100644
index 000000000..fe8947868
--- /dev/null
+++ b/e2e/sparring-flow.spec.ts
@@ -0,0 +1,144 @@
+import { test, expect } from '@playwright/test';
+import { stubSupabaseSession, setLocale } from './helpers/auth';
+
+const scorecard = {
+ verdict: 'user-edge',
+ summary: 'The user won the central mechanism clash and did more comparative weighing in rebuttal.',
+ biggestImprovement: 'Front-load the comparison against narrower alternatives in the opening.',
+ strengths: ['The rebuttal directly answered the opponent mechanism.'],
+ missedArguments: ['The opening did not preempt the narrower-policy alternative.'],
+ categories: [
+ { category: 'argument-strength', rating: 'strong', evidence: 'The user established a clear participation benefit.', advice: 'Keep the causal claim this explicit.' },
+ { category: 'warrant-quality', rating: 'solid', evidence: 'The participation mechanism was plausible but brief.', advice: 'Add one more causal link.' },
+ { category: 'impact-analysis', rating: 'solid', evidence: 'The user explained why access matters but did not compare scale in the opening.', advice: 'Make the affected population comparative.' },
+ { category: 'engagement', rating: 'strong', evidence: 'The rebuttal answered the opponent funding alternative directly.', advice: 'Keep organizing rebuttal around their offense.' },
+ { category: 'rebuttal-efficiency', rating: 'strong', evidence: 'The response spent its time on the decisive alternative-policy clash.', advice: 'Preserve this collapse discipline.' },
+ { category: 'weighing', rating: 'solid', evidence: 'The user compared access, but not probability explicitly.', advice: 'Add a one-sentence probability comparison.' },
+ { category: 'evidence-use', rating: 'needs-work', evidence: 'No source was cited in the typed round.', advice: 'Use evidence only where it resolves a contested factual warrant.' },
+ { category: 'strategy', rating: 'strong', evidence: 'The rebuttal collapsed onto why the alternative did not solve access.', advice: 'Preempt that alternative earlier next time.' },
+ ],
+ nextDrill: 'Give a 60-second opening that preempts one narrower counter-policy before the opponent raises it.',
+};
+
+function askEnvelope(response: string) {
+ return JSON.stringify({ output: { response, followUpQuestions: [], uncertainties: [] } });
+}
+
+const corsHeaders = {
+ 'access-control-allow-origin': '*',
+ 'access-control-allow-headers': 'authorization, x-client-info, apikey, content-type',
+ 'content-type': 'application/json',
+};
+
+test.beforeEach(async ({ page }) => {
+ await setLocale(page, 'en');
+ await stubSupabaseSession(page);
+});
+
+test('sparring completes the training loop and hands the round into existing tools', async ({ page }) => {
+ let aiCall = 0;
+ let discussInput: { message?: string; history?: Array<{ role?: string; content?: string }> } | undefined;
+
+ await page.route('**/functions/v1/ai', async (route) => {
+ if (route.request().method() === 'OPTIONS') {
+ await route.fulfill({ status: 200, headers: corsHeaders, body: 'ok' });
+ return;
+ }
+
+ aiCall += 1;
+ if (aiCall === 4) {
+ const requestBody = route.request().postDataJSON() as { input?: typeof discussInput };
+ discussInput = requestBody.input;
+ await route.fulfill({
+ status: 200,
+ headers: corsHeaders,
+ body: askEnvelope('Focus the next drill on comparing universal access against targeted alternatives.'),
+ });
+ return;
+ }
+
+ const response = aiCall === 1
+ ? JSON.stringify({
+ speech: 'A universal free-transit policy is expensive. Targeted discounts can improve access while preserving funding for service quality.',
+ pressurePoints: ['You have not shown why targeted support cannot solve the same access problem.'],
+ })
+ : aiCall === 2
+ ? JSON.stringify({
+ speech: 'You still need to prove that universal access creates more benefit than investing the same budget in frequency and reliability.',
+ pressurePoints: ['The budget tradeoff is still underdeveloped.'],
+ })
+ : JSON.stringify(scorecard);
+
+ await route.fulfill({ status: 200, headers: corsHeaders, body: askEnvelope(response) });
+ });
+
+ await page.goto('/app/sparring');
+ await page.getByLabel('Motion').fill('This House would make public transport free at the point of use.');
+ await page.getByRole('button', { name: 'Start sparring' }).click();
+
+ const speechBox = page.locator('textarea').first();
+ const opening = 'Free public transport removes a direct price barrier and lets more people participate in work, education, and public life.';
+ await speechBox.fill(opening);
+ await page.getByRole('button', { name: 'Send opening' }).click();
+ await expect(page.getByText(/universal free-transit policy is expensive/i)).toBeVisible();
+ await expect(page.getByText(/targeted support/i)).toBeVisible();
+
+ await speechBox.fill('Targeted discounts preserve eligibility gaps and administrative friction, while universal access removes the barrier for every marginal trip.');
+ await page.getByRole('button', { name: 'Send rebuttal' }).click();
+ await expect(page.getByText(/frequency and reliability/i)).toBeVisible();
+
+ await page.getByRole('button', { name: 'Finish and get scorecard' }).click();
+ await expect(page.getByText('Your side had the edge')).toBeVisible();
+ await expect(page.getByText(/Front-load the comparison against narrower alternatives/i)).toBeVisible();
+ await expect(page.getByText('Weighing')).toBeVisible();
+ await expect(page.getByRole('button', { name: 'Rematch this motion' })).toBeVisible();
+ expect(aiCall).toBe(3);
+
+ await page.getByRole('button', { name: 'Attack my round' }).click();
+ await expect(page).toHaveURL(/\/app\/attack$/);
+ await expect(page.locator('#caseText')).toHaveValue(new RegExp(opening.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
+
+ await page.goto('/app/sparring');
+ await page.getByRole('button', { name: 'Discuss with Ask' }).click();
+ await expect(page).toHaveURL(/\/app\/ask$/);
+ await expect(page.getByText(/Focus the next drill on comparing universal access/i)).toBeVisible();
+
+ expect(discussInput?.message).toContain('I just finished a DebateLab sparring round');
+ expect(discussInput?.history?.some((entry) => entry.content === discussInput?.message)).toBe(false);
+ expect(aiCall).toBe(4);
+});
+
+test('sparring can retry a failed opponent response without losing the user speech', async ({ page }) => {
+ let aiCall = 0;
+ await page.route('**/functions/v1/ai', async (route) => {
+ if (route.request().method() === 'OPTIONS') {
+ await route.fulfill({ status: 200, headers: corsHeaders, body: 'ok' });
+ return;
+ }
+
+ aiCall += 1;
+ const response = aiCall === 1
+ ? ''
+ : JSON.stringify({
+ speech: 'A narrower subsidy still leaves eligibility friction, but you need to show why that friction is large enough to justify universality.',
+ pressurePoints: ['Quantify or comparatively explain the eligibility-friction harm.'],
+ });
+ await route.fulfill({ status: 200, headers: corsHeaders, body: askEnvelope(response) });
+ });
+
+ await page.goto('/app/sparring');
+ await page.getByLabel('Motion').fill('This House would make public transport free at the point of use.');
+ await page.getByRole('button', { name: 'Start sparring' }).click();
+
+ const opening = 'Universal access removes price and eligibility friction at the same time, increasing participation across marginal trips.';
+ await page.locator('textarea').first().fill(opening);
+ await page.getByRole('button', { name: 'Send opening' }).click();
+
+ await expect(page.getByText(opening)).toBeVisible();
+ await expect(page.getByRole('button', { name: 'Retry opponent' })).toBeVisible();
+ await page.getByRole('button', { name: 'Retry opponent' }).click();
+
+ await expect(page.getByText(/narrower subsidy still leaves eligibility friction/i)).toBeVisible();
+ await expect(page.getByText(opening)).toHaveCount(1);
+ expect(aiCall).toBe(2);
+});
diff --git a/e2e/staging/README.md b/e2e/staging/README.md
new file mode 100644
index 000000000..6106e5298
--- /dev/null
+++ b/e2e/staging/README.md
@@ -0,0 +1,49 @@
+# Stateful staging E2E
+
+This directory is deliberately excluded from the ordinary `playwright.config.ts` suite and from `npm run verify`.
+
+## Why it is separate
+
+`auth.setup.ts` calls the deployed staging `demo-code-login` Edge Function. That operation is **stateful**:
+
+- it reserves a use of the supplied demo code;
+- it can create the demo user;
+- it updates the demo user's role/settings;
+- the live Sparring canary consumes AI provider quota and writes normal usage telemetry.
+
+Do not run this suite on pull-request code merely because credentials happen to be available.
+
+## Required environment
+
+Provide these values through the shell or an approved secret store. Never commit them to the repository or print them in logs.
+
+```text
+E2E_STAGING_SUPABASE_URL
+E2E_STAGING_ANON_KEY
+E2E_STAGING_DEMO_CODE
+```
+
+The anon key is a browser credential, but keep the harness configurable so the test cannot silently point at the wrong project. The demo code is reusable access material and must be treated as a secret.
+
+Optional:
+
+```text
+STAGING_PLAYWRIGHT_BASE_URL=http://localhost:8080
+```
+
+The frontend process is forced to `VITE_FORCE_SUPABASE_ENV=staging` by `playwright.staging.config.ts`.
+
+## Run
+
+Only after remote staging mutation has been explicitly authorized:
+
+```bash
+npx playwright install chromium
+npm run test:e2e:staging
+```
+
+The setup exchanges the demo code for a real staging session and saves only the browser storage state under ignored `test-results/.auth/`. The authenticated project then verifies that the session survives reload and that one real Sparring opponent request can travel end to end.
+
+## Pull-request safety
+
+Do not add this config to ordinary pull-request CI with staging secrets. Pull-request code controls the test process and must not receive reusable staging credentials.
diff --git a/e2e/staging/auth.setup.ts b/e2e/staging/auth.setup.ts
new file mode 100644
index 000000000..f971f9b60
--- /dev/null
+++ b/e2e/staging/auth.setup.ts
@@ -0,0 +1,66 @@
+import { mkdir } from 'node:fs/promises';
+import { dirname } from 'node:path';
+import { test as setup, expect } from '@playwright/test';
+import { createClient } from '@supabase/supabase-js';
+
+const authFile = 'test-results/.auth/staging.json';
+
+function requireEnv(name: string): string {
+ const value = process.env[name]?.trim();
+ if (!value) {
+ throw new Error(`${name} is required for the stateful staging E2E suite. This suite must not run in ordinary pull-request CI.`);
+ }
+ return value;
+}
+
+setup('authenticate against the pre-provisioned staging demo account', async ({ page, request, baseURL }) => {
+ const supabaseUrl = requireEnv('E2E_STAGING_SUPABASE_URL').replace(/\/$/, '');
+ const anonKey = requireEnv('E2E_STAGING_ANON_KEY');
+ const demoCode = requireEnv('E2E_STAGING_DEMO_CODE');
+ const appBaseUrl = baseURL || 'http://localhost:8080';
+
+ // This endpoint is intentionally stateful: it consumes a demo-code use and
+ // may provision/update the corresponding demo account. Never call this setup
+ // merely to verify an untrusted pull request.
+ const login = await request.post(`${supabaseUrl}/functions/v1/demo-code-login`, {
+ headers: {
+ apikey: anonKey,
+ authorization: `Bearer ${anonKey}`,
+ origin: appBaseUrl,
+ 'content-type': 'application/json',
+ },
+ data: {
+ code: demoCode,
+ browserFingerprint: `debatelab-playwright-${Date.now()}`,
+ redirectTo: `${appBaseUrl}/app`,
+ },
+ });
+
+ expect(login.ok(), `demo-code-login failed with HTTP ${login.status()}`).toBeTruthy();
+ const handoff = await login.json() as { tokenHash?: string; type?: string; error?: string };
+ expect(handoff.tokenHash, handoff.error || 'demo-code-login did not return a token hash').toBeTruthy();
+
+ const client = createClient(supabaseUrl, anonKey, {
+ auth: {
+ persistSession: false,
+ autoRefreshToken: false,
+ detectSessionInUrl: false,
+ },
+ });
+ const { data, error } = await client.auth.verifyOtp({
+ token_hash: handoff.tokenHash!,
+ type: 'magiclink',
+ });
+ if (error || !data.session) {
+ throw new Error(`Staging OTP verification failed: ${error?.message || 'no session returned'}`);
+ }
+
+ await page.goto('/');
+ await page.evaluate((session) => {
+ window.localStorage.setItem('debate-supabase-env', 'staging');
+ window.localStorage.setItem('debate-auth-staging', JSON.stringify(session));
+ }, data.session);
+
+ await mkdir(dirname(authFile), { recursive: true });
+ await page.context().storageState({ path: authFile });
+});
diff --git a/e2e/staging/authenticated.spec.ts b/e2e/staging/authenticated.spec.ts
new file mode 100644
index 000000000..5cf67b1b7
--- /dev/null
+++ b/e2e/staging/authenticated.spec.ts
@@ -0,0 +1,28 @@
+import { test, expect } from '@playwright/test';
+
+test('real staging session survives reload and stays inside the protected app', async ({ page }) => {
+ await page.goto('/app');
+ await expect(page.locator('body')).toBeVisible();
+ expect(page.url()).not.toContain('/login');
+
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await expect(page.locator('body')).toBeVisible();
+ expect(page.url()).not.toContain('/login');
+});
+
+test('real staging auth can complete one Sparring opponent turn', async ({ page }) => {
+ test.setTimeout(180_000);
+
+ await page.goto('/app/sparring');
+ await page.getByLabel('Motion').fill('This House would require social media platforms to offer a chronological feed by default.');
+ await page.getByRole('button', { name: 'Start sparring' }).click();
+
+ await page.locator('textarea').first().fill(
+ 'A chronological default reduces platform incentives to maximize engagement through opaque ranking, while still letting users opt into recommendations if they want them.',
+ );
+ await page.getByRole('button', { name: 'Send opening' }).click();
+
+ // The text is rendered only after the authenticated AI request completes.
+ await expect(page.getByText('AI opponent')).toBeVisible({ timeout: 150_000 });
+ await expect(page.getByRole('button', { name: 'Send rebuttal' })).toBeVisible();
+});
diff --git a/package.json b/package.json
index 3572f0058..8df01c3b2 100644
--- a/package.json
+++ b/package.json
@@ -19,8 +19,10 @@
"test:coverage": "vitest run --coverage",
"test:watch": "vitest",
"test:e2e": "playwright test",
+ "test:e2e:staging": "playwright test -c playwright.staging.config.ts",
"test:e2e:ui": "playwright test --ui",
- "verify": "npm run lint && npm run typecheck && npm test -- --coverage && npm run build"
+ "verify:core": "npm run lint && npm run typecheck && npm test -- --coverage && npm run build",
+ "verify": "npm run verify:core && npm run test:e2e"
},
"dependencies": {
"@capacitor/app": "^8.1.0",
diff --git a/plans/README.md b/plans/README.md
index 66599ccf5..2c8972fcb 100644
--- a/plans/README.md
+++ b/plans/README.md
@@ -1,24 +1,55 @@
# Plans
-This folder holds implementation plans for remaining DebateLab product work. Keep plans actionable: scope, files, data model, rollout steps, tests, and deployment notes.
+This folder tracks remaining DebateLab product work. The source tree is the authority: a plan is active only when it describes work that is not already present on the current branch.
-## Active Plans
+## Current branch status
+
+`feature/sparring-verification-retention-loop` implements the planned product/verification source work:
+
+- Sparring v1: four-speech practice loop, transcript-grounded qualitative scorecard, retry recovery, local history, exports, analytics, and handoffs into Ask/Attack/Brainstorm/Factoids.
+- Verification loop: one `npm run verify` gate covering lint, typecheck, coverage, build, and deterministic Playwright browser journeys with retained failure evidence.
+- Authenticated staging E2E: prepared as a separate opt-in stateful suite, deliberately excluded from PR CI and not executed while remote Supabase mutation is prohibited.
+- Post-remediation security validation: original August findings mapped to current source, regression tests strengthened, and branch-only backend hardening recorded under `security/reviews/`.
+- Search Quality: first-class Exa domain/category filters, raw request tests, handler-boundary research coverage, and Factoids consulted-source provenance/UI are implemented in source.
+
+No branch migration or Edge Function has been applied/deployed to Supabase.
+
+## NEXT — data and authorized deployment, not another feature pile
+
+- Run the full verification gate on an available runner and fix any executed failures.
+- When explicitly authorized, review/apply the pending Supabase migrations and deploy changed functions to a controlled staging environment, then execute the separate stateful staging suite.
+- Run a fresh official security scan against the deployment candidate after those backend changes are actually active.
+- Measure Sparring activation, completion, rematch/revisit behavior, feedback-action usage, and conversion before expanding the training product.
+- Choose follow-up Sparring work from evidence: format presets, richer drills, voice/live-round experiments, and longitudinal progress only if the core loop proves useful.
+- Conversion/retention work should follow observed funnel behavior rather than assumed feature demand.
+
+## LATER
- [Admin announcements](./admin-announcements.md)
-- [Ask and sitewide search quality](./search-quality.md)
-- [Control honesty and deterministic toggles](./control-honesty.md)
-- [Debate sparring and streaming](./sparring-and-streaming.md)
-- [Model cost accounting](./model-cost-accounting.md)
-- [Prompt evaluation sheet (manual)](./prompt-eval-sheet.md)
-- [Quality & Truth release checklist](./release-checklist.md)
-- [Rate-limit UI cleanup](./rate-limit-ui-cleanup.md)
-- [Source auto-import](./source-auto-import.md)
-- [Streaming AI](./streaming-ai.md)
-- [System prompt improvement](./system-prompt-improvement.md)
- [Animation polish](./animation-polish.md)
+- [Rate-limit UI cleanup](./rate-limit-ui-cleanup.md)
+- Further model/prompt experiments supported by evaluation data.
+
+## Historical / substantially implemented
+
+These files are kept for implementation history. Do not treat them as current instructions without checking the source tree first.
+
+- [Ask and sitewide search quality](./search-quality.md) — implementation record; formerly deferred domain filtering, handler coverage, and Factoids consulted-source UI are now present on this branch.
+- [Streaming AI](./streaming-ai.md) — Ask streaming endpoint/client/tests are already present.
+- [Source auto-import](./source-auto-import.md) — import endpoint, validation, schema, Library integration, and tests are present; this branch further hardens its network egress in source.
+- [Control honesty and deterministic toggles](./control-honesty.md) — core enforcement is implemented and tested.
+- [Debate sparring and streaming](./sparring-and-streaming.md) — original design record; Sparring v1 is implemented with a deliberately narrower four-speech loop.
+- [Model cost accounting](./model-cost-accounting.md) — accounting helpers, audit metadata, and tests are present; remaining work is operational validation.
+- [System prompt improvement](./system-prompt-improvement.md) — layered shared prompt architecture is present.
+- [Quality & Truth release checklist](./release-checklist.md) — use as release history/checklist, not a live roadmap.
+- [Supabase staging](./supabase-staging.md) and [Agent verification loop](./agent-verification-loop.md) — useful design references, but migration/function/secret deployment is explicitly outside the current branch execution boundary.
## Done
- [Bug and polish cleanup](./done/bugs.txt)
- [Admin trial gifts](./done/admin-trial-gifts.md)
- [Top users email fallback](./done/top-users-email-fallback.md)
+
+## Planning rule
+
+Every active plan should name scope, data ownership, rollout, automated verification, and what is deliberately not being changed. If implementation has landed, update this index in the same branch so agents do not repeatedly rediscover stale work.
diff --git a/plans/search-quality.md b/plans/search-quality.md
index e11af3e59..421fe6779 100644
--- a/plans/search-quality.md
+++ b/plans/search-quality.md
@@ -1,56 +1,52 @@
-# Ask and Sitewide Search Quality Plan
+# Ask and Sitewide Search Quality — Completed Implementation Record
## Goal
Make web research trustworthy across Generate, Attack, Brainstorm, Factoids, and Ask. Search should prefer specific, primary, current sources and visibly downgrade broad fallback sources.
-## Current Baseline
-
-- The shared Supabase `ai` function uses Exa for search and content extraction.
-- Search now runs multiple deterministic queries, AI follow-up queries, source ranking, and reads the strongest URLs first.
-- Ask displays source quality metadata in `searchInfo.sources`.
-
-## Next Implementation Steps
-
-1. Move research code from `supabase/functions/ai/index.ts` into `supabase/functions/_shared/research.ts`.
-2. Add topic-specific query builders:
- - debate tournament: official pages, Tabbycat, Calicotab, WSDC, WUDC, tab/results pages.
- - policy/statistics: government, intergovernmental orgs, academic papers, high-quality news.
- - legal/current events: official laws, court pages, agencies, primary reporting.
-3. Add source quality metadata to all discovered source returns, not only Ask.
-4. Add source display components for Generate, Attack, Brainstorm, and Factoids where useful.
-5. Store source ranking metadata in saved source records so users can review why a source was selected.
-6. Add a "source quality floor":
- - strong/moderate sources can support citations.
- - weak sources can only appear as fallback context with an uncertainty.
-7. Add integration tests with mocked Exa responses for each feature.
-
-## Tests
-
-- Deno tests for ranking and query building.
-- Mocked edge function tests for Ask citation fallback.
-- Frontend tests for displaying source quality labels.
-- Manual browser test with current tournament queries and policy-stat queries.
-
-## Deployment
-
-Deploy `supabase functions deploy ai --project-ref uziwdtkzofptihahfooo` after Deno checks, frontend tests, and a local authenticated Ask test pass.
-
-## Status after Quality & Truth Release (branch t3code/quality-truth-release)
-
-Implemented:
-
-1. Research pipeline extracted to `supabase/functions/_shared/research.ts` (Exa search, follow-ups, ranking context, citation sources) with an injected model caller so it is testable; ranking/query builders moved to `_shared/source-quality.ts`.
-2. Topic-specific query builders (tournament, policy/statistics, legal/current events) were already present and are now covered by tests in the shared module.
-3. All discovered-source returns carry quality metadata; Factoids now also returns `discoveredSources`.
-4. Source display: shared `SourceQualityList` renders strong/moderate/weak labels plus "fallback context only" for weak sources in Generate, Attack, and Brainstorm results. Factoids shows only verified citations (quality-labeled consulted-list display deferred).
-5. Provenance persists to saved sources: `sources.metadata` jsonb (migration `20260822000001`, prepared but NOT applied) stores url, sourceType, qualityScore, qualityLabel, originating query, capturedVia.
-6. Source-quality floor completed: factoid citations verify only against fetched non-weak research content (`applyFactoidsVerification`); weak or unknown URLs are downgraded and filtered. Ask fallback already excluded weak sources.
-
-Tests: `_shared/research.test.ts`, extended `source-quality.test.ts`, frontend `SourceQualityList.test.tsx`, session-store provenance round-trip, client discoveredSources tests.
-
-Deferred:
-
-- Replace inline `site:gov` query text with Exa's dedicated domain-filter request field (needs a live Exa parameter-verification pass).
-- Handler-level integration test that mocks Exa end-to-end through `Deno.serve`; current coverage is at the extracted-function level plus frontend mocks.
-- Factoids UI panel listing all consulted sources with quality chips.
+## Completed source work
+
+1. Research is isolated in `supabase/functions/_shared/research.ts`, with model calls injected through `ResearchDeps` so the research layer remains testable.
+2. Topic-aware request planning covers:
+ - debate tournaments: official/tab/results searches, Tabbycat/Calicotab/WSDC/WUDC terminology;
+ - policy/statistics: government and intergovernmental data, academic evidence, current reporting;
+ - legal/current events: official laws, courts, agencies, primary sources.
+3. Preferred Exa source types are represented as first-class request fields rather than query-operator hacks:
+ - government/public-sector searches use `includeDomains` with a curated domain set;
+ - academic searches use Exa's `research paper` category;
+ - news searches use Exa's `news` category;
+ - a zero-result filtered request retries the same query without the filter to preserve recall.
+4. Search results are ranked with source type, quality score, quality label, reasons, query provenance, recency, specificity, and fallback penalties.
+5. Strong/moderate sources may support citations. Weak sources remain fallback context only.
+6. Generate, Attack, and Brainstorm expose researched source quality through the shared `SourceQualityList`.
+7. Factoids now returns `discoveredSources` separately from verified factoids and renders all consulted pages with quality chips. Weak consulted sources remain visible as fallback context but cannot become verified evidence.
+8. Ask keeps search metadata and citation fallback quality-gated.
+9. Saved source provenance can store source type, quality score/label, originating query, and capture path where the database migration is available.
+
+## Verification in source
+
+Coverage now includes:
+
+- ranking and topic-specific request planning;
+- raw Exa request body assertions for `includeDomains`, category, `numResults`, and text-size fields;
+- filtered-search zero-recall fallback;
+- quality-floor enforcement for Factoids;
+- frontend consulted-source labels and weak-source warnings;
+- Factoids client provenance parsing;
+- a handler-boundary Factoids test that imports the actual `supabase/functions/ai/index.ts` entrypoint through the Vitest `Deno.serve` loopback shim while mocking Supabase, Exa, and model-provider network boundaries.
+
+Relevant tests include:
+
+- `supabase/functions/_shared/research.test.ts`
+- `supabase/functions/_shared/research-exa-filters.test.ts`
+- `supabase/functions/ai/source-quality.test.ts`
+- `supabase/functions/ai/handler-research.test.ts`
+- `src/test/components/SourceQualityList.test.tsx`
+- `src/test/components/FactoidsResults.test.tsx`
+- `src/test/ai/factoids-provenance.test.ts`
+
+## Deployment status
+
+This record describes repository source. On `feature/sparring-verification-retention-loop`, **no Supabase migration or Edge Function has been applied or deployed**. Any backend behavior changed on this branch remains pending explicit deployment authorization.
+
+`supabase/migrations/20260822000001_source_provenance_metadata.sql` and any later branch migrations are source artifacts until separately applied.
diff --git a/playwright.config.ts b/playwright.config.ts
index 97c7daaa2..d13e5c90d 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -5,15 +5,18 @@ const baseURL = process.env.PLAYWRIGHT_BASE_URL || `http://localhost:${PORT}`;
export default defineConfig({
testDir: './e2e',
+ testIgnore: '**/staging/**',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 2 : undefined,
reporter: process.env.CI ? [['github'], ['html', { open: 'never' }]] : 'list',
+ outputDir: 'test-results',
use: {
baseURL,
- trace: 'on-first-retry',
+ trace: 'retain-on-failure',
screenshot: 'only-on-failure',
+ video: 'retain-on-failure',
locale: 'en-US',
},
projects: [
diff --git a/playwright.staging.config.ts b/playwright.staging.config.ts
new file mode 100644
index 000000000..36d6eb700
--- /dev/null
+++ b/playwright.staging.config.ts
@@ -0,0 +1,51 @@
+import { defineConfig, devices } from '@playwright/test';
+
+const PORT = Number(process.env.PORT || 8080);
+const baseURL = process.env.STAGING_PLAYWRIGHT_BASE_URL || `http://localhost:${PORT}`;
+const authFile = 'test-results/.auth/staging.json';
+
+export default defineConfig({
+ testDir: './e2e/staging',
+ fullyParallel: false,
+ forbidOnly: true,
+ retries: 0,
+ workers: 1,
+ reporter: [['list'], ['html', { outputFolder: 'playwright-report-staging', open: 'never' }]],
+ outputDir: 'test-results-staging',
+ use: {
+ baseURL,
+ trace: 'retain-on-failure',
+ screenshot: 'only-on-failure',
+ video: 'retain-on-failure',
+ locale: 'en-US',
+ },
+ projects: [
+ {
+ name: 'staging-auth-setup',
+ testMatch: /auth\.setup\.ts/,
+ use: { ...devices['Desktop Chrome'] },
+ },
+ {
+ name: 'staging-desktop-chromium',
+ testIgnore: /auth\.setup\.ts/,
+ dependencies: ['staging-auth-setup'],
+ use: {
+ ...devices['Desktop Chrome'],
+ viewport: { width: 1280, height: 800 },
+ storageState: authFile,
+ },
+ },
+ ],
+ webServer: {
+ command: 'npm run dev',
+ url: baseURL,
+ reuseExistingServer: false,
+ timeout: 120_000,
+ stdout: 'ignore',
+ stderr: 'pipe',
+ env: {
+ ...process.env,
+ VITE_FORCE_SUPABASE_ENV: 'staging',
+ },
+ },
+});
diff --git a/security/reviews/2026-08-27-post-remediation-and-sparring.md b/security/reviews/2026-08-27-post-remediation-and-sparring.md
new file mode 100644
index 000000000..ae48351df
--- /dev/null
+++ b/security/reviews/2026-08-27-post-remediation-and-sparring.md
@@ -0,0 +1,117 @@
+# Post-remediation source validation — 2026-08-27
+
+## Scope and limitations
+
+This is a source-backed validation record for the repository state on `feature/sparring-verification-retention-loop`. It is **not** a new Codex Security scanner result and it is **not** a live penetration test of staging or production.
+
+The baseline is the committed 2026-08-19 Codex Security scan (`security/scans/2026-08-19-codex-security-06d93a47`), which reported 11 findings: 2 high, 8 medium, and 1 low.
+
+The user explicitly prohibited applying migrations, deploying Edge Functions, setting Supabase secrets, or otherwise mutating staging/production Supabase during this branch. Therefore:
+
+- database-policy conclusions below are based on migration/function/client source;
+- branch migrations are **unapplied**;
+- branch Edge Function changes are **undeployed**;
+- no claim is made that staging or production currently contains branch-only backend fixes;
+- the opt-in stateful staging E2E suite was prepared but not executed.
+
+## Original August findings
+
+| # | Original finding | Source status | Evidence / note |
+|---|---|---|---|
+| 1 | Users can self-assign paid entitlements and restore expired trials | **Remediated in existing source** | `20260819000000_security_hardening.sql` revokes authenticated INSERT/DELETE and unrestricted UPDATE on `user_settings`, grants only preference-column updates, and AI entitlement readers fail closed for a missing settings row. |
+| 2 | Users can delete or forge the usage ledger that enforces AI quotas | **Remediated in existing source** | The hardening migration removes client writes to `ai_requests` and introduces the service-role-only atomic `consume_daily_quota` RPC. |
+| 3 | Anonymous users can enumerate all active shared items | **Remediated in existing source** | Public table SELECT is removed; `get_shared_item(p_share_code)` exposes only a possessed, non-expired capability and updates its view count atomically. `SharedPage` calls this RPC. |
+| 4 | Anonymous callers can create arbitrary shared items | **Remediated in existing source** | Direct anon/authenticated access to `shared_items` is revoked; the `share` Edge Function requires a valid user before inserting and generates share codes server-side. |
+| 5 | SECURITY DEFINER analytics functions are exposed too broadly | **Remediated in existing source** | Sensitive analytics RPCs call `require_admin()`. `20260820090000_advisor_security_cleanup.sql` revokes public/anon/authenticated execution from internal SECURITY DEFINER helpers as appropriate. |
+| 6 | Promo redemption accepts a caller-selected beneficiary | **Remediated in existing source** | The UUID-taking RPC is dropped. `redeem_promo_code_atomic(text)` derives `v_user_id := auth.uid()`, and the Edge Function invokes it through the caller-authenticated client. |
+| 7 | Source importer can be DNS-rebound into private network targets | **Remediated on this branch, not deployed** | `import-source` no longer performs a network request to the user-controlled hostname. The validated URL is sent as data to the fixed `https://api.exa.ai/contents` egress endpoint. Tests assert the importer has exactly one executable fetch destination. |
+| 8 | Citation verification performs blind requests to model-provided URLs | **Remediated in existing source** | `_shared/research.ts::verifyUrl` deliberately performs no network request to model-provided citation URLs; unsupported verification remains explicitly false. |
+| 9 | Source importer can consume an unbounded/slow response body after headers | **Remediated on this branch, not deployed** | `readBoundedText` enforces both a 1 MB byte cap and a body-read deadline, including streamed bodies whose headers already arrived. The fixed Exa egress response is consumed through this helper. |
+| 10 | Concurrent requests can race past the daily quota | **Remediated in existing source; correctness strengthened on this branch** | Existing source uses service-role-only atomic UPSERT reservation. This branch additionally scopes counters by `quota_key` so source imports cannot consume the independently configured AI allowance. The scoping migration is unapplied. |
+| 11 | Unknown model IDs can bypass Pro-model gating / provider assumptions | **Remediated in existing source** | `MODEL_PROVIDERS` is a positive allowlist and `providerForModel` rejects unsupported model/provider combinations before provider work. |
+
+## Branch-specific security findings and fixes
+
+### 1. Cross-account exposure of locally persisted Sparring transcripts
+
+**Finding:** Sparring sessions were persisted in browser storage but initially were not cleared on sign-out or account changes. A second user on the same browser profile could inherit the previous account's local practice transcripts.
+
+**Fix:** `AuthProvider` now clears `useSparringStore` when:
+
+- the session becomes unauthenticated;
+- the authenticated user ID changes;
+- explicit sign-out runs.
+
+`src/test/stores/sparring-store.test.ts` covers the clearing operation.
+
+### 2. Untrusted persisted Sparring state
+
+**Finding:** browser-persisted session data is user-controlled and could contain malformed shapes.
+
+**Fix:** the persisted session list is re-parsed through `SparringSessionSchema`; malformed entries are dropped and `activeSessionId` is retained only when it references a validated session.
+
+### 3. Dishonest source-grounding control
+
+**Finding:** an early Sparring UI revision exposed a per-round “Use active sources” switch while the shared AI client always attached active Source Library items. Turning the switch off would not have changed the request.
+
+**Fix:** the false OFF control was removed. Sparring now states that active Source Library items are factual grounding and links users to the Library to manage that selection.
+
+### 4. Preference persistence conflicted with the entitlement hardening model
+
+**Finding:** the browser settings store still used `upsert`, which requires INSERT privilege even on conflict, while the security hardening migration intentionally revoked authenticated INSERT on `user_settings`. In addition, `output_language` was omitted from the allowed preference-column UPDATE grant.
+
+**Fix in branch source:**
+
+- settings persistence now performs UPDATE on the already-provisioned row, scoped to the current user;
+- `20260827000000_user_settings_output_language_privilege.sql` grants UPDATE on only `output_language` to authenticated users.
+
+**Deployment status:** the migration is unapplied, so deployed environments do not gain this branch-only privilege until separately authorized.
+
+### 5. Import quota and AI quota shared one counter namespace
+
+**Finding:** the atomic counter removed the race condition, but `daily_usage_counters` had only `(user_id, usage_date)`. Import requests and normal AI requests have different configured limits yet incremented the same row, causing one workload to consume the other's allowance.
+
+**Fix in branch source:** `20260827001000_scope_daily_usage_counters.sql` adds `quota_key`, changes the primary key to `(user_id, usage_date, quota_key)`, gives existing two-argument callers a default `ai` namespace, and makes the importer pass `import_source`.
+
+**Deployment status:** unapplied.
+
+## Search / provenance trust-boundary changes reviewed
+
+The branch closes the previously deferred Search Quality work:
+
+- Exa government/public-sector restrictions are request `includeDomains` fields rather than `site:` query text.
+- Academic and news preferences use Exa category fields.
+- Filtered zero-result searches retry unfiltered to avoid silent recall collapse.
+- Factoids preserves consulted-source provenance separately from verified evidence.
+- Weak consulted sources remain visible as fallback context but cannot become verified Factoids evidence.
+- A handler-boundary test imports the actual `supabase/functions/ai/index.ts` entrypoint under loopback shims and mocks Supabase/Exa/model network boundaries.
+
+The source importer is also aligned to a fixed Exa egress boundary and bounded provider-response consumption.
+
+## Verification artifacts added
+
+Security-relevant regression coverage includes:
+
+- `src/test/security-hardening.test.ts`
+- `src/test/import-source-egress.test.ts`
+- `supabase/functions/import-source/resource-limits.test.ts`
+- `supabase/functions/import-source/url-validation.test.ts`
+- `supabase/functions/_shared/research-exa-filters.test.ts`
+- `supabase/functions/ai/source-quality.test.ts`
+- `supabase/functions/ai/handler-research.test.ts`
+- `src/test/stores/sparring-store.test.ts`
+- `src/test/ai/factoids-provenance.test.ts`
+- deterministic Playwright Sparring flow/recovery tests.
+
+## Remaining uncertainty before deployment
+
+The branch should not be described as production-remediated until the following occur under explicit authorization:
+
+1. run the full repository verification gate successfully;
+2. review/apply the branch migrations in a controlled environment;
+3. deploy the changed Edge Functions;
+4. run the stateful authenticated staging suite;
+5. validate effective RLS/GRANT/RPC privileges against the actual remote database;
+6. run a fresh official security scan against the deployment candidate.
+
+Until then, branch-only backend remediations are repository source, not active Supabase controls.
diff --git a/src/App.tsx b/src/App.tsx
index f68db8b2e..6cfff11f3 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -25,6 +25,7 @@ const AttackPage = lazy(() => import("./pages/AttackPage"));
const BrainstormPage = lazy(() => import("./pages/BrainstormPage"));
const FactoidsPage = lazy(() => import("./pages/FactoidsPage"));
const AskPage = lazy(() => import("./pages/AskPage"));
+const SparringPage = lazy(() => import("./pages/SparringPage"));
const SettingsPage = lazy(() => import("./pages/SettingsPage"));
const HistoryPage = lazy(() => import("./pages/HistoryPage"));
const LibraryPage = lazy(() => import("./pages/LibraryPage"));
@@ -118,6 +119,7 @@ function AnimatedRoutes() {
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/src/components/features/factoids/FactoidsResults.tsx b/src/components/features/factoids/FactoidsResults.tsx
index ff622a5a9..096bb8060 100644
--- a/src/components/features/factoids/FactoidsResults.tsx
+++ b/src/components/features/factoids/FactoidsResults.tsx
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button';
import { SectionCard } from '@/components/ui/section-card';
import { Progress } from '@/components/ui/progress';
import type { FactoidsOutput, Factoid } from '@/lib/ai/schemas/factoids';
+import type { DiscoveredSource } from '@/lib/ai/client';
import { StaggeredContainer } from '@/components/ui/animated-section';
import { cn } from '@/lib/utils';
import { validateUrl } from '@/lib/validation/url';
@@ -15,11 +16,12 @@ import { exportFactoidsToDocx, exportFactoidsToGoogleDocs } from '@/lib/export-u
import { toast } from 'sonner';
import { ExportToolbar } from '@/components/features/ExportToolbar';
import { GoogleDriveAuthModal } from '@/components/features/export/GoogleDriveAuthModal';
+import { SourceQualityList } from '@/components/features/shared/SourceQualityList';
import { openExternalUrl } from '@/lib/native';
-
interface FactoidsResultsProps {
result: FactoidsOutput;
+ consultedSources?: DiscoveredSource[];
onDiscuss?: () => void;
onSaveSource?: (title: string, content: string, url?: string) => void;
onUseSuggestion?: (query: string) => void;
@@ -33,16 +35,13 @@ const verificationConfig = {
function FactoidCard({ factoid, onSave }: { factoid: Factoid, onSave?: FactoidsResultsProps['onSaveSource'] }) {
const { t } = useTranslation();
- // Defensive check for old data format in localStorage
const verified = factoid.citation?.verified ?? 'pending';
const config = verificationConfig[verified] ?? verificationConfig.pending;
const VerifyIcon = config.icon;
- // Use secure URL validation instead of simple string checks
const safeUrl = validateUrl(factoid.citation?.url);
return (
+
+
+
+ Keep working from this round
+ Carry the actual transcript and motion into DebateLab's existing prep tools instead of starting from scratch.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/features/sparring/SparringSetup.tsx b/src/components/features/sparring/SparringSetup.tsx
new file mode 100644
index 000000000..6d7144dc7
--- /dev/null
+++ b/src/components/features/sparring/SparringSetup.tsx
@@ -0,0 +1,175 @@
+import { useMemo, useState } from 'react';
+import { Link } from 'react-router-dom';
+import { BookOpenCheck, Shield, Sparkles, Swords, Zap } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Label } from '@/components/ui/label';
+import { Textarea } from '@/components/ui/textarea';
+import { ModelSelector } from '@/components/ModelSelector';
+import { cn } from '@/lib/utils';
+import { useSessionStore } from '@/lib/stores/session-store';
+import { SparringSetupSchema, type SparringDifficulty, type SparringSetup } from '@/lib/ai/schemas/sparring';
+
+interface SparringSetupProps {
+ onStart: (setup: SparringSetup) => void;
+ isStarting?: boolean;
+ defaultMotion?: string;
+}
+
+const difficulties: Array<{
+ id: SparringDifficulty;
+ label: string;
+ description: string;
+ icon: typeof Shield;
+}> = [
+ {
+ id: 'beginner',
+ label: 'Beginner',
+ description: 'Clear clash and forgiving pressure. Good for drilling fundamentals.',
+ icon: Shield,
+ },
+ {
+ id: 'competitive',
+ label: 'Competitive',
+ description: 'A competent tournament opponent that directly engages your case.',
+ icon: Swords,
+ },
+ {
+ id: 'brutal',
+ label: 'Brutal',
+ description: 'Elimination-round pressure. Dropped warrants and weak weighing get punished.',
+ icon: Zap,
+ },
+];
+
+export function SparringSetup({ onStart, isStarting = false, defaultMotion = '' }: SparringSetupProps) {
+ const [motion, setMotion] = useState(defaultMotion);
+ const [side, setSide] = useState<'pro' | 'con'>('pro');
+ const [difficulty, setDifficulty] = useState('competitive');
+ const activeSourceCount = useSessionStore((state) => state.sourceLibrary.filter((source) => source.isActive).length);
+
+ // Sparring uses the same active-source selection as the rest of DebateLab.
+ // This is intentionally not a per-round toggle: the shared AI client always
+ // includes active sources, so exposing an OFF switch here would be dishonest.
+ const validation = useMemo(
+ () => SparringSetupSchema.safeParse({ motion, side, difficulty, useActiveSources: true }),
+ [motion, side, difficulty],
+ );
+
+ const handleStart = () => {
+ if (!validation.success) return;
+ onStart(validation.data);
+ };
+
+ return (
+
+
+
+
+
+
+
+
+ Set up a round
+ Two speeches from you, two from the AI, then a transcript-grounded scorecard.
+
+
+
+ Round intelligence
+ Choose the model independently from Ask.
+
+
+
+
+
+
+
+
+
+
+
+
Active Source Library grounding
+
+ {activeSourceCount > 0
+ ? `${activeSourceCount} active ${activeSourceCount === 1 ? 'source is' : 'sources are'} available to the opponent as factual grounding.`
+ : 'No active sources are selected, so the opponent is instructed not to manufacture evidence.'}
+