Skip to content

fix: auto-reassign default project on delete instead of refusing#1139

Open
phernandez wants to merge 1 commit into
mainfrom
fix-delete-default-project-reassign
Open

fix: auto-reassign default project on delete instead of refusing#1139
phernandez wants to merge 1 commit into
mainfrom
fix-delete-default-project-reassign

Conversation

@phernandez

Copy link
Copy Markdown
Member

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.

  • Promotion pick is deterministic: the oldest remaining active project (created_at ASC, id ASC).
  • The single-default invariant is preserved (is_default is nullable with no unique constraint; the old default is cleared and the replacement set in one commit).
  • Only genuine last-project deletes are still refused (nothing left to inherit the flag), with a clearer message.

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:

  • delete default with a sibling → succeeds, sibling promoted to default, target soft-deleted + enqueued
  • delete default that is the only project → still 400 (clearer "only project" message), no soft-delete, no enqueue
  • promotion picks the oldest active project when several exist

uv run pytest tests/cloud/test_project_deletes.py → 5 passed. Lint, format, and ty type-check clean on the changed files.

🤖 Generated with Claude Code

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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +149 to +151
if replacement_default is not None:
project.is_default = None
replacement_default.is_default = True

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +69 to +73
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant