From f31ecb65dff6099e647e74f21c2f0af19fedf249 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 22 Oct 2026 10:55:33 +0000 Subject: [PATCH 1/3] Make Claude Sonnet 5.5 the Pro default and Opus opt-in only New Pro accounts start on Sonnet 5.5. Auto Pro routes between Sonnet 5.5 and GPT-6.1 Sol and falls back to Sonnet, so it never picks Opus. Opus runs only when someone selects it. Retired Opus IDs still move to Opus 5.5, since those were explicit picks. Co-Authored-By: Claude Opus 5.5 --- .../verify-debatelab/features/README.md | 2 +- .../features/case-generation.md | 2 +- .../features/model-defaults.md | 8 +++--- .../recipes/model-defaults.spec.ts | 28 +++++++++---------- docs/architecture.md | 4 +-- src/lib/stores/settings-store.ts | 2 +- supabase/README.md | 2 +- .../_shared/auto-model-router.test.ts | 28 +++++++++++++++++-- .../functions/_shared/auto-model-router.ts | 6 ++-- supabase/functions/_shared/model-catalog.ts | 8 ++++-- .../functions/ai/handler-research.test.ts | 8 +++--- 11 files changed, 62 insertions(+), 36 deletions(-) diff --git a/.claude/skills/verify-debatelab/features/README.md b/.claude/skills/verify-debatelab/features/README.md index 565c095c3..caa7c78fa 100644 --- a/.claude/skills/verify-debatelab/features/README.md +++ b/.claude/skills/verify-debatelab/features/README.md @@ -45,7 +45,7 @@ Each feature file starts with an H1 title and one paragraph describing the user- - [AI tools](./ai-tools.md): Attack, Brainstorm, Factoids, and Ask streamed, cited, and with web search (`recipes/ai-tools.spec.ts`). - [Live generation](./live-generation.md): every AI tool shows its answer while the model writes it, Cancel mid-stream, and the event stream's shape (`recipes/live-generation.spec.ts`); Crossfire and Sparring requests stream too (`recipes/practice.spec.ts`). - [Current Debate](./current-debate.md): start, the first-debate offer and the different-motion choice, sources and results scoped to a debate, switching, and the first onboarding step (`recipes/current-debate.spec.ts`); debate context review and pins (`e2e/full-stack/debate-context.spec.ts`). -- [Model defaults](./model-defaults.md): Pro accounts start on Claude Opus 5.5, Basic accounts on Claude Haiku 5.5 with Pro models locked, and retired model IDs move to Opus 5.5 (`recipes/model-defaults.spec.ts`). +- [Model defaults](./model-defaults.md): Pro accounts start on Claude Sonnet 5.5 with Opus 5.5 as an opt-in, Basic accounts on Claude Haiku 5.5 with Pro models locked, and retired model IDs move to their successor (`recipes/model-defaults.spec.ts`). - [Practice](./practice.md): a full Lincoln-Douglas sparring round with verdict, and Crossfire in both directions with debrief, each filed in its debate (`recipes/practice.spec.ts`). - [Voice rounds](./voice-rounds.md): spoken Sparring and Crossfire on the browser voice, with no ElevenLabs key (`recipes/voice-rounds.spec.ts`). - [Workspace autosave](./workspace-autosave.md): a slow Crossfire or Sparring save from a Workspace never overwrites newer text (`recipes/workspace-autosave.spec.ts`). Needs `up --collab`; skips without it. diff --git a/.claude/skills/verify-debatelab/features/case-generation.md b/.claude/skills/verify-debatelab/features/case-generation.md index aca27a4ff..b22161ae3 100644 --- a/.claude/skills/verify-debatelab/features/case-generation.md +++ b/.claude/skills/verify-debatelab/features/case-generation.md @@ -42,5 +42,5 @@ Preconditions: - `Save` appears both on the result toolbar and in the dialog. Scope the second click to `getByRole("dialog")`. The toolbar `Save` exists only for one-off results. - A case filed in a debate is titled with the motion, which the header control also shows (truncated and hidden). Look for it inside the History panel, not with a page-wide `getByText`. - The client files a result after it renders. Wait for the result on screen (and the row, if the next step reloads) before a `page.goto`, or the reload can drop it. -- The model label in the result header (for example `anthropic/claude-opus-5.5`) is the model the function requested. The answer itself came from the fake provider. +- The model label in the result header (for example `anthropic/claude-sonnet-5.5`) is the model the function requested. The answer itself came from the fake provider. - `Google Docs` export needs Google OAuth and cannot be proven locally. diff --git a/.claude/skills/verify-debatelab/features/model-defaults.md b/.claude/skills/verify-debatelab/features/model-defaults.md index 00ab98457..c03ac776e 100644 --- a/.claude/skills/verify-debatelab/features/model-defaults.md +++ b/.claude/skills/verify-debatelab/features/model-defaults.md @@ -1,12 +1,12 @@ # Model defaults and the model selector -Every AI tool has a model selector. Basic accounts can pick Claude Haiku 5.5, GPT-6 Luna, or Muse Spark 1.3 (`meta/muse-spark-1.3-contributor`); everything else is Pro. A new Pro account starts every tool on Claude Opus 5.5, and a Basic (trial) account on Claude Haiku 5.5. Pro-only models show a `Pro` badge and are disabled for Basic accounts. A setting saved with a retired catalog ID is rewritten on the next load: GPT-5.6 Luna moves to Claude Haiku 5.5, Claude Sonnet 5 and Opus 5 to Claude Opus 5.5, GPT-5.6 Sol and Terra to GPT-6.1 Sol, DeepSeek V4 Flash to Claude Haiku 5.5, and Muse Spark 1.3 to its contributor tier. The Edge Functions resolve a retired ID from an older client the same way. A retired Basic ID never resolves to a Pro model. The catalog and defaults live in `supabase/functions/_shared/model-catalog.ts`. +Every AI tool has a model selector. Basic accounts can pick Claude Haiku 5.5, GPT-6 Luna, or Muse Spark 1.3 (`meta/muse-spark-1.3-contributor`); everything else is Pro. A new Pro account starts every tool on Claude Sonnet 5.5, and a Basic (trial) account on Claude Haiku 5.5. Claude Opus 5.5 is opt-in only: it runs when someone picks it, and Auto Pro never routes or falls back to it. Pro-only models show a `Pro` badge and are disabled for Basic accounts. A setting saved with a retired catalog ID is rewritten on the next load: GPT-5.6 Luna moves to Claude Haiku 5.5, Claude Sonnet 5 to Claude Sonnet 5.5, Claude Opus 5 to Claude Opus 5.5, GPT-5.6 Sol and Terra to GPT-6.1 Sol, DeepSeek V4 Flash to Claude Haiku 5.5, and Muse Spark 1.3 to its contributor tier. The Edge Functions resolve a retired ID from an older client the same way. A retired Basic ID never resolves to a Pro model. The catalog and defaults live in `supabase/functions/_shared/model-catalog.ts`. ## Sub-features -- `model-pro-default`: a new Pro account's `user_settings.feature_configs` hold `anthropic/claude-opus-5.5` for every tool, the selector shows `Claude Opus 5.5`, and a Generate run records `model: anthropic/claude-opus-5.5` in `ai_requests`. +- `model-pro-default`: a new Pro account's `user_settings.feature_configs` hold `anthropic/claude-sonnet-5.5` for every tool, the selector shows `Claude Sonnet 5.5` with `Claude Opus 5.5` still enabled, and a Generate run records `model: anthropic/claude-sonnet-5.5` in `ai_requests`. - `model-basic-default`: a trial account with no saved settings gets `anthropic/claude-haiku-5.5` everywhere. `Claude Opus 5.5` and `Claude Sonnet 5.5` are disabled options, and a Generate run records Haiku 5.5. -- `model-retired`: saved `anthropic/claude-sonnet-5` configs become `anthropic/claude-opus-5.5` on screen and in `user_settings`. +- `model-retired`: saved `anthropic/claude-sonnet-5` configs become `anthropic/claude-sonnet-5.5` on screen and in `user_settings`. ## How to get to it (user POV) @@ -21,4 +21,4 @@ Every AI tool has a model selector. Basic accounts can pick Claude Haiku 5.5, GP ## Gotchas - Fake mode records the requested model but answers with fake text, and live mode rewrites every chat call to the Muse Spark contributor pin. Neither proves that OpenRouter accepts the Claude 5.5 IDs; that needs a direct OpenRouter call. -- Existing saved choices of a model still in the catalog are kept. Only retired IDs and unsaved settings move to the new defaults. +- Existing saved choices of a model still in the catalog are kept. Only retired IDs and unsaved settings move to the new defaults, so a Pro account that saved Opus 5.5 under the old default keeps it. diff --git a/.claude/skills/verify-debatelab/recipes/model-defaults.spec.ts b/.claude/skills/verify-debatelab/recipes/model-defaults.spec.ts index 07d11abf3..bd90b9168 100644 --- a/.claude/skills/verify-debatelab/recipes/model-defaults.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/model-defaults.spec.ts @@ -3,7 +3,7 @@ import { admin, AI_TIMEOUT, aiResponse, chooseForMotion, expect, providerCalls, // Feature map: features/model-defaults.md. const motion = "This House would make public transport free at the point of use"; -const OPUS = "anthropic/claude-opus-5.5"; +const SONNET = "anthropic/claude-sonnet-5.5"; const HAIKU = "anthropic/claude-haiku-5.5"; async function settings(userId: string) { @@ -25,29 +25,29 @@ async function generateOnce(page: Page) { return response; } -test("model-pro-default: a new Pro account starts on Claude Opus 5.5 and generates with it", async ({ signedIn: page, user, evidence }) => { +test("model-pro-default: a new Pro account starts on Claude Sonnet 5.5, keeps Opus as an opt-in, and generates with Sonnet", async ({ signedIn: page, user, evidence }) => { const userId = await user.userId(); - await expect.poll(async () => (await settings(userId)).generate?.model, { timeout: AI_TIMEOUT }).toBe(OPUS); + await expect.poll(async () => (await settings(userId)).generate?.model, { timeout: AI_TIMEOUT }).toBe(SONNET); await page.goto("/app/generate"); const selector = page.getByRole("combobox", { name: "Select AI model for generate" }); - await expect(selector).toContainText("Claude Opus 5.5"); + await expect(selector).toContainText("Claude Sonnet 5.5"); await selector.click(); await expect(page.getByRole("option", { name: /Claude Haiku 5\.5/ })).toBeEnabled(); - await expect(page.getByRole("option", { name: /Claude Sonnet 5\.5/ })).toBeEnabled(); + await expect(page.getByRole("option", { name: /Claude Opus 5\.5/ })).toBeEnabled(); await evidence.shot("01-pro-model-list"); await page.keyboard.press("Escape"); - await evidence.step("New Pro account: selector shows Claude Opus 5.5, all Claude 5.5 models enabled"); + await evidence.step("New Pro account: selector shows Claude Sonnet 5.5; Opus 5.5 stays enabled as an opt-in"); const body = await generateOnce(page); - expect((body.meta as { resolvedModel?: string } | undefined)?.resolvedModel ?? OPUS).toBe(OPUS); + expect((body.meta as { resolvedModel?: string } | undefined)?.resolvedModel ?? SONNET).toBe(SONNET); const calls = await providerCalls(); // Live mode rewrites the upstream model to the pin, so read what the app asked for. - expect(calls.filter((call) => call.feature === "generate").map((call) => call.requestedModel ?? call.model)).toContain(OPUS); + expect(calls.filter((call) => call.feature === "generate").map((call) => call.requestedModel ?? call.model)).toContain(SONNET); const { data: usage } = await admin.from("ai_requests").select("feature,status,model").eq("user_id", userId); - expect(usage).toContainEqual(expect.objectContaining({ feature: "generate", status: "success", model: OPUS })); + expect(usage).toContainEqual(expect.objectContaining({ feature: "generate", status: "success", model: SONNET })); await evidence.json("02-ai-requests", usage); - await evidence.shot("02-generated-with-opus"); + await evidence.shot("02-generated-with-sonnet"); }); test("model-basic-default: a Basic account starts on Claude Haiku 5.5 with only Haiku, Luna and Muse Spark unlocked", async ({ signedIn: page, user, evidence }) => { @@ -81,7 +81,7 @@ test("model-basic-default: a Basic account starts on Claude Haiku 5.5 with only await evidence.json("02-ai-requests", usage); }); -test("model-retired: a saved Claude Sonnet 5 setting moves to Claude Opus 5.5", async ({ signedIn: page, user, evidence }) => { +test("model-retired: a saved Claude Sonnet 5 setting moves to Claude Sonnet 5.5", async ({ signedIn: page, user, evidence }) => { const userId = await user.userId(); const retired = Object.fromEntries(["generate", "attack", "brainstorm", "factoids", "ask", "sparring"].map((feature) => [ feature, @@ -91,9 +91,9 @@ test("model-retired: a saved Claude Sonnet 5 setting moves to Claude Opus 5.5", await page.evaluate(() => localStorage.removeItem("debate-trainer-settings")); await page.goto("/app/generate"); - await expect(page.getByRole("combobox", { name: "Select AI model for generate" })).toContainText("Claude Opus 5.5"); + await expect(page.getByRole("combobox", { name: "Select AI model for generate" })).toContainText("Claude Sonnet 5.5"); await expect.poll(async () => Object.values(await settings(userId)).map((config) => config.model), { timeout: AI_TIMEOUT }) - .toEqual(Array(6).fill(OPUS)); + .toEqual(Array(6).fill(SONNET)); await evidence.shot("01-retired-migrated"); - await evidence.step("Saved Sonnet 5 configs were rewritten to Opus 5.5 in user_settings"); + await evidence.step("Saved Sonnet 5 configs were rewritten to Sonnet 5.5 in user_settings"); }); diff --git a/docs/architecture.md b/docs/architecture.md index 30b508c00..352b0a4cb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -132,9 +132,9 @@ Supabase Auth owns user sessions. Protected routes provide a client navigation b 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. -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 Opus 5.5 for Pro), Pro-only entries, retired-ID successors, and request controls live in [`supabase/functions/_shared/model-catalog.ts`](../supabase/functions/_shared/model-catalog.ts). OpenRouter usage remains raw in the audit row, and the reported charge is unknown when the provider does not return a complete cost. 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`](../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. +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`](../supabase/functions/_shared/model-catalog.ts). OpenRouter usage remains raw in the audit row, and the reported charge is unknown when the provider does not return a complete cost. 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`](../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. -`Auto Fast` and `Auto Pro` are virtual selections, not provider model IDs. The Edge Functions resolve them through TypeSafe Jev on OpenRouter before calling the concrete writing model. Auto Fast is available to Basic accounts, routes only among the Basic models (Claude Haiku 5.5, GPT-6 Luna, Muse Spark 1.3 Contributor), and normally resolves to Claude Haiku 5.5. Auto Pro remains Pro-only and normally resolves to Claude Opus 5.5. 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. +`Auto Fast` and `Auto Pro` are virtual selections, not provider model IDs. The Edge Functions resolve them through TypeSafe Jev on OpenRouter before calling the concrete writing model. Auto Fast is available to Basic accounts, routes only among the Basic models (Claude Haiku 5.5, GPT-6 Luna, Muse Spark 1.3 Contributor), and normally resolves to Claude Haiku 5.5. Auto Pro remains Pro-only, routes between Claude Sonnet 5.5 and GPT-6.1 Sol, and normally resolves to Claude Sonnet 5.5; it never routes or falls back to Opus. 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. ## Observability diff --git a/src/lib/stores/settings-store.ts b/src/lib/stores/settings-store.ts index 4d96bd91d..bb7cb96c9 100644 --- a/src/lib/stores/settings-store.ts +++ b/src/lib/stores/settings-store.ts @@ -46,7 +46,7 @@ const LEGACY_MODEL_SUCCESSORS: Record = { 'gpt-5.4-mini': DEFAULT_MODEL, 'gpt-4o': DEFAULT_MODEL, 'gpt-4o-mini': DEFAULT_MODEL, - 'claude-opus-4-7': DEFAULT_PRO_MODEL, + 'claude-opus-4-7': 'anthropic/claude-opus-5.5', 'claude-sonnet-4-6': DEFAULT_PRO_MODEL, 'claude-haiku-4-5-20251001': DEFAULT_MODEL, 'gemini-3.1-pro-preview': 'google/gemini-3.8-flash', diff --git a/supabase/README.md b/supabase/README.md index 37e2e17a1..7520fe741 100644 --- a/supabase/README.md +++ b/supabase/README.md @@ -36,7 +36,7 @@ supabase/tests/ pgTAP database and policy tests Both endpoints accept an optional `debateContext` of IDs (`debateId`, `side`, `sourceIds`, `outputs`). `_shared/debate-context-loader.ts` loads the matching material with the service role, filtering every query by the caller's user ID and the one debate, and returns 404 for a debate the caller does not own before any quota is reserved. Requests without it still accept the legacy `sourceIds` field. -Both endpoints route every model inference through OpenRouter and enforce the shared ten-model Basic/Pro catalog. Basic accounts get Claude Haiku 5.5 (the default), GPT-6 Luna, and Muse Spark 1.3 Contributor; Claude Opus 5.5 is the Pro default; Pro-only models require active Pro access. Retired catalog IDs from older clients resolve to their successor before validation. +Both endpoints route every model inference through OpenRouter and enforce the shared ten-model Basic/Pro catalog. Basic accounts get Claude Haiku 5.5 (the default), GPT-6 Luna, and Muse Spark 1.3 Contributor; Claude Sonnet 5.5 is the Pro default and Claude Opus 5.5 runs only when picked; Pro-only models require active Pro access. Retired catalog IDs from older clients resolve to their successor before validation. The browser boundary is [`../src/lib/ai/client.ts`](../src/lib/ai/client.ts). Shared prompt, research, source-quality, cost, entitlement, CORS, and control logic lives in `_shared`. diff --git a/supabase/functions/_shared/auto-model-router.test.ts b/supabase/functions/_shared/auto-model-router.test.ts index a6cb9fbf5..2882041a9 100644 --- a/supabase/functions/_shared/auto-model-router.test.ts +++ b/supabase/functions/_shared/auto-model-router.test.ts @@ -106,7 +106,7 @@ Deno.test("Auto Pro rejects Basic before calling Jev", async () => { }); Deno.test("Auto Pro accepts only its approved smart-model pool", async () => { - for (const model of ["anthropic/claude-opus-5.5", "anthropic/claude-sonnet-5.5", "openai/gpt-6.1-sol"]) { + for (const model of ["anthropic/claude-sonnet-5.5", "openai/gpt-6.1-sol"]) { const resolved = await resolveModelSelection({ requestedModel: AUTO_PRO, feature: "generate", @@ -124,11 +124,35 @@ Deno.test("Auto Pro accepts only its approved smart-model pool", async () => { canAccessProModels: true, callJevImpl: jevReturning(choiceResult("z-ai/glm-5.3-flash")), }); - if (outsidePool.resolvedModel !== "anthropic/claude-opus-5.5" || !outsidePool.usedFallback) { + if (outsidePool.resolvedModel !== "anthropic/claude-sonnet-5.5" || !outsidePool.usedFallback) { throw new Error("Auto Pro accepted a model outside its pool"); } }); +Deno.test("Auto Pro never routes or falls back to Opus", async () => { + const routedToOpus = await resolveModelSelection({ + requestedModel: AUTO_PRO, + feature: "generate", + input: { motion: "Complex international law motion" }, + canAccessProModels: true, + callJevImpl: jevReturning(choiceResult("anthropic/claude-opus-5.5")), + }); + if (routedToOpus.resolvedModel !== "anthropic/claude-sonnet-5.5" || !routedToOpus.usedFallback) { + throw new Error(`Auto Pro accepted Opus: ${JSON.stringify(routedToOpus)}`); + } + + const unavailable = await resolveModelSelection({ + requestedModel: AUTO_PRO, + feature: "generate", + input: { motion: "Complex international law motion" }, + canAccessProModels: true, + callJevImpl: () => Promise.reject(new Error("provider unavailable")), + }); + if (unavailable.resolvedModel !== "anthropic/claude-sonnet-5.5" || !unavailable.usedFallback) { + throw new Error(`Auto Pro fell back to the wrong model: ${JSON.stringify(unavailable)}`); + } +}); + Deno.test("manual model selections bypass Jev", async () => { const observed = { calls: 0 }; const resolved = await resolveModelSelection({ diff --git a/supabase/functions/_shared/auto-model-router.ts b/supabase/functions/_shared/auto-model-router.ts index a16d465fd..b81674ca1 100644 --- a/supabase/functions/_shared/auto-model-router.ts +++ b/supabase/functions/_shared/auto-model-router.ts @@ -23,9 +23,9 @@ const FAST_CANDIDATES = { "meta/muse-spark-1.3-contributor": "Best for very long input, multilingual output, or broad context handling.", } as const; +// Opus is opt-in only: Auto Pro never routes or falls back to it. const PRO_CANDIDATES = { - "anthropic/claude-opus-5.5": "Default for professional DebateLab work: nuanced rhetoric, adversarial critique, and long-form writing.", - "anthropic/claude-sonnet-5.5": "Best for quick, simpler Pro requests where speed matters more than depth.", + "anthropic/claude-sonnet-5.5": "Default for professional DebateLab work: nuanced rhetoric, adversarial critique, and long-form writing.", "openai/gpt-6.1-sol": "Best for complex structured reasoning and reliable multi-part analysis.", } as const; @@ -72,7 +72,7 @@ function routingState(args: ResolveModelSelectionArgs) { } function fallbackFor(selection: typeof AUTO_FAST | typeof AUTO_PRO): AutoCandidate { - return selection === AUTO_FAST ? "anthropic/claude-haiku-5.5" : "anthropic/claude-opus-5.5"; + return selection === AUTO_FAST ? "anthropic/claude-haiku-5.5" : "anthropic/claude-sonnet-5.5"; } function isAbort(error: unknown, signal?: AbortSignal): boolean { diff --git a/supabase/functions/_shared/model-catalog.ts b/supabase/functions/_shared/model-catalog.ts index 9ac386965..98e653f8b 100644 --- a/supabase/functions/_shared/model-catalog.ts +++ b/supabase/functions/_shared/model-catalog.ts @@ -38,18 +38,20 @@ export type AutoModelSelection = (typeof AUTO_MODEL_SELECTIONS)[number]["id"]; export type ModelSelection = ModelId | AutoModelSelection; export const DEFAULT_MODEL = "anthropic/claude-haiku-5.5" as const; -export const DEFAULT_PRO_MODEL = "anthropic/claude-opus-5.5" as const; +// Opus costs several times Sonnet, so Pro starts on Sonnet and Opus is only +// used when someone picks it. +export const DEFAULT_PRO_MODEL = "anthropic/claude-sonnet-5.5" as const; // Retired catalog IDs that older clients (an installed PWA, a saved setting) // may still send. Both the server and the settings store resolve them here. // The old defaults (GPT-5.6 Luna, Claude Sonnet 5) move to the new defaults; -// the rest move to their family's successor. A Basic ID never maps to Pro, +// the rest move to their family's successor, so a saved Opus pick stays Opus. A Basic ID never maps to Pro, // so DeepSeek V4 Flash, which was Basic, moves to the Basic default. const RETIRED_MODEL_SUCCESSORS = new Map([ ["openai/gpt-5.6-luna", DEFAULT_MODEL], ["deepseek/deepseek-v4-flash-0731", DEFAULT_MODEL], ["anthropic/claude-sonnet-5", DEFAULT_PRO_MODEL], - ["anthropic/claude-opus-5", DEFAULT_PRO_MODEL], + ["anthropic/claude-opus-5", "anthropic/claude-opus-5.5"], ["openai/gpt-5.6-sol", "openai/gpt-6.1-sol"], ["openai/gpt-5.6-terra", "openai/gpt-6.1-sol"], ["meta/muse-spark-1.3", "meta/muse-spark-1.3-contributor"], diff --git a/supabase/functions/ai/handler-research.test.ts b/supabase/functions/ai/handler-research.test.ts index d9ab9383c..36b8a26fa 100644 --- a/supabase/functions/ai/handler-research.test.ts +++ b/supabase/functions/ai/handler-research.test.ts @@ -657,7 +657,7 @@ Deno.test("AI handlers use strict OpenRouter routing and server-side reasoning t decisionBodies.push(body); const questions = body.questions as Record }>; const labels = Object.keys(questions.model.criteria); - const choice = labels.includes("anthropic/claude-haiku-5.5") ? "anthropic/claude-haiku-5.5" : "anthropic/claude-opus-5.5"; + const choice = labels.includes("anthropic/claude-haiku-5.5") ? "anthropic/claude-haiku-5.5" : "anthropic/claude-sonnet-5.5"; return jsonResponse({ model: "typesafe/jev-1.13", answers: { @@ -712,7 +712,7 @@ Deno.test("AI handlers use strict OpenRouter routing and server-side reasoning t const autoPro = await requestForModel("auto-pro", "pro"); if (autoPro.status !== 200) throw new Error(`Pro Auto Pro returned ${autoPro.status}: ${await autoPro.text()}`); const autoProPayload = await autoPro.json(); - if (autoProPayload.meta?.resolvedModel !== "anthropic/claude-opus-5.5") { + if (autoProPayload.meta?.resolvedModel !== "anthropic/claude-sonnet-5.5") { throw new Error(`Auto Pro metadata was wrong: ${JSON.stringify(autoProPayload.meta)}`); } @@ -742,12 +742,12 @@ Deno.test("AI handlers use strict OpenRouter routing and server-side reasoning t if ((body.reasoning as Record).effort !== "low") throw new Error("unsupported Pro medium was not lowered"); } if (openRouterBodies.at(-2)?.model !== "anthropic/claude-haiku-5.5") throw new Error("Auto Fast did not call Haiku"); - if (openRouterBodies.at(-1)?.model !== "anthropic/claude-opus-5.5") throw new Error("Auto Pro did not call Opus"); + if (openRouterBodies.at(-1)?.model !== "anthropic/claude-sonnet-5.5") throw new Error("Auto Pro did not call Sonnet"); // A stale client still sending a retired ID reaches its successor, not a 400. const retired = await requestForModel("anthropic/claude-sonnet-5", "pro"); if (retired.status !== 200) throw new Error(`retired Sonnet 5 returned ${retired.status}: ${await retired.text()}`); - if (openRouterBodies.at(-1)?.model !== "anthropic/claude-opus-5.5") throw new Error("retired Sonnet 5 did not reach Opus 5.5"); + if (openRouterBodies.at(-1)?.model !== "anthropic/claude-sonnet-5.5") throw new Error("retired Sonnet 5 did not reach Sonnet 5.5"); } finally { globalThis.fetch = originalFetch; if (originalTier === undefined) delete testState.__testTier; From 220b3679dc7546a3112e0a2d167e4dc7d80ca2d2 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 22 Oct 2026 12:13:33 +0000 Subject: [PATCH 2/3] Move saved Opus 5.5 settings to Sonnet 5.5 Opus 5.5 was the Pro default, and a saved setting can't tell the default from a pick. This data migration moves every saved Opus 5.5 tool setting to Sonnet 5.5, backs the original rows up in private.opus_default_backup, and skips users it has already migrated. A rollback script restores Opus wherever the migrated Sonnet is still saved. A database test runs both twice against seeded rows. Co-Authored-By: Claude Opus 5.5 --- .../features/model-defaults.md | 2 +- package.json | 2 +- scripts/test-opus-default-migration.sh | 185 ++++++++++++++++++ supabase/README.md | 3 + .../20261022120000_opus_default_to_sonnet.sql | 60 ++++++ ...1022120000_opus_default_to_sonnet.down.sql | 42 ++++ 6 files changed, 292 insertions(+), 2 deletions(-) create mode 100755 scripts/test-opus-default-migration.sh create mode 100644 supabase/migrations/20261022120000_opus_default_to_sonnet.sql create mode 100644 supabase/rollbacks/20261022120000_opus_default_to_sonnet.down.sql diff --git a/.claude/skills/verify-debatelab/features/model-defaults.md b/.claude/skills/verify-debatelab/features/model-defaults.md index c03ac776e..9f6683732 100644 --- a/.claude/skills/verify-debatelab/features/model-defaults.md +++ b/.claude/skills/verify-debatelab/features/model-defaults.md @@ -21,4 +21,4 @@ Every AI tool has a model selector. Basic accounts can pick Claude Haiku 5.5, GP ## Gotchas - Fake mode records the requested model but answers with fake text, and live mode rewrites every chat call to the Muse Spark contributor pin. Neither proves that OpenRouter accepts the Claude 5.5 IDs; that needs a direct OpenRouter call. -- Existing saved choices of a model still in the catalog are kept. Only retired IDs and unsaved settings move to the new defaults, so a Pro account that saved Opus 5.5 under the old default keeps it. +- Existing saved choices of a model still in the catalog are kept. Only retired IDs and unsaved settings move to the new defaults. Saved Opus 5.5 settings from the old Pro default are moved to Sonnet 5.5 once, by the `20261022120000_opus_default_to_sonnet.sql` data migration, not by the client. diff --git a/package.json b/package.json index aeb18beb2..2f0f3afe0 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "start:collaboration": "tsx services/collaboration/src/index.ts", "test:watch": "vitest", "test:edge": "deno test --allow-read --allow-env=DEMO_CODE_PEPPER,STRIPE_SECRET_KEY,STRIPE_PRICE_ID,STRIPE_PRICE_LOOKUP_KEY,EXA_API_KEY,SUPABASE_URL,SUPABASE_ANON_KEY,SUPABASE_SERVICE_ROLE_KEY,SPARRING_INTEGRITY_SECRET,ELEVENLABS_API_KEY,ELEVENLABS_VOICE_ID,OPENROUTER_API_KEY,OPENROUTER_API_URL,WORKSPACE_INVITE_SECRET,WORKSPACE_INVITE_MAILER_URL,WORKSPACE_INVITE_MAILER_TOKEN,WORKSPACE_INVITE_MAILER_FORMAT,WORKSPACE_INVITE_BASE_URL,WORKSPACE_INVITE_ALLOWED_ORIGINS,WORKSPACE_INVITE_SERVICE_ROLE_KEY --allow-net=127.0.0.1 supabase/functions/", - "test:db": "supabase test db && bash scripts/test-daily-quota.sh && bash scripts/test-workspace-invite-concurrency.sh && bash scripts/test-workspace-mutation-concurrency.sh", + "test:db": "supabase test db && bash scripts/test-daily-quota.sh && bash scripts/test-opus-default-migration.sh && bash scripts/test-workspace-invite-concurrency.sh && bash scripts/test-workspace-mutation-concurrency.sh", "test:e2e": "playwright test --project=desktop-chromium --project=mobile-chromium", "test:e2e:smoke": "playwright test --project=desktop-chromium --grep @smoke", "test:e2e:webkit": "playwright test --project=webkit", diff --git a/scripts/test-opus-default-migration.sh b/scripts/test-opus-default-migration.sh new file mode 100755 index 000000000..2ed416de9 --- /dev/null +++ b/scripts/test-opus-default-migration.sh @@ -0,0 +1,185 @@ +#!/usr/bin/env bash +# Runs the Opus-to-Sonnet settings migration and its rollback against seeded +# accounts, each twice, inside one transaction that is rolled back at the end. +# now() is fixed inside a transaction, so "changed nothing" compares row +# versions (ctid), which every UPDATE replaces. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +database_url="${SUPABASE_DB_URL:-postgresql://postgres:postgres@127.0.0.1:54322/postgres}" +database_password="${SUPABASE_DB_PASSWORD:-postgres}" + +cd "$repo_root" +PGPASSWORD="$database_password" psql "$database_url" -qX --set ON_ERROR_STOP=1 <<'SQL' +BEGIN; + +CREATE FUNCTION pg_temp.check(passed boolean, message text) RETURNS void +LANGUAGE plpgsql AS $$ +BEGIN + IF passed IS NOT TRUE THEN RAISE EXCEPTION 'FAIL: %', message; END IF; +END; +$$; + +CREATE FUNCTION pg_temp.configs(p_user uuid) RETURNS jsonb +LANGUAGE sql AS $$ SELECT feature_configs FROM public.user_settings WHERE user_id = p_user $$; + +CREATE FUNCTION pg_temp.model(p_user uuid, p_feature text) RETURNS text +LANGUAGE sql AS $$ SELECT feature_configs->p_feature->>'model' FROM public.user_settings WHERE user_id = p_user $$; + +CREATE FUNCTION pg_temp.tool(p_model text, p_effort text DEFAULT 'medium') RETURNS jsonb +LANGUAGE sql AS $$ SELECT jsonb_build_object('provider', 'openrouter', 'model', p_model, 'reasoningEffort', p_effort) $$; + +-- a: every tool on Opus (the old Pro default) +-- b: Opus on some tools, other models on the rest +-- c: a retired Opus ID, which the client already maps to Opus 5.5 as a pick +-- d: never saved +-- e: a lapsed Basic account with Opus still saved +-- f: a malformed scalar value +INSERT INTO auth.users (id) VALUES + ('a0000000-0000-4000-8000-00000000000a'), + ('b0000000-0000-4000-8000-00000000000b'), + ('c0000000-0000-4000-8000-00000000000c'), + ('d0000000-0000-4000-8000-00000000000d'), + ('e0000000-0000-4000-8000-00000000000e'), + ('f0000000-0000-4000-8000-00000000000f'); + +CREATE TEMP TABLE seeded (user_id uuid PRIMARY KEY, feature_configs jsonb NOT NULL); +INSERT INTO seeded VALUES + ('a0000000-0000-4000-8000-00000000000a', ( + SELECT jsonb_object_agg(feature, pg_temp.tool('anthropic/claude-opus-5.5')) + FROM unnest(ARRAY['generate', 'attack', 'brainstorm', 'factoids', 'ask', 'sparring']) AS feature + )), + ('b0000000-0000-4000-8000-00000000000b', jsonb_build_object( + 'generate', pg_temp.tool('anthropic/claude-opus-5.5', 'high'), + 'attack', pg_temp.tool('anthropic/claude-haiku-5.5', 'low'), + 'ask', pg_temp.tool('auto-pro') + )), + ('c0000000-0000-4000-8000-00000000000c', jsonb_build_object('generate', pg_temp.tool('anthropic/claude-opus-5'))), + ('d0000000-0000-4000-8000-00000000000d', '{}'::jsonb), + ('e0000000-0000-4000-8000-00000000000e', jsonb_build_object('ask', pg_temp.tool('anthropic/claude-opus-5.5', 'low'))), + ('f0000000-0000-4000-8000-00000000000f', '"anthropic/claude-opus-5.5"'::jsonb); + +UPDATE public.user_settings AS settings +SET feature_configs = seeded.feature_configs, + subscription_tier = CASE WHEN settings.user_id = 'e0000000-0000-4000-8000-00000000000e' THEN 'trial' ELSE 'pro' END +FROM seeded +WHERE settings.user_id = seeded.user_id; +SELECT pg_temp.check(count(*) = 6, 'six seeded settings rows') +FROM public.user_settings WHERE user_id IN (SELECT user_id FROM seeded); + +-- Migration, first run. +\i supabase/migrations/20261022120000_opus_default_to_sonnet.sql + +SELECT pg_temp.check( + (SELECT bool_and(value->>'model' = 'anthropic/claude-sonnet-5.5' AND value->>'reasoningEffort' = 'medium') + FROM jsonb_each(pg_temp.configs('a0000000-0000-4000-8000-00000000000a'))) + AND (SELECT count(*) FROM jsonb_object_keys(pg_temp.configs('a0000000-0000-4000-8000-00000000000a'))) = 6, + 'a: all six tools move to Sonnet with their reasoning effort' +); +SELECT pg_temp.check( + pg_temp.configs('b0000000-0000-4000-8000-00000000000b') = jsonb_build_object( + 'generate', pg_temp.tool('anthropic/claude-sonnet-5.5', 'high'), + 'attack', pg_temp.tool('anthropic/claude-haiku-5.5', 'low'), + 'ask', pg_temp.tool('auto-pro') + ), + 'b: only the Opus tool moves' +); +SELECT pg_temp.check(pg_temp.model('e0000000-0000-4000-8000-00000000000e', 'ask') = 'anthropic/claude-sonnet-5.5', 'e: a Basic row with Opus moves too'); +SELECT pg_temp.check( + (SELECT bool_and(pg_temp.configs(user_id) = feature_configs) FROM seeded + WHERE user_id IN ('c0000000-0000-4000-8000-00000000000c', 'd0000000-0000-4000-8000-00000000000d', 'f0000000-0000-4000-8000-00000000000f')), + 'c, d, f: retired Opus, empty and malformed settings are untouched' +); +SELECT pg_temp.check( + (SELECT array_agg(backup.user_id ORDER BY backup.user_id) FROM private.opus_default_backup AS backup + WHERE backup.user_id IN (SELECT user_id FROM seeded)) + = ARRAY['a0000000-0000-4000-8000-00000000000a', 'b0000000-0000-4000-8000-00000000000b', 'e0000000-0000-4000-8000-00000000000e']::uuid[], + 'backup holds exactly the migrated users' +); +SELECT pg_temp.check( + (SELECT bool_and(backup.feature_configs = seeded.feature_configs AND backup.migrated_feature_configs = pg_temp.configs(backup.user_id)) + FROM private.opus_default_backup AS backup JOIN seeded USING (user_id)), + 'backup holds the original and the migrated settings' +); + +-- A picks Opus again for one tool. A second run must not undo that or touch anyone else. +UPDATE public.user_settings +SET feature_configs = jsonb_set(feature_configs, '{generate,model}', '"anthropic/claude-opus-5.5"') +WHERE user_id = 'a0000000-0000-4000-8000-00000000000a'; +CREATE TEMP TABLE before_second_run AS + SELECT user_id, feature_configs, ctid AS row_version FROM public.user_settings WHERE user_id IN (SELECT user_id FROM seeded); +CREATE TEMP TABLE backup_before_second_run AS + SELECT * FROM private.opus_default_backup WHERE user_id IN (SELECT user_id FROM seeded); + +-- Migration, second run. +\i supabase/migrations/20261022120000_opus_default_to_sonnet.sql + +SELECT pg_temp.check( + NOT EXISTS ( + SELECT 1 FROM before_second_run AS before JOIN public.user_settings AS now USING (user_id) + WHERE before.feature_configs IS DISTINCT FROM now.feature_configs OR before.row_version IS DISTINCT FROM now.ctid + ), + 'second migration run changes no settings, including an Opus pick made after the first run' +); +SELECT pg_temp.check( + (SELECT count(*) FROM private.opus_default_backup WHERE user_id IN (SELECT user_id FROM seeded)) = 3 + AND NOT EXISTS ( + SELECT 1 FROM backup_before_second_run AS before JOIN private.opus_default_backup AS now USING (user_id) + WHERE before.feature_configs IS DISTINCT FROM now.feature_configs + OR before.migrated_feature_configs IS DISTINCT FROM now.migrated_feature_configs + ), + 'second migration run leaves the backup alone' +); + +-- Changes after the migration that a rollback must keep: A raises one tool's +-- effort but stays on Sonnet; B moves its migrated tool to GPT-6.1 Sol. +UPDATE public.user_settings +SET feature_configs = jsonb_set(feature_configs, '{brainstorm,reasoningEffort}', '"high"') +WHERE user_id = 'a0000000-0000-4000-8000-00000000000a'; +UPDATE public.user_settings +SET feature_configs = jsonb_set(feature_configs, '{generate,model}', '"openai/gpt-6.1-sol"') +WHERE user_id = 'b0000000-0000-4000-8000-00000000000b'; + +-- Rollback, first run. +\i supabase/rollbacks/20261022120000_opus_default_to_sonnet.down.sql + +SELECT pg_temp.check( + (SELECT bool_and(value->>'model' = 'anthropic/claude-opus-5.5') FROM jsonb_each(pg_temp.configs('a0000000-0000-4000-8000-00000000000a'))) + AND pg_temp.configs('a0000000-0000-4000-8000-00000000000a')->'brainstorm'->>'reasoningEffort' = 'high', + 'a: every tool is back on Opus and the later effort change is kept' +); +SELECT pg_temp.check( + pg_temp.configs('b0000000-0000-4000-8000-00000000000b') = jsonb_build_object( + 'generate', pg_temp.tool('openai/gpt-6.1-sol', 'high'), + 'attack', pg_temp.tool('anthropic/claude-haiku-5.5', 'low'), + 'ask', pg_temp.tool('auto-pro') + ), + 'b: a model picked after the migration is kept' +); +SELECT pg_temp.check( + pg_temp.configs('e0000000-0000-4000-8000-00000000000e') = (SELECT feature_configs FROM seeded WHERE user_id = 'e0000000-0000-4000-8000-00000000000e'), + 'e: restored to its original settings' +); +SELECT pg_temp.check( + (SELECT bool_and(pg_temp.configs(user_id) = feature_configs) FROM seeded + WHERE user_id IN ('c0000000-0000-4000-8000-00000000000c', 'd0000000-0000-4000-8000-00000000000d', 'f0000000-0000-4000-8000-00000000000f')), + 'c, d, f: rollback leaves unmigrated users alone' +); +SELECT pg_temp.check(NOT EXISTS (SELECT 1 FROM private.opus_default_backup), 'rollback empties the backup'); + +-- Rollback, second run. +CREATE TEMP TABLE before_second_rollback AS + SELECT user_id, feature_configs, ctid AS row_version FROM public.user_settings WHERE user_id IN (SELECT user_id FROM seeded); +\i supabase/rollbacks/20261022120000_opus_default_to_sonnet.down.sql +SELECT pg_temp.check( + NOT EXISTS ( + SELECT 1 FROM before_second_rollback AS before JOIN public.user_settings AS now USING (user_id) + WHERE before.feature_configs IS DISTINCT FROM now.feature_configs OR before.row_version IS DISTINCT FROM now.ctid + ), + 'second rollback run changes nothing' +); + +ROLLBACK; +SQL + +echo "Opus default migration: migrate, rerun, rollback and rerun passed" diff --git a/supabase/README.md b/supabase/README.md index 7520fe741..ecf8d0ff2 100644 --- a/supabase/README.md +++ b/supabase/README.md @@ -21,6 +21,7 @@ supabase/config.toml local and function gateway configuration supabase/functions/ Deno Edge Functions supabase/functions/_shared shared auth, validation, prompts, research, and cost logic supabase/migrations/ ordered database history +supabase/rollbacks/ down scripts for data migrations; never applied by db push supabase/tests/ pgTAP database and policy tests ``` @@ -128,6 +129,8 @@ The database gate proves migration replay and pgTAP assertions locally. It does `20260926090100_legacy_debate_cleanup.sql` defines `private.purge_legacy_debate_material`, which deletes legacy results, chats, flowcharts, sparring rounds, and sources that belong to no debate and had no activity in the 30 days before `20260926090000_current_debates.sql` recorded its rollout time in `private.current_debate_rollout`. The migration does not call it, so applying it deletes nothing; legacy rows stay in the Unsorted view. Running the purge on a remote project is a separate, destructive step that needs explicit authorization for that environment. Work created after the rollout is never touched, however late it runs. +`20261022120000_opus_default_to_sonnet.sql` moves every saved Claude Opus 5.5 tool setting in `user_settings.feature_configs` to Claude Sonnet 5.5, because Opus was the Pro default and a saved row cannot tell a default from a pick. It first copies each changed row into `private.opus_default_backup` and skips users already there, so a second run changes nothing. Apply it only after the frontend that makes Sonnet the Pro default is live; otherwise an account that never saved settings still gets Opus on its first load. `supabase/rollbacks/20261022120000_opus_default_to_sonnet.down.sql` restores Opus wherever a tool still holds the Sonnet the migration wrote, and empties the backup. To roll back, copy it into `supabase/migrations` under a new timestamp. `scripts/test-opus-default-migration.sh`, part of `npm run test:db`, runs both twice against seeded rows. + ## Adding an Edge Function A new function needs more than its own folder. In the same PR: diff --git a/supabase/migrations/20261022120000_opus_default_to_sonnet.sql b/supabase/migrations/20261022120000_opus_default_to_sonnet.sql new file mode 100644 index 000000000..b2d52555f --- /dev/null +++ b/supabase/migrations/20261022120000_opus_default_to_sonnet.sql @@ -0,0 +1,60 @@ +-- Claude Opus 5.5 was the Pro default until Sonnet 5.5 replaced it. A saved +-- setting cannot tell a default from a deliberate pick, so every saved Opus 5.5 +-- tool setting moves to Sonnet 5.5. Opus stays selectable. Pro access depends +-- on the clock and on Stripe rows, so this covers every account with Opus +-- saved: Basic clients already map Pro-only models to Claude Haiku 5.5. +-- +-- The original settings go to a backup table first. A user with a backup row +-- is never migrated twice, so running this again cannot undo an Opus pick made +-- after the first run. supabase/rollbacks/20261022120000_opus_default_to_sonnet.down.sql +-- restores the backup. + +CREATE TABLE IF NOT EXISTS private.opus_default_backup ( + user_id uuid PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, + feature_configs jsonb NOT NULL, + migrated_feature_configs jsonb NOT NULL, + migrated_at timestamptz NOT NULL DEFAULT now() +); + +ALTER TABLE private.opus_default_backup ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON private.opus_default_backup FROM PUBLIC, anon, authenticated; + +WITH targets AS ( + SELECT + settings.user_id, + settings.feature_configs AS original, + ( + SELECT jsonb_object_agg( + entry.key, + CASE + WHEN jsonb_typeof(entry.value) = 'object' + AND entry.value->>'model' = 'anthropic/claude-opus-5.5' + THEN jsonb_set(entry.value, '{model}', '"anthropic/claude-sonnet-5.5"') + ELSE entry.value + END + ) + FROM jsonb_each(settings.feature_configs) AS entry + ) AS migrated + FROM public.user_settings AS settings + WHERE jsonb_typeof(settings.feature_configs) = 'object' + AND EXISTS ( + SELECT 1 FROM jsonb_each(settings.feature_configs) AS entry + WHERE jsonb_typeof(entry.value) = 'object' + AND entry.value->>'model' = 'anthropic/claude-opus-5.5' + ) + AND NOT EXISTS ( + SELECT 1 FROM private.opus_default_backup AS backup + WHERE backup.user_id = settings.user_id + ) + FOR UPDATE OF settings +), +backed_up AS ( + INSERT INTO private.opus_default_backup (user_id, feature_configs, migrated_feature_configs) + SELECT user_id, original, migrated FROM targets + RETURNING user_id +) +UPDATE public.user_settings AS settings +SET feature_configs = targets.migrated, updated_at = now() +FROM targets +JOIN backed_up USING (user_id) +WHERE settings.user_id = targets.user_id; diff --git a/supabase/rollbacks/20261022120000_opus_default_to_sonnet.down.sql b/supabase/rollbacks/20261022120000_opus_default_to_sonnet.down.sql new file mode 100644 index 000000000..a299674ac --- /dev/null +++ b/supabase/rollbacks/20261022120000_opus_default_to_sonnet.down.sql @@ -0,0 +1,42 @@ +-- Rolls back supabase/migrations/20261022120000_opus_default_to_sonnet.sql. +-- Not a migration: `supabase db push` never reads this directory. To apply it, +-- copy it into supabase/migrations under a new timestamp (a forward corrective +-- migration) and ship that like any other migration. +-- +-- A tool setting goes back to Opus 5.5 only where the migration moved it from +-- Opus to Sonnet and it still holds Sonnet. Anything the user changed after the +-- migration stays. Restored users leave the backup table, so a second run does +-- nothing. The backup table itself stays; dropping it is a separate decision. + +WITH restored AS ( + SELECT + settings.user_id, + ( + SELECT jsonb_object_agg( + entry.key, + CASE + WHEN jsonb_typeof(entry.value) = 'object' + AND entry.value->>'model' = 'anthropic/claude-sonnet-5.5' + AND backup.feature_configs->entry.key->>'model' = 'anthropic/claude-opus-5.5' + AND backup.migrated_feature_configs->entry.key->>'model' = 'anthropic/claude-sonnet-5.5' + THEN jsonb_set(entry.value, '{model}', '"anthropic/claude-opus-5.5"') + ELSE entry.value + END + ) + FROM jsonb_each(settings.feature_configs) AS entry + ) AS configs + FROM public.user_settings AS settings + JOIN private.opus_default_backup AS backup USING (user_id) + WHERE jsonb_typeof(settings.feature_configs) = 'object' + AND settings.feature_configs <> '{}'::jsonb + FOR UPDATE OF settings +), +updated AS ( + UPDATE public.user_settings AS settings + SET feature_configs = restored.configs, updated_at = now() + FROM restored + WHERE settings.user_id = restored.user_id + AND settings.feature_configs IS DISTINCT FROM restored.configs + RETURNING settings.user_id +) +DELETE FROM private.opus_default_backup; From 8ca1e46c56b589557ab8405acd2a731aa7d56bd3 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 22 Oct 2026 13:10:12 +0000 Subject: [PATCH 3/3] Cover more rollback and stale-client cases in the Opus migration test The test now seeds a tool already on Sonnet before the migration, which the rollback must keep, and an old updated_at, so it fails if the migration stops bumping it. Clients only save their settings cleanup while updated_at is unchanged, so the bump keeps an older client from writing Opus back. agent-stack.sh test now runs the script too, like npm run test:db. Co-Authored-By: Claude Opus 5.5 --- scripts/agent-stack.sh | 2 +- scripts/test-opus-default-migration.sh | 17 +++++++++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/scripts/agent-stack.sh b/scripts/agent-stack.sh index eaa129081..5d6918e79 100755 --- a/scripts/agent-stack.sh +++ b/scripts/agent-stack.sh @@ -87,7 +87,7 @@ case "$cmd" in ;; test) sb test db "$@" - for script in test-daily-quota test-workspace-invite-concurrency test-workspace-mutation-concurrency; do + for script in test-daily-quota test-opus-default-migration test-workspace-invite-concurrency test-workspace-mutation-concurrency; do SUPABASE_DB_URL="$(db_url)" bash "$root/scripts/$script.sh" done ;; diff --git a/scripts/test-opus-default-migration.sh b/scripts/test-opus-default-migration.sh index 2ed416de9..f1f972043 100755 --- a/scripts/test-opus-default-migration.sh +++ b/scripts/test-opus-default-migration.sh @@ -30,7 +30,7 @@ CREATE FUNCTION pg_temp.tool(p_model text, p_effort text DEFAULT 'medium') RETUR LANGUAGE sql AS $$ SELECT jsonb_build_object('provider', 'openrouter', 'model', p_model, 'reasoningEffort', p_effort) $$; -- a: every tool on Opus (the old Pro default) --- b: Opus on some tools, other models on the rest +-- b: Opus on some tools, other models on the rest, one already on Sonnet -- c: a retired Opus ID, which the client already maps to Opus 5.5 as a pick -- d: never saved -- e: a lapsed Basic account with Opus still saved @@ -52,6 +52,7 @@ INSERT INTO seeded VALUES ('b0000000-0000-4000-8000-00000000000b', jsonb_build_object( 'generate', pg_temp.tool('anthropic/claude-opus-5.5', 'high'), 'attack', pg_temp.tool('anthropic/claude-haiku-5.5', 'low'), + 'brainstorm', pg_temp.tool('anthropic/claude-sonnet-5.5', 'low'), 'ask', pg_temp.tool('auto-pro') )), ('c0000000-0000-4000-8000-00000000000c', jsonb_build_object('generate', pg_temp.tool('anthropic/claude-opus-5'))), @@ -61,6 +62,7 @@ INSERT INTO seeded VALUES UPDATE public.user_settings AS settings SET feature_configs = seeded.feature_configs, + updated_at = '2026-01-01T00:00:00Z', subscription_tier = CASE WHEN settings.user_id = 'e0000000-0000-4000-8000-00000000000e' THEN 'trial' ELSE 'pro' END FROM seeded WHERE settings.user_id = seeded.user_id; @@ -80,10 +82,20 @@ SELECT pg_temp.check( pg_temp.configs('b0000000-0000-4000-8000-00000000000b') = jsonb_build_object( 'generate', pg_temp.tool('anthropic/claude-sonnet-5.5', 'high'), 'attack', pg_temp.tool('anthropic/claude-haiku-5.5', 'low'), + 'brainstorm', pg_temp.tool('anthropic/claude-sonnet-5.5', 'low'), 'ask', pg_temp.tool('auto-pro') ), 'b: only the Opus tool moves' ); +-- A client only saves its cleanup while updated_at is unchanged since its read, +-- so the bump is what stops an older client writing Opus back. +SELECT pg_temp.check( + (SELECT bool_and(updated_at > '2026-01-01T00:00:00Z') FROM public.user_settings + WHERE user_id IN ('a0000000-0000-4000-8000-00000000000a', 'b0000000-0000-4000-8000-00000000000b', 'e0000000-0000-4000-8000-00000000000e')) + AND (SELECT bool_and(updated_at = '2026-01-01T00:00:00Z') FROM public.user_settings + WHERE user_id IN ('c0000000-0000-4000-8000-00000000000c', 'd0000000-0000-4000-8000-00000000000d', 'f0000000-0000-4000-8000-00000000000f')), + 'migrated rows get a new updated_at and the rest keep theirs' +); SELECT pg_temp.check(pg_temp.model('e0000000-0000-4000-8000-00000000000e', 'ask') = 'anthropic/claude-sonnet-5.5', 'e: a Basic row with Opus moves too'); SELECT pg_temp.check( (SELECT bool_and(pg_temp.configs(user_id) = feature_configs) FROM seeded @@ -152,9 +164,10 @@ SELECT pg_temp.check( pg_temp.configs('b0000000-0000-4000-8000-00000000000b') = jsonb_build_object( 'generate', pg_temp.tool('openai/gpt-6.1-sol', 'high'), 'attack', pg_temp.tool('anthropic/claude-haiku-5.5', 'low'), + 'brainstorm', pg_temp.tool('anthropic/claude-sonnet-5.5', 'low'), 'ask', pg_temp.tool('auto-pro') ), - 'b: a model picked after the migration is kept' + 'b: a model picked after the migration and a tool already on Sonnet before it are kept' ); SELECT pg_temp.check( pg_temp.configs('e0000000-0000-4000-8000-00000000000e') = (SELECT feature_configs FROM seeded WHERE user_id = 'e0000000-0000-4000-8000-00000000000e'),