diff --git a/docs/architecture.md b/docs/architecture.md
index a9a91b862..27b1c0a42 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -53,12 +53,12 @@ Client-side environment selection is a developer convenience, not authorization.
1. A feature page validates input and calls a feature wrapper.
2. The client reads the current Supabase session.
-3. It invokes `ai` or `ai-stream` with selected controls, stable active-source IDs, and a bounded recent Ask history when needed.
-4. The Edge Function validates authentication, loads active personal sources by authenticated user and ID, then enforces input bounds, supported model and control combinations, access limits, and feature-specific rules.
+3. It invokes `ai` or `ai-stream` with selected controls, the Current Debate context as IDs only, and a bounded recent Ask history when needed.
+4. 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.
5. The backend optionally gathers research, builds prompts, calls an allowlisted model through OpenRouter, validates output, and records the provider-reported usage and charge.
6. The client validates or transforms the response and updates the relevant store.
-[`supabase/functions/ai/index.ts`](../supabase/functions/ai/index.ts) serves non-streaming structured tools and practice modes. [`supabase/functions/ai-stream/index.ts`](../supabase/functions/ai-stream/index.ts) serves Ask over server-sent events.
+[`supabase/functions/ai/index.ts`](../supabase/functions/ai/index.ts) serves non-streaming 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/`](../supabase/functions/ai/features/) builds the prompt and shapes the output. [`supabase/functions/ai-stream/index.ts`](../supabase/functions/ai-stream/index.ts) serves Ask over server-sent events. Each feature's task prompt and user-prompt builder lives in [`supabase/functions/_shared/prompts/`](../supabase/functions/_shared/prompts/) beside one module of shared rules.
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.
@@ -70,7 +70,7 @@ Shared research, source ranking, URL validation, and prompt construction live un
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.
-Authenticated personal AI requests carry stable source IDs instead of source content, drawn only from the target debate's active sources; one-off requests send none. The Edge Function loads matching active rows for the authenticated user, bounds their title, content, origin, and type, then passes that context to ranking and prompt construction. 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.
+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`](../src/lib/debate/context.ts) chooses the default with the rules in [`supabase/functions/_shared/debate-context.ts`](../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`](../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.
diff --git a/docs/testing.md b/docs/testing.md
index 234da1465..1d1ac33a1 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -66,6 +66,19 @@ This uses Deno directly for `supabase/functions/`, including tests that import d
A browser-oriented Vitest pass does not replace native Deno tests. The stream accounting handler tests use synthetic OpenRouter responses and inspect audit inserts for complete, interrupted, and truncated streams. The permission list includes the OpenRouter key name because the handler reads it, but these tests require no real provider credentials.
+Current Debate context has focused Deno tests: `_shared/debate-context.test.ts` covers selection rules, the IDs-only request parser, digest budgets, per-feature policy, and loader isolation against real PostgREST query shapes; `ai/debate-context.test.ts` drives the `ai` and `ai-stream` handlers with forged debates, cross-debate IDs, a retired custom prompt, and stored text that tries to close its block.
+
+`_shared/prompts/prompt-examples.test.ts` checks the two representative examples per feature in [`supabase/functions/_shared/prompts/prompt-examples.ts`](../supabase/functions/_shared/prompts/prompt-examples.ts) against each feature's quality rules and the fake provider's routing lines.
+
+### Prompt example comparison
+
+```bash
+OPENROUTER_API_KEY=... deno run --allow-read --allow-write --allow-env=OPENROUTER_API_KEY \
+ --allow-net=openrouter.ai scripts/prompt-examples.ts supabase/functions /tmp/after.json
+```
+
+This sends the fourteen prompt examples to one real model (GPT-5.6 Luna by default) and writes the raw outputs for manual review. Point the first argument at a directory holding an older `_shared/prompts.ts` and `ai/prompt-builder.ts` to compare revisions. It runs no web research and is a bounded manual check, not a model matrix or an automated quality gate.
+
### Live Jev contract smoke
```bash
@@ -164,6 +177,7 @@ Each test signs in through the Login page with a fresh demo code and deletes tha
- sign-in, reload, sign-out, a rejected code, and an expired token that refreshes before an AI request;
- case generation grounded in a 250 KB Library source, provider failure with retry, a zero quota, and a saved case that a fresh browser loads from History;
- Attack, Brainstorm, Factoids, streamed Ask (it fails if the client falls back to the non-streaming path), and Ask with citations;
+- Current Debate context: every request carries the debate's motion, instructions, and sources; Review drops a source and pins a saved case for exactly one request; the pin is stored; Crossfire leaves instructions out; and one-off work sends nothing;
- a judged Lincoln-Douglas sparring round and a Crossfire debrief after a Jev signal attaches to an answer;
- Library activation and deletion, output-language sync across devices, flowchart saves, and Delete All My Data (it must leave another account's rows intact);
- a promo code redeemed in Settings that restores AI access, and signed Stripe webhooks that grant and revoke Pro while a forged or replayed one changes nothing;
diff --git a/e2e/full-stack/debate-context.spec.ts b/e2e/full-stack/debate-context.spec.ts
new file mode 100644
index 000000000..6768dba02
--- /dev/null
+++ b/e2e/full-stack/debate-context.spec.ts
@@ -0,0 +1,111 @@
+import type { Page } from "@playwright/test";
+import { admin, expect, fake, MARK, stackReady, startDebate, test } from "./stack";
+
+test.skip(!stackReady, "Local full stack is required (run ./.superset/db-gate.sh)");
+
+// EVIDENCE_DIR=
records video and key screenshots for review.
+const evidenceDir = process.env.EVIDENCE_DIR;
+test.use({ video: evidenceDir ? "on" : "retain-on-failure" });
+
+const MOTION = "This House would make public transport free";
+const INSTRUCTION = "DEBATE-INSTRUCTION: define free as zero fare at the point of use.";
+
+async function snap(page: Page, name: string) {
+ if (!evidenceDir) return;
+ await page.screenshot({ path: `${evidenceDir}/${test.info().project.name}-${name}.png`, fullPage: true });
+}
+
+async function addSource(page: Page, title: string, content: string) {
+ await page.goto("/app/library");
+ await page.getByRole("button", { name: "Add Source" }).click();
+ await page.getByRole("textbox", { name: "Source title..." }).fill(title);
+ await page.getByRole("textbox", { name: "Paste source content..." }).fill(content);
+ await page.getByRole("button", { name: "Save to Library" }).click();
+ await expect(page.getByRole("switch", { name: `Toggle ${title}` })).toBeChecked();
+}
+
+async function latestCall(feature: string, after = 0) {
+ let call: Awaited>[number] | undefined;
+ await expect.poll(async () => {
+ const calls = (await fake.requests()).filter((request) => request.feature === feature);
+ call = calls.length > after ? calls.at(-1) : undefined;
+ return Boolean(call);
+ }).toBe(true);
+ return call!;
+}
+
+test("every request carries its debate's material, and Review changes exactly one request", async ({ signedIn: page, user }) => {
+ test.setTimeout(150_000);
+ const userId = await user.userId();
+
+ await startDebate(page, MOTION);
+ await addSource(page, "Fare study", "FARE-SOURCE fare collection costs a fifth of farebox revenue.");
+ await addSource(page, "Ridership study", "RIDERSHIP-SOURCE ridership rose after fares were removed.");
+ const { error: instructionError } = await admin.from("debates").update({ instructions: INSTRUCTION }).eq("user_id", userId);
+ expect(instructionError).toBeNull();
+
+ // Generate reads the motion, instructions, and both active sources, and saves a case.
+ await page.goto("/app/generate");
+ const control = page.getByTestId("debate-context-control");
+ await expect(control).toContainText("Using 2 sources");
+ await snap(page, "1-generate-context-line");
+ await page.getByRole("button", { name: "Generate Debate Case" }).click();
+ await expect(page.getByText(`${MARK} case thesis`)).toBeVisible({ timeout: 30_000 });
+ const generateCall = await latestCall("generate");
+ for (const expected of ["", MOTION, INSTRUCTION, "FARE-SOURCE", "RIDERSHIP-SOURCE", "STUDENT MATERIAL RULES"]) {
+ expect(generateCall.system).toContain(expected);
+ }
+
+ // Brainstorm offers the saved case by default. Review drops one source and pins the case.
+ await page.goto("/app/brainstorm");
+ await expect(control).toContainText("Using 2 sources and 1 saved result");
+ await control.getByRole("button", { name: "Review" }).click();
+ const sheet = page.getByRole("dialog", { name: "What this request will use" });
+ await sheet.getByRole("checkbox", { name: /Ridership study/ }).click();
+ await sheet.getByRole("button", { name: /^Pin / }).click();
+ await expect(sheet.getByRole("button", { name: /^Unpin / })).toHaveAttribute("aria-pressed", "true");
+ await snap(page, "2-review-sheet");
+ await page.keyboard.press("Escape");
+ await expect(sheet).toBeHidden();
+ await expect(control).toContainText("Using 1 source and 1 saved result");
+
+ await page.getByRole("button", { name: "Start Brainstorming" }).click();
+ await expect(page.getByText(`${MARK} idea 1`)).toBeVisible({ timeout: 30_000 });
+ const brainstormCall = await latestCall("brainstorm");
+ expect(brainstormCall.system).toContain("FARE-SOURCE");
+ expect(brainstormCall.system).not.toContain("RIDERSHIP-SOURCE");
+ expect(brainstormCall.system).toContain(`${MARK} case thesis`);
+
+ // The choice covered one request: the next one starts from the defaults
+ // again, which now include the brainstorm that request just saved.
+ await expect(control).toContainText("Using 2 sources and 2 saved results: Brainstorm, Case (In favor)");
+ await control.getByRole("button", { name: "Review" }).click();
+ await expect(sheet).toContainText("1 source and 1 saved result reached the model.");
+ await snap(page, "3-review-after-request");
+ await page.keyboard.press("Escape");
+
+ // The pin is stored for this debate only.
+ const { data: pins } = await admin.from("debate_context_pins").select("item_kind").eq("user_id", userId);
+ expect(pins).toEqual([{ item_kind: "case" }]);
+
+ // Crossfire keeps free-form instructions out, and one-off mode sends nothing.
+ await page.goto("/app/crossfire");
+ await page.getByTestId("debate-context-control").getByRole("button", { name: "Review" }).click();
+ await expect(sheet).toContainText("Free-form instructions are not used here");
+ await page.keyboard.press("Escape");
+ await expect(sheet).toBeHidden();
+ await page.goto("/app/brainstorm");
+ await page.getByLabel("Motion / Topic *").fill("This House would lower the voting age");
+ const before = (await fake.requests()).filter((request) => request.feature === "brainstorm").length;
+ await page.getByRole("button", { name: "Start Brainstorming" }).click();
+ await page.getByRole("dialog").getByRole("button", { name: "Use once without saving to a debate" }).click();
+ await expect(page.getByText(`${MARK} idea 1`)).toBeVisible({ timeout: 30_000 });
+ const oneOffCall = await latestCall("brainstorm", before);
+ expect(oneOffCall.system).not.toContain("");
+ expect(oneOffCall.system).not.toContain("FARE-SOURCE");
+ // A single "use once" run leaves the Current Debate in place; one-off mode says so above the button.
+ await expect(control).toContainText("Using 2 sources and 2 saved results");
+ await page.getByTestId("current-debate-strip").getByRole("button", { name: "Use once" }).click();
+ await expect(control).toContainText("One-off mode: no Current Debate material will be used or saved.");
+ await snap(page, "4-one-off-line");
+});
diff --git a/scripts/prompt-examples.ts b/scripts/prompt-examples.ts
new file mode 100644
index 000000000..87a8d9fab
--- /dev/null
+++ b/scripts/prompt-examples.ts
@@ -0,0 +1,92 @@
+// Runs the fourteen prompt examples (two per AI feature) against one real
+// OpenRouter model and writes the raw outputs for side-by-side review.
+//
+// OPENROUTER_API_KEY=... deno run --allow-read --allow-write --allow-env=OPENROUTER_API_KEY \
+// --allow-net=openrouter.ai scripts/prompt-examples.ts [model] [id,id,...]
+//
+// is a directory holding _shared/prompts.ts and
+// ai/prompt-builder.ts, so an older copy of the prompts can be compared with
+// the current one. This is a bounded manual check, not a model matrix: one
+// model, fourteen calls, no web research.
+
+import { PROMPT_EXAMPLES } from "../supabase/functions/_shared/prompts/prompt-examples.ts";
+
+const [functionsDir, outPath, model = "openai/gpt-5.6-luna", only] = Deno.args;
+const selected = only ? new Set(only.split(",")) : null;
+if (!functionsDir || !outPath) {
+ console.error("usage: prompt-examples.ts [model]");
+ Deno.exit(2);
+}
+const apiKey = Deno.env.get("OPENROUTER_API_KEY");
+if (!apiKey) {
+ console.error("OPENROUTER_API_KEY is required");
+ Deno.exit(2);
+}
+
+const root = new URL(`file://${Deno.realPathSync(functionsDir)}/`);
+const prompts = await import(new URL("_shared/prompts.ts", root).href);
+const builder = await import(new URL("ai/prompt-builder.ts", root).href);
+
+const results = [];
+for (const example of PROMPT_EXAMPLES.filter((item) => !selected || selected.has(item.id))) {
+ const sparringScore = example.feature === "sparring" && example.input.action === "score";
+ const context = example.debateContext && {
+ ...example.debateContext,
+ // Mirror the server's per-feature policy.
+ instructions: sparringScore || example.feature === "crossfire" ? undefined : example.debateContext.instructions,
+ outputs: example.feature === "sparring" ? [] : example.debateContext.outputs,
+ };
+ const system = prompts.buildSystemPrompt({
+ feature: example.feature,
+ dateContext: `CURRENT DATE: ${new Date().toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })}`,
+ sources: example.feature === "sparring" ? [] : example.sources ?? [],
+ debateContext: context,
+ researchContext: example.searchContext ? "WEB RESEARCH RESULTS are attached to this request." : undefined,
+ });
+ const user = example.feature === "ask"
+ ? builder.buildAskBasePrompt({
+ message: String(example.input.message),
+ mode: String(example.input.mode),
+ citationMode: false,
+ webSearchEnabled: false,
+ prosConsMode: false,
+ argumentMapMode: false,
+ researchAvailable: false,
+ })
+ : builder.buildUserPrompt(example.feature, example.input, example.searchContext ?? "");
+ const started = Date.now();
+ const call = () => fetch("https://openrouter.ai/api/v1/chat/completions", {
+ method: "POST",
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
+ body: JSON.stringify({
+ model,
+ messages: [{ role: "system", content: system }, { role: "user", content: user }],
+ max_tokens: 8_000,
+ response_format: { type: "json_object" },
+ reasoning: { effort: "low", exclude: true },
+ provider: { require_parameters: true },
+ }),
+ // Matches the ai function's request budget; one retry covers a stalled route.
+ signal: AbortSignal.timeout(120_000),
+ });
+ const response = await call().catch(() => call()).catch(() => null);
+ const data = response ? await response.json().catch(() => null) : null;
+ const content = data?.choices?.[0]?.message?.content ?? "";
+ let output: unknown = content;
+ try {
+ output = JSON.parse(content);
+ } catch {
+ // Keep the raw text so invalid JSON is visible in review.
+ }
+ results.push({
+ id: example.id,
+ status: response?.status ?? 0,
+ ms: Date.now() - started,
+ cost: data?.usage?.cost ?? null,
+ systemChars: system.length,
+ output,
+ });
+ console.log(`${example.id}: ${response?.status ?? "timed out"} in ${Date.now() - started} ms`);
+}
+
+await Deno.writeTextFile(outPath, JSON.stringify({ model, ranAt: new Date().toISOString(), results }, null, 2));
diff --git a/src/components/features/ask/AskChat.tsx b/src/components/features/ask/AskChat.tsx
index 222b10d44..9eccc37ec 100644
--- a/src/components/features/ask/AskChat.tsx
+++ b/src/components/features/ask/AskChat.tsx
@@ -45,6 +45,8 @@ interface AskChatProps {
savedChats?: SavedChatSummary[];
onLoadChat?: (id: string) => void;
onSaveSource?: (title: string, content: string) => void;
+ /** Shown directly above the message box, e.g. which debate material Ask will use. */
+ contextControl?: React.ReactNode;
}
interface SavedChatSummary {
@@ -354,7 +356,7 @@ function SavedChatsPanel({
);
}
-export function AskChat({ messages, isLoading, onSend, onClear, onSaveChat, savedChats = [], onLoadChat, onSaveSource }: AskChatProps) {
+export function AskChat({ messages, isLoading, onSend, onClear, onSaveChat, savedChats = [], onLoadChat, onSaveSource, contextControl }: AskChatProps) {
const { t } = useTranslation();
const [input, setInput] = useState('');
const [mode, setMode] = useState('general');
@@ -659,6 +661,8 @@ export function AskChat({ messages, isLoading, onSend, onClear, onSaveChat, save
)}
+ {contextControl}
+