Skip to content

Commit d231cef

Browse files
committed
Merge remote-tracking branch 'origin/main' into s182
2 parents 25c0754 + bf4c34e commit d231cef

7 files changed

Lines changed: 306 additions & 3 deletions

File tree

‎.claude/skills/verify-debatelab/features/model-defaults.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,4 @@ Every AI tool has a model selector. Basic accounts can pick Claude Haiku 5.5, GP
2121
## Gotchas
2222

2323
- 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.
24-
- 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.
24+
- 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.

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
"start:collaboration": "tsx services/collaboration/src/index.ts",
2828
"test:watch": "vitest",
2929
"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/",
30-
"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",
30+
"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",
3131
"test:e2e": "playwright test --project=desktop-chromium --project=mobile-chromium",
3232
"test:e2e:smoke": "playwright test --project=desktop-chromium --grep @smoke",
3333
"test:e2e:webkit": "playwright test --project=webkit",

‎scripts/agent-stack.sh‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ case "$cmd" in
8787
;;
8888
test)
8989
sb test db "$@"
90-
for script in test-daily-quota test-workspace-invite-concurrency test-workspace-mutation-concurrency; do
90+
for script in test-daily-quota test-opus-default-migration test-workspace-invite-concurrency test-workspace-mutation-concurrency; do
9191
SUPABASE_DB_URL="$(db_url)" bash "$root/scripts/$script.sh"
9292
done
9393
;;
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
#!/usr/bin/env bash
2+
# Runs the Opus-to-Sonnet settings migration and its rollback against seeded
3+
# accounts, each twice, inside one transaction that is rolled back at the end.
4+
# now() is fixed inside a transaction, so "changed nothing" compares row
5+
# versions (ctid), which every UPDATE replaces.
6+
set -euo pipefail
7+
8+
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
9+
database_url="${SUPABASE_DB_URL:-postgresql://postgres:postgres@127.0.0.1:54322/postgres}"
10+
database_password="${SUPABASE_DB_PASSWORD:-postgres}"
11+
12+
cd "$repo_root"
13+
PGPASSWORD="$database_password" psql "$database_url" -qX --set ON_ERROR_STOP=1 <<'SQL'
14+
BEGIN;
15+
16+
CREATE FUNCTION pg_temp.check(passed boolean, message text) RETURNS void
17+
LANGUAGE plpgsql AS $$
18+
BEGIN
19+
IF passed IS NOT TRUE THEN RAISE EXCEPTION 'FAIL: %', message; END IF;
20+
END;
21+
$$;
22+
23+
CREATE FUNCTION pg_temp.configs(p_user uuid) RETURNS jsonb
24+
LANGUAGE sql AS $$ SELECT feature_configs FROM public.user_settings WHERE user_id = p_user $$;
25+
26+
CREATE FUNCTION pg_temp.model(p_user uuid, p_feature text) RETURNS text
27+
LANGUAGE sql AS $$ SELECT feature_configs->p_feature->>'model' FROM public.user_settings WHERE user_id = p_user $$;
28+
29+
CREATE FUNCTION pg_temp.tool(p_model text, p_effort text DEFAULT 'medium') RETURNS jsonb
30+
LANGUAGE sql AS $$ SELECT jsonb_build_object('provider', 'openrouter', 'model', p_model, 'reasoningEffort', p_effort) $$;
31+
32+
-- a: every tool on Opus (the old Pro default)
33+
-- b: Opus on some tools, other models on the rest, one already on Sonnet
34+
-- c: a retired Opus ID, which the client already maps to Opus 5.5 as a pick
35+
-- d: never saved
36+
-- e: a lapsed Basic account with Opus still saved
37+
-- f: a malformed scalar value
38+
INSERT INTO auth.users (id) VALUES
39+
('a0000000-0000-4000-8000-00000000000a'),
40+
('b0000000-0000-4000-8000-00000000000b'),
41+
('c0000000-0000-4000-8000-00000000000c'),
42+
('d0000000-0000-4000-8000-00000000000d'),
43+
('e0000000-0000-4000-8000-00000000000e'),
44+
('f0000000-0000-4000-8000-00000000000f');
45+
46+
CREATE TEMP TABLE seeded (user_id uuid PRIMARY KEY, feature_configs jsonb NOT NULL);
47+
INSERT INTO seeded VALUES
48+
('a0000000-0000-4000-8000-00000000000a', (
49+
SELECT jsonb_object_agg(feature, pg_temp.tool('anthropic/claude-opus-5.5'))
50+
FROM unnest(ARRAY['generate', 'attack', 'brainstorm', 'factoids', 'ask', 'sparring']) AS feature
51+
)),
52+
('b0000000-0000-4000-8000-00000000000b', jsonb_build_object(
53+
'generate', pg_temp.tool('anthropic/claude-opus-5.5', 'high'),
54+
'attack', pg_temp.tool('anthropic/claude-haiku-5.5', 'low'),
55+
'brainstorm', pg_temp.tool('anthropic/claude-sonnet-5.5', 'low'),
56+
'ask', pg_temp.tool('auto-pro')
57+
)),
58+
('c0000000-0000-4000-8000-00000000000c', jsonb_build_object('generate', pg_temp.tool('anthropic/claude-opus-5'))),
59+
('d0000000-0000-4000-8000-00000000000d', '{}'::jsonb),
60+
('e0000000-0000-4000-8000-00000000000e', jsonb_build_object('ask', pg_temp.tool('anthropic/claude-opus-5.5', 'low'))),
61+
('f0000000-0000-4000-8000-00000000000f', '"anthropic/claude-opus-5.5"'::jsonb);
62+
63+
UPDATE public.user_settings AS settings
64+
SET feature_configs = seeded.feature_configs,
65+
updated_at = '2026-01-01T00:00:00Z',
66+
subscription_tier = CASE WHEN settings.user_id = 'e0000000-0000-4000-8000-00000000000e' THEN 'trial' ELSE 'pro' END
67+
FROM seeded
68+
WHERE settings.user_id = seeded.user_id;
69+
SELECT pg_temp.check(count(*) = 6, 'six seeded settings rows')
70+
FROM public.user_settings WHERE user_id IN (SELECT user_id FROM seeded);
71+
72+
-- Migration, first run.
73+
\i supabase/migrations/20261022120000_opus_default_to_sonnet.sql
74+
75+
SELECT pg_temp.check(
76+
(SELECT bool_and(value->>'model' = 'anthropic/claude-sonnet-5.5' AND value->>'reasoningEffort' = 'medium')
77+
FROM jsonb_each(pg_temp.configs('a0000000-0000-4000-8000-00000000000a')))
78+
AND (SELECT count(*) FROM jsonb_object_keys(pg_temp.configs('a0000000-0000-4000-8000-00000000000a'))) = 6,
79+
'a: all six tools move to Sonnet with their reasoning effort'
80+
);
81+
SELECT pg_temp.check(
82+
pg_temp.configs('b0000000-0000-4000-8000-00000000000b') = jsonb_build_object(
83+
'generate', pg_temp.tool('anthropic/claude-sonnet-5.5', 'high'),
84+
'attack', pg_temp.tool('anthropic/claude-haiku-5.5', 'low'),
85+
'brainstorm', pg_temp.tool('anthropic/claude-sonnet-5.5', 'low'),
86+
'ask', pg_temp.tool('auto-pro')
87+
),
88+
'b: only the Opus tool moves'
89+
);
90+
-- A client only saves its cleanup while updated_at is unchanged since its read,
91+
-- so the bump is what stops an older client writing Opus back.
92+
SELECT pg_temp.check(
93+
(SELECT bool_and(updated_at > '2026-01-01T00:00:00Z') FROM public.user_settings
94+
WHERE user_id IN ('a0000000-0000-4000-8000-00000000000a', 'b0000000-0000-4000-8000-00000000000b', 'e0000000-0000-4000-8000-00000000000e'))
95+
AND (SELECT bool_and(updated_at = '2026-01-01T00:00:00Z') FROM public.user_settings
96+
WHERE user_id IN ('c0000000-0000-4000-8000-00000000000c', 'd0000000-0000-4000-8000-00000000000d', 'f0000000-0000-4000-8000-00000000000f')),
97+
'migrated rows get a new updated_at and the rest keep theirs'
98+
);
99+
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');
100+
SELECT pg_temp.check(
101+
(SELECT bool_and(pg_temp.configs(user_id) = feature_configs) FROM seeded
102+
WHERE user_id IN ('c0000000-0000-4000-8000-00000000000c', 'd0000000-0000-4000-8000-00000000000d', 'f0000000-0000-4000-8000-00000000000f')),
103+
'c, d, f: retired Opus, empty and malformed settings are untouched'
104+
);
105+
SELECT pg_temp.check(
106+
(SELECT array_agg(backup.user_id ORDER BY backup.user_id) FROM private.opus_default_backup AS backup
107+
WHERE backup.user_id IN (SELECT user_id FROM seeded))
108+
= ARRAY['a0000000-0000-4000-8000-00000000000a', 'b0000000-0000-4000-8000-00000000000b', 'e0000000-0000-4000-8000-00000000000e']::uuid[],
109+
'backup holds exactly the migrated users'
110+
);
111+
SELECT pg_temp.check(
112+
(SELECT bool_and(backup.feature_configs = seeded.feature_configs AND backup.migrated_feature_configs = pg_temp.configs(backup.user_id))
113+
FROM private.opus_default_backup AS backup JOIN seeded USING (user_id)),
114+
'backup holds the original and the migrated settings'
115+
);
116+
117+
-- A picks Opus again for one tool. A second run must not undo that or touch anyone else.
118+
UPDATE public.user_settings
119+
SET feature_configs = jsonb_set(feature_configs, '{generate,model}', '"anthropic/claude-opus-5.5"')
120+
WHERE user_id = 'a0000000-0000-4000-8000-00000000000a';
121+
CREATE TEMP TABLE before_second_run AS
122+
SELECT user_id, feature_configs, ctid AS row_version FROM public.user_settings WHERE user_id IN (SELECT user_id FROM seeded);
123+
CREATE TEMP TABLE backup_before_second_run AS
124+
SELECT * FROM private.opus_default_backup WHERE user_id IN (SELECT user_id FROM seeded);
125+
126+
-- Migration, second run.
127+
\i supabase/migrations/20261022120000_opus_default_to_sonnet.sql
128+
129+
SELECT pg_temp.check(
130+
NOT EXISTS (
131+
SELECT 1 FROM before_second_run AS before JOIN public.user_settings AS now USING (user_id)
132+
WHERE before.feature_configs IS DISTINCT FROM now.feature_configs OR before.row_version IS DISTINCT FROM now.ctid
133+
),
134+
'second migration run changes no settings, including an Opus pick made after the first run'
135+
);
136+
SELECT pg_temp.check(
137+
(SELECT count(*) FROM private.opus_default_backup WHERE user_id IN (SELECT user_id FROM seeded)) = 3
138+
AND NOT EXISTS (
139+
SELECT 1 FROM backup_before_second_run AS before JOIN private.opus_default_backup AS now USING (user_id)
140+
WHERE before.feature_configs IS DISTINCT FROM now.feature_configs
141+
OR before.migrated_feature_configs IS DISTINCT FROM now.migrated_feature_configs
142+
),
143+
'second migration run leaves the backup alone'
144+
);
145+
146+
-- Changes after the migration that a rollback must keep: A raises one tool's
147+
-- effort but stays on Sonnet; B moves its migrated tool to GPT-6.1 Sol.
148+
UPDATE public.user_settings
149+
SET feature_configs = jsonb_set(feature_configs, '{brainstorm,reasoningEffort}', '"high"')
150+
WHERE user_id = 'a0000000-0000-4000-8000-00000000000a';
151+
UPDATE public.user_settings
152+
SET feature_configs = jsonb_set(feature_configs, '{generate,model}', '"openai/gpt-6.1-sol"')
153+
WHERE user_id = 'b0000000-0000-4000-8000-00000000000b';
154+
155+
-- Rollback, first run.
156+
\i supabase/rollbacks/20261022120000_opus_default_to_sonnet.down.sql
157+
158+
SELECT pg_temp.check(
159+
(SELECT bool_and(value->>'model' = 'anthropic/claude-opus-5.5') FROM jsonb_each(pg_temp.configs('a0000000-0000-4000-8000-00000000000a')))
160+
AND pg_temp.configs('a0000000-0000-4000-8000-00000000000a')->'brainstorm'->>'reasoningEffort' = 'high',
161+
'a: every tool is back on Opus and the later effort change is kept'
162+
);
163+
SELECT pg_temp.check(
164+
pg_temp.configs('b0000000-0000-4000-8000-00000000000b') = jsonb_build_object(
165+
'generate', pg_temp.tool('openai/gpt-6.1-sol', 'high'),
166+
'attack', pg_temp.tool('anthropic/claude-haiku-5.5', 'low'),
167+
'brainstorm', pg_temp.tool('anthropic/claude-sonnet-5.5', 'low'),
168+
'ask', pg_temp.tool('auto-pro')
169+
),
170+
'b: a model picked after the migration and a tool already on Sonnet before it are kept'
171+
);
172+
SELECT pg_temp.check(
173+
pg_temp.configs('e0000000-0000-4000-8000-00000000000e') = (SELECT feature_configs FROM seeded WHERE user_id = 'e0000000-0000-4000-8000-00000000000e'),
174+
'e: restored to its original settings'
175+
);
176+
SELECT pg_temp.check(
177+
(SELECT bool_and(pg_temp.configs(user_id) = feature_configs) FROM seeded
178+
WHERE user_id IN ('c0000000-0000-4000-8000-00000000000c', 'd0000000-0000-4000-8000-00000000000d', 'f0000000-0000-4000-8000-00000000000f')),
179+
'c, d, f: rollback leaves unmigrated users alone'
180+
);
181+
SELECT pg_temp.check(NOT EXISTS (SELECT 1 FROM private.opus_default_backup), 'rollback empties the backup');
182+
183+
-- Rollback, second run.
184+
CREATE TEMP TABLE before_second_rollback AS
185+
SELECT user_id, feature_configs, ctid AS row_version FROM public.user_settings WHERE user_id IN (SELECT user_id FROM seeded);
186+
\i supabase/rollbacks/20261022120000_opus_default_to_sonnet.down.sql
187+
SELECT pg_temp.check(
188+
NOT EXISTS (
189+
SELECT 1 FROM before_second_rollback AS before JOIN public.user_settings AS now USING (user_id)
190+
WHERE before.feature_configs IS DISTINCT FROM now.feature_configs OR before.row_version IS DISTINCT FROM now.ctid
191+
),
192+
'second rollback run changes nothing'
193+
);
194+
195+
ROLLBACK;
196+
SQL
197+
198+
echo "Opus default migration: migrate, rerun, rollback and rerun passed"

‎supabase/README.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ supabase/config.toml local and function gateway configuration
2121
supabase/functions/ Deno Edge Functions
2222
supabase/functions/_shared shared auth, validation, prompts, research, and cost logic
2323
supabase/migrations/ ordered database history
24+
supabase/rollbacks/ down scripts for data migrations; never applied by db push
2425
supabase/tests/ pgTAP database and policy tests
2526
```
2627

@@ -128,6 +129,8 @@ The database gate proves migration replay and pgTAP assertions locally. It does
128129

129130
`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.
130131

132+
`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.
133+
131134
## Adding an Edge Function
132135

133136
A new function needs more than its own folder. In the same PR:
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
-- Claude Opus 5.5 was the Pro default until Sonnet 5.5 replaced it. A saved
2+
-- setting cannot tell a default from a deliberate pick, so every saved Opus 5.5
3+
-- tool setting moves to Sonnet 5.5. Opus stays selectable. Pro access depends
4+
-- on the clock and on Stripe rows, so this covers every account with Opus
5+
-- saved: Basic clients already map Pro-only models to Claude Haiku 5.5.
6+
--
7+
-- The original settings go to a backup table first. A user with a backup row
8+
-- is never migrated twice, so running this again cannot undo an Opus pick made
9+
-- after the first run. supabase/rollbacks/20261022120000_opus_default_to_sonnet.down.sql
10+
-- restores the backup.
11+
12+
CREATE TABLE IF NOT EXISTS private.opus_default_backup (
13+
user_id uuid PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
14+
feature_configs jsonb NOT NULL,
15+
migrated_feature_configs jsonb NOT NULL,
16+
migrated_at timestamptz NOT NULL DEFAULT now()
17+
);
18+
19+
ALTER TABLE private.opus_default_backup ENABLE ROW LEVEL SECURITY;
20+
REVOKE ALL ON private.opus_default_backup FROM PUBLIC, anon, authenticated;
21+
22+
WITH targets AS (
23+
SELECT
24+
settings.user_id,
25+
settings.feature_configs AS original,
26+
(
27+
SELECT jsonb_object_agg(
28+
entry.key,
29+
CASE
30+
WHEN jsonb_typeof(entry.value) = 'object'
31+
AND entry.value->>'model' = 'anthropic/claude-opus-5.5'
32+
THEN jsonb_set(entry.value, '{model}', '"anthropic/claude-sonnet-5.5"')
33+
ELSE entry.value
34+
END
35+
)
36+
FROM jsonb_each(settings.feature_configs) AS entry
37+
) AS migrated
38+
FROM public.user_settings AS settings
39+
WHERE jsonb_typeof(settings.feature_configs) = 'object'
40+
AND EXISTS (
41+
SELECT 1 FROM jsonb_each(settings.feature_configs) AS entry
42+
WHERE jsonb_typeof(entry.value) = 'object'
43+
AND entry.value->>'model' = 'anthropic/claude-opus-5.5'
44+
)
45+
AND NOT EXISTS (
46+
SELECT 1 FROM private.opus_default_backup AS backup
47+
WHERE backup.user_id = settings.user_id
48+
)
49+
FOR UPDATE OF settings
50+
),
51+
backed_up AS (
52+
INSERT INTO private.opus_default_backup (user_id, feature_configs, migrated_feature_configs)
53+
SELECT user_id, original, migrated FROM targets
54+
RETURNING user_id
55+
)
56+
UPDATE public.user_settings AS settings
57+
SET feature_configs = targets.migrated, updated_at = now()
58+
FROM targets
59+
JOIN backed_up USING (user_id)
60+
WHERE settings.user_id = targets.user_id;
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
-- Rolls back supabase/migrations/20261022120000_opus_default_to_sonnet.sql.
2+
-- Not a migration: `supabase db push` never reads this directory. To apply it,
3+
-- copy it into supabase/migrations under a new timestamp (a forward corrective
4+
-- migration) and ship that like any other migration.
5+
--
6+
-- A tool setting goes back to Opus 5.5 only where the migration moved it from
7+
-- Opus to Sonnet and it still holds Sonnet. Anything the user changed after the
8+
-- migration stays. Restored users leave the backup table, so a second run does
9+
-- nothing. The backup table itself stays; dropping it is a separate decision.
10+
11+
WITH restored AS (
12+
SELECT
13+
settings.user_id,
14+
(
15+
SELECT jsonb_object_agg(
16+
entry.key,
17+
CASE
18+
WHEN jsonb_typeof(entry.value) = 'object'
19+
AND entry.value->>'model' = 'anthropic/claude-sonnet-5.5'
20+
AND backup.feature_configs->entry.key->>'model' = 'anthropic/claude-opus-5.5'
21+
AND backup.migrated_feature_configs->entry.key->>'model' = 'anthropic/claude-sonnet-5.5'
22+
THEN jsonb_set(entry.value, '{model}', '"anthropic/claude-opus-5.5"')
23+
ELSE entry.value
24+
END
25+
)
26+
FROM jsonb_each(settings.feature_configs) AS entry
27+
) AS configs
28+
FROM public.user_settings AS settings
29+
JOIN private.opus_default_backup AS backup USING (user_id)
30+
WHERE jsonb_typeof(settings.feature_configs) = 'object'
31+
AND settings.feature_configs <> '{}'::jsonb
32+
FOR UPDATE OF settings
33+
),
34+
updated AS (
35+
UPDATE public.user_settings AS settings
36+
SET feature_configs = restored.configs, updated_at = now()
37+
FROM restored
38+
WHERE settings.user_id = restored.user_id
39+
AND settings.feature_configs IS DISTINCT FROM restored.configs
40+
RETURNING settings.user_id
41+
)
42+
DELETE FROM private.opus_default_backup;

0 commit comments

Comments
 (0)