🐾 fix(api): workspace DELETE must not report success it didn't perform #11

Merged
r merged 1 commits from fix/workspace-delete-orphan-libraries into main 2026-08-07 17:19:17 +00:00
Owner

Why

DELETE /library/api/workspaces/{id}/ returned 204 having deleted nothing when the Library's owner_username didn't match the caller. The caller (Daedalus) takes 204 as confirmation, drops its own workspace row, and moves on — while the Library survives, holding its globally-unique name forever. Nothing anywhere records why. The name simply can never be reused, and the next workspace that wants it hits an unexplained name_conflict.

This bites hardest for libraries created via POST /library/api/libraries/, which sets neither owner_username nor workspace_id (views.py:116-131) — such a library holds a name globally but can never be deleted through the workspaces endpoint.

What

  • An unowned library on DELETE now returns 409 owner_conflict, reusing the code the create path already emits for the same condition (so Daedalus's existing _sync_error_message mapping already understands it).
  • GET is unchanged — still 404 for an unowned workspace. The non-disclosure concern is about reads; a delete that silently does nothing is the worse failure, and 409 here reveals no more than the create path already does.
  • A genuinely absent library still returns 204. That idempotency is relied upon by Daedalus's delete path and is correct.

Verification

  • 4 new tests in WorkspaceDeleteOwnershipTests: absent → 204, owned → deleted (cascade called once), unowned → 409 with delete_library_cascade not called, unowned GET → still 404.
  • Full library suite run before and after: identical 8 failures + 1 error, all pre-existing in test_search_api/test_tasks and unrelated to this change (artifacts of the sqlite settings override used locally — this host has no Postgres create permission and Neo4j at ariel.incus is unreachable).
  • Neo4j-touching paths are mocked at neomodel.sync_.match.NodeSet.getLibrary.nodes returns a fresh NodeSet per access, so patching the instance silently does nothing.

Companion

Daedalus side is in r/daedalus PR #27, which records a failed delete as a durable orphan and retries it from the reconciler. Either fix alone narrows the window; together they close it.

🤖 Generated with Claude Code

## Why `DELETE /library/api/workspaces/{id}/` returned **204 having deleted nothing** when the Library's `owner_username` didn't match the caller. The caller (Daedalus) takes 204 as confirmation, drops its own workspace row, and moves on — while the Library survives, holding its **globally-unique name forever**. Nothing anywhere records why. The name simply can never be reused, and the next workspace that wants it hits an unexplained `name_conflict`. This bites hardest for libraries created via `POST /library/api/libraries/`, which sets neither `owner_username` nor `workspace_id` ([views.py:116-131](mnemosyne/library/api/views.py#L116-L131)) — such a library holds a name globally but can *never* be deleted through the workspaces endpoint. ## What - An unowned library on DELETE now returns **409 `owner_conflict`**, reusing the code the create path already emits for the same condition (so Daedalus's existing `_sync_error_message` mapping already understands it). - **GET is unchanged** — still 404 for an unowned workspace. The non-disclosure concern is about reads; a delete that silently does nothing is the worse failure, and 409 here reveals no more than the create path already does. - A **genuinely absent** library still returns 204. That idempotency is relied upon by Daedalus's delete path and is correct. ## Verification - 4 new tests in `WorkspaceDeleteOwnershipTests`: absent → 204, owned → deleted (cascade called once), unowned → 409 with `delete_library_cascade` **not called**, unowned GET → still 404. - Full `library` suite run before and after: **identical** 8 failures + 1 error, all pre-existing in `test_search_api`/`test_tasks` and unrelated to this change (artifacts of the sqlite settings override used locally — this host has no Postgres create permission and Neo4j at `ariel.incus` is unreachable). - Neo4j-touching paths are mocked at `neomodel.sync_.match.NodeSet.get` — `Library.nodes` returns a fresh `NodeSet` per access, so patching the instance silently does nothing. ## Companion Daedalus side is in `r/daedalus` PR #27, which records a failed delete as a durable orphan and retries it from the reconciler. Either fix alone narrows the window; together they close it. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
r added 1 commit 2026-08-07 17:03:52 +00:00
DELETE returned 204 for a library owned by another user, having deleted
nothing. The caller (Daedalus) recorded the workspace as cleaned up and
dropped its own row, while the Library survived holding its globally-unique
name — so that name could never be reused, and nothing anywhere recorded why.

An unowned library now returns 409 owner_conflict, reusing the code the create
path already emits for the same condition. Ownership stays opaque on GET (404
as before): the disclosure concern is about reads, and a delete that silently
does nothing is the worse failure.

A genuinely absent library still returns 204 — that idempotency is relied upon
and is correct.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
r merged commit b5ba2ecadb into main 2026-08-07 17:19:17 +00:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: r/mnemosyne#11