fix: auto-reassign default project on delete instead of refusing#1139
fix: auto-reassign default project on delete instead of refusing#1139phernandez wants to merge 1 commit into
Conversation
Deleting the default project raised a 400 telling the user to "set another project as default first". Cloud team/organization workspaces intentionally hide the "Set as Default" control (basic-memory-cloud #968), so for those workspaces the default project became permanently undeletable — a dead end a customer hit trying to remove their Getting Started project. The default flag exists because a workspace must always resolve a project for project-less writes. Instead of refusing, promote the oldest remaining active project to default in the same transaction, preserving the single-default invariant. Only refuse when the default is the last remaining project. This is on the async acceptance path used by cloud project deletes. The local/self-hosted v2 router keeps its explicit guard because local has a working set-default affordance, so it is not a dead end there. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a0669847dd
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if replacement_default is not None: | ||
| project.is_default = None | ||
| replacement_default.is_default = True |
There was a problem hiding this comment.
Restore the default flags when enqueue fails
If deleting a default project reaches this promotion and enqueue_project_delete() then raises, the except path only calls reactivate_accepted_project(), which restores is_active on the deleted project but never restores its is_default flag or demotes the replacement. The delete request fails, yet the workspace default has silently moved to another project, so later project-less writes can be routed to the wrong project after a transient queue outage.
Useful? React with 👍 / 👎.
| result = await session.execute( | ||
| select(Project) | ||
| .where(Project.is_active.is_(True), Project.id != deleted_project_id) | ||
| .order_by(Project.created_at.asc(), Project.id.asc()) | ||
| .limit(1) |
There was a problem hiding this comment.
Lock the promoted project before handing it the default
When one request deletes the current default while another simultaneously deletes the sibling selected here, this query reads the replacement without locking it. The sibling delete can commit after this selection but before this transaction sets replacement_default.is_default = True, leaving an inactive row marked as the default and no active default project; selecting the replacement with FOR UPDATE or rechecking its active state before commit would keep the promotion and delete paths serialized.
Useful? React with 👍 / 👎.
Why
A customer (solo owner on a team trial) reported they couldn't delete their Getting Started project. It was their default project, and the delete path refuses to delete a default with "Cannot delete default project… Set another project as default first."
But cloud team/organization workspaces intentionally hide the "Set as Default" control (basic-memory-cloud #968 — "Default has no clear meaning when multiple users share projects"). So in those workspaces the default project became permanently undeletable: the error points at an affordance that doesn't exist there. Dead end.
What
Instead of refusing, the async project-delete acceptance path now promotes another active project to default in the same transaction, then proceeds with the soft-delete. The default flag exists because a workspace must always resolve a project for project-less writes, so we keep that invariant rather than dropping it.
created_at ASC, id ASC).is_defaultis nullable with no unique constraint; the old default is cleared and the replacement set in one commit).Scope
This changes the async acceptance path (
ProjectDeleteAcceptanceService) used by cloud project deletes. The local/self-hosted v2 router (delete_project_by_id) keeps its own default guard because local has a working "set default" affordance — it is not a dead end there. Called out so the intentional asymmetry is visible to reviewers.Testing
tests/cloud/test_project_deletes.py:uv run pytest tests/cloud/test_project_deletes.py→ 5 passed. Lint, format, andtytype-check clean on the changed files.🤖 Generated with Claude Code