Skip to content

Commit 48a6f6b

Browse files
LEANDERANTONYclaude
andcommitted
Fix: allow Free saved_workspace upsert (Codex P1 finding)
The saved_workspaces quota gate was rejecting every re-save from a Free user (cap=1) because it checked `existing_count >= cap` without distinguishing UPDATES to an existing slot from creation of NEW slots. Today's store upserts on user_id (one row per user), so any "existing row" means the save is an update — not a new slot — and the cap shouldn't apply. Real-world impact: the frontend autosave path calls /workspace/save directly without pre-delete. Once a Free user has one saved workspace, every subsequent autosave got rejected with a 429, breaking the snapshot-refresh UX for every Free user past their first save. Fix splits the gate into two questions: * is_existing_slot_update — does the user already own the slot? * is_creating_new_slot — would this save add to the slot count? The cap fires only on the second case. Today, with one-row-per-user storage, the gate is effectively dormant (existing_count == 1 always means update, existing_count == 0 with cap >= 1 always passes). It will start firing correctly the moment we migrate to per-slot rows (future PR with slugs/labels), without revisiting this code path. test_2nd_saved_workspace_on_free_returns_429 was inverted to test_free_re_save_upserts_existing_workspace and now pins the correct UX: re-saving is allowed because it's an update, not a new slot. test_saved_workspace_delete_then_save_works was updated to drop the intermediate 429 assertion (no longer fires under the fix) and instead pin that DELETE + save round-trips cleanly. Found by ChatGPT Codex review on PR #2 (May 2026). Full backend suite: 501 passed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8dacf9c commit 48a6f6b

2 files changed

Lines changed: 60 additions & 47 deletions

File tree

backend/services/workspace_persistence_service.py

Lines changed: 24 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -114,23 +114,27 @@ def save_workspace_snapshot(
114114
raise RuntimeError("Saved workspace persistence is not configured.")
115115

116116
# Persistent-count quota gate. Skip when the tier cap is UNLIMITED
117-
# (Business). For capped tiers, count existing slots: the current
118-
# store has at most one row per user, so the count is 0 or 1.
119-
# `load_workspace` returns `(record, status)` -- status is
120-
# "missing" / "expired" / "available". Anything other than
121-
# "available" means there's no live row.
117+
# (Business). For capped tiers, the rule is: a NEW slot creation
118+
# counts toward the cap; UPDATES to an existing slot do not.
122119
#
123-
# Eviction policy at-cap: REJECT, do not auto-evict. Users must
124-
# explicitly delete an existing saved workspace before saving a
125-
# new one. This matches the saved_jobs gate. The "overwrite" UX
126-
# for Free at cap=1 is achieved client-side: the frontend deletes
127-
# the existing workspace before issuing the new save.
120+
# Today's store upserts on user_id (one row per user). So when the
121+
# user already has an "available" record, this save is an UPDATE to
122+
# their existing slot, not a new slot — the cap doesn't apply and
123+
# the save proceeds. This is critical for the frontend autosave UX:
124+
# every snapshot refresh after the first save would otherwise 429
125+
# for Free users (cap=1, 1 >= 1 blocks the upsert).
128126
#
129-
# Pro cap=5 / Business UNLIMITED are future-schema slots. Today
130-
# the store upserts on user_id (one row per user) so Pro and
131-
# Business never functionally reach the cap; the gate remains
132-
# tier-correct so the next schema migration (e.g. per-slug rows)
133-
# doesn't require revisiting this code path.
127+
# When the schema migrates to per-slot rows (e.g. one row per
128+
# saved-workspace slug, future PR), `existing_count` becomes the
129+
# user's actual distinct-slot count and `is_creating_new_slot`
130+
# becomes True only when the save's slug isn't already in the
131+
# user's set. The gate logic then naturally blocks the (cap+1)-th
132+
# distinct slot without revisiting this code path.
133+
#
134+
# The 429 message + the saved_jobs gate's parallel "delete to make
135+
# room" UX still apply for genuinely-new slot creation; this just
136+
# carves out the upsert path so autosave doesn't break under tier
137+
# caps the user hasn't actually exceeded.
134138
tier = resolve_user_tier(context.app_user)
135139
cap = TIER_CAPS[tier]["saved_workspaces"]
136140
quota_user_id = str(getattr(context.app_user, "id", "") or "")
@@ -140,12 +144,12 @@ def save_workspace_snapshot(
140144
refresh_token,
141145
quota_user_id,
142146
)
143-
existing_count = (
144-
1
145-
if existing_status == "available" and existing_record is not None
146-
else 0
147+
is_existing_slot_update = (
148+
existing_status == "available" and existing_record is not None
147149
)
148-
if existing_count >= cap:
150+
existing_count = 1 if is_existing_slot_update else 0
151+
is_creating_new_slot = not is_existing_slot_update
152+
if is_creating_new_slot and existing_count >= cap:
149153
raise QuotaExceededError(
150154
"You have reached the saved-workspaces limit for your "
151155
"plan. Delete an existing saved workspace to make room, "

tests/backend/test_search_and_saved_quota_enforcement.py

Lines changed: 36 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -527,31 +527,34 @@ def _resolver(*, access_token=None, refresh_token=None):
527527
)
528528

529529

530-
def test_2nd_saved_workspace_on_free_returns_429():
531-
"""Free's saved_workspaces cap is 1. The first save succeeds; the
532-
second attempt (with one row already present) rejects with cap=1.
533-
534-
The current single-row schema means save 1 puts 1 row + save 2
535-
would normally overwrite it. The gate REJECTS the overwrite to
536-
keep the Free=1 ceiling explicit -- users delete their existing
537-
workspace before saving a new one. This is the documented
538-
eviction policy from the brief.
530+
def test_free_re_save_upserts_existing_workspace():
531+
"""Free's saved_workspaces cap is 1, but re-saving an existing
532+
workspace is an UPDATE to the same slot rather than a new slot —
533+
so the gate must NOT 429. This is what the frontend autosave
534+
relies on: every snapshot refresh after the first save calls
535+
/workspace/save without pre-delete; blocking those would break
536+
the autosave UX for every Free user past their first save.
537+
538+
The cap (1) governs the maximum number of *distinct slots* a Free
539+
user can occupy, not the number of write operations against their
540+
existing slot. Today's one-row-per-user schema can't actually
541+
produce a >1 distinct-slot state for a single user, so the
542+
"(cap+1)-th distinct save" assertion lands with the multi-row
543+
schema migration (see test_distinct_2nd_workspace_blocks_when_multi_slot_lands).
544+
545+
Regression for Codex P1 flagged on PR #2 (May 2026).
539546
"""
540547
auth_context = _build_auth_context(user_id="user-free-workspace-1")
541548
store = _FakeSavedWorkspaceStore()
542549

543550
# First save: 0 existing -> allowed.
544-
result = _save_workspace_snapshot(auth_context=auth_context, store=store)
545-
assert result["status"] == "saved"
551+
first = _save_workspace_snapshot(auth_context=auth_context, store=store)
552+
assert first["status"] == "saved"
546553

547-
# Second save: 1 existing -> rejected.
548-
with pytest.raises(QuotaExceededError) as exc_info:
549-
_save_workspace_snapshot(auth_context=auth_context, store=store)
550-
err = exc_info.value
551-
assert err.counter == "saved_workspaces"
552-
assert err.cap == TIER_CAPS["free"]["saved_workspaces"] == 1
553-
assert err.current == 1
554-
assert err.tier == "free"
554+
# Second save: same user_id, store upserts -> still an update to
555+
# the user's existing slot, NOT a new slot. Must succeed.
556+
second = _save_workspace_snapshot(auth_context=auth_context, store=store)
557+
assert second["status"] == "saved"
555558

556559

557560
def test_pro_saved_workspace_with_existing_row_is_allowed(monkeypatch):
@@ -579,15 +582,21 @@ def test_pro_saved_workspace_with_existing_row_is_allowed(monkeypatch):
579582

580583

581584
def test_saved_workspace_delete_then_save_works():
582-
"""Deleting the saved workspace frees the slot so the user can save
583-
again -- the gate is consistent with the "delete to make room"
584-
UX in the 429 message.
585+
"""Deleting the saved workspace and re-saving the next snapshot
586+
both succeed. With the post-Codex-fix gate, re-saving never
587+
requires a pre-delete (upserts are always allowed against an
588+
existing slot), so the explicit DELETE is purely UX-driven --
589+
e.g. when the user wants to clear their workspace from the
590+
saved-jobs sidebar. This test pins that DELETE + save round-trips
591+
cleanly even after the gate is in place.
585592
"""
586593
auth_context = _build_auth_context(user_id="user-free-workspace-delete-1")
587594
store = _FakeSavedWorkspaceStore()
588-
_save_workspace_snapshot(auth_context=auth_context, store=store)
589-
with pytest.raises(QuotaExceededError):
590-
_save_workspace_snapshot(auth_context=auth_context, store=store)
595+
# First save: 0 existing -> allowed.
596+
first = _save_workspace_snapshot(auth_context=auth_context, store=store)
597+
assert first["status"] == "saved"
598+
# Explicit delete (user-driven clean slate).
591599
store.delete_workspace("access", "refresh", "user-free-workspace-delete-1")
592-
result = _save_workspace_snapshot(auth_context=auth_context, store=store)
593-
assert result["status"] == "saved"
600+
# Save again from 0 existing -> allowed.
601+
second = _save_workspace_snapshot(auth_context=auth_context, store=store)
602+
assert second["status"] == "saved"

0 commit comments

Comments
 (0)