Skip to content

fix: make permanent workspace delete remove everything it owns - #347

Open
ProfSynapse wants to merge 2 commits into
mainfrom
fix/workspace-delete-ownership
Open

fix: make permanent workspace delete remove everything it owns#347
ProfSynapse wants to merge 2 commits into
mainfrom
fix/workspace-delete-ownership

Conversation

@ProfSynapse

Copy link
Copy Markdown
Owner

Permanent workspace deletion removed the workspaces row and left everything else behind.

Stays UI-only: no delete tool is added and none becomes reachable. The AI still gets archiveWorkspace, which is reversible.

Measured before fixing

Seeded a workspace with 2 sessions / 2 states / 2 traces / 1 project / 2 tasks in a real headless Obsidian and deleted it through the exact WorkspacesTab call. Only the workspaces row and its FTS entry went.

Survivors: sessions 2, states 2, memory_traces 2, projects 1, tasks 2, plus workspaces/ws_<id>/ (8 events, tombstone appended) and tasks/tasks_<id>/ (3 events, untouched).

rebuildCache() reproduced that orphan set identically, so this was never cache-only residue. The original report of "orphan states rows" understated it — every child table survived.

Two independent causes, both proven

  1. The declared ON DELETE CASCADEs never fire. SQLite foreign-key enforcement is off and nothing turns it on — grep -rn "foreign_keys" src/ returns nothing. schema.ts already notes this for note_properties; it applies file-wide.
  2. Nothing could remove a stream. StorageRouter.deleteFile only reached a legacy flat file that a vault-root install never wrote, and the tasks stream carries no tombstone at all.

What a workspace actually owns

SQLite: sessions, states, memory_traces, projects, tasks (and task_dependencies / task_note_links through them), plus trace_embedding_metadata and its vec0 rows — which nothing in the system would ever have collected, since clearAllData skips them too. workspace_fts was already handled by a delete trigger.

JSONL: two streams, not oneworkspaces/ws_<id> and tasks/tasks_<id>.

Conversations are deliberately not owned — nullable back-reference, own stream, outlives the workspace. A test pins that boundary so a future "tidy up" does not quietly widen the blast radius.

Ownership is declared once

New src/database/workspaceOwnership.ts. Both the live delete and applyWorkspaceDeleted (the replay path) go through it, so they cannot drift — and that drift is precisely what made the rebuild resurrect the children.

Stream removal is new and idempotent, from ShardedJsonlStreamStore up through JSONLWriter. It removes cloud-sync conflict siblings too; leaving one turns a delete into a partial truncation.

Partial failure, chosen rather than incidental

Order: tombstone → streams → SQLite. Throw on any failure. Purge the cache only if the JSONL side finished.

The halves are not symmetric, so the choice is by direction of convergence:

  • a stream failure leaves the workspace fully intact and retryable
  • a SQLite failure leaves stale rows over an event store that no longer has the workspace — which the next rebuild clears

Both converge on deleted. SQLite-first converges on the workspace coming back with all its states, which is the #333 shape.

The tasks stream goes first so the tombstone-bearing stream survives longest: once the tombstone lands, every later failure still resolves to deleted, leaving only disk bytes.

Evidence

12 regression tests; 11 fail against pre-fix code (verified by checking out the base commit's src/). The 12th is the conversations-boundary guard, which correctly passes both ways.

Live on a cold start: after delete every table 0 and both stream directories gone; after rebuildCache(), identical — nothing returned. A stream left on disk with a tombstone replays to 0/0/0/0. Full process restart: no resurrection, dev:errors clean. A simulated EBUSY vault throws with the workspace fully intact, and a retry completes it.

npx jest tests/unit tests/integration — 4378 passing. npm run build green.

Neighbouring gaps found, measured, not fixed

None qualified as small-and-obviously-correct:

  • deleteSession writes no event at all, so a deleted session comes back on rebuild (0 → 1) and its states are never removed. Same family, and the most serious one left.
  • Deleting a project orphans its tasks — same unenforced-FK shape.
  • Deleting a conversation leaks its stream directory (disk growth only; that replay self-cancels).

🤖 Generated with Claude Code

https://claude.ai/code/session_01C6aSoCAS5gNoew6n9qv6DJ


Generated by Claude Code

claude added 2 commits August 15, 2026 10:47
Deleting a workspace ran one `DELETE FROM workspaces WHERE id = ?` and left
every child row and both of its JSONL streams behind. Measured in a real
vault, deleting a workspace with 2 sessions / 2 states / 2 traces / 1 project
/ 2 tasks left: sessions 2, states 2, memory_traces 2, projects 1, tasks 2 in
SQLite, plus `workspaces/ws_<id>/` (8 events) and `tasks/tasks_<id>/` (3
events) on disk. Running "Rebuild cache" reproduced that orphan set exactly,
because SQLite is dropped and replayed from those two surviving streams.

Two independent causes:

- The schema declares `ON DELETE CASCADE` on sessions/states/memory_traces/
  projects/tasks, but FK enforcement is off on the shared connection (SQLite's
  per-connection default — schema.ts already says so for `note_properties`).
  Nothing ever cascaded, in the live delete or in `applyWorkspaceDeleted`.
- Nothing removed the event streams. The tombstone made
  `reconcileMissingWorkspaces` skip the workspace stream, but `fullRebuild`
  replays every stream it lists, and the tasks stream is a SEPARATE file that
  carries no tombstone at all.

`workspaceOwnership.ts` now states once what a workspace owns — the ordered
row purge and both stream paths — and both the live delete and the replay of
that delete go through it, so they cannot drift apart. Conversations are
deliberately excluded: `workspaceId` there is a nullable back-reference, not
ownership.

Ordering is deliberate, because the two halves are not symmetric. Tombstone,
then streams, then SQLite. If stream removal fails we throw before touching
the cache, so nothing is destroyed, the workspace still lists, and the delete
is a retryable no-op; `WorkspaceService` already skips its 'deleted'
notification when the write throws. If the (local, transactional) SQLite purge
fails, the rows are stale cache over an event store that no longer has the
workspace, and the next rebuild clears them. Every partial failure converges
toward deleted. The opposite order converges on the workspace coming back with
all its states at the next rebuild, which is the shape of #333.

This stays UI-only. No tool is added and none becomes reachable — the memory
agent still exposes only the reversible `archiveWorkspace`.

Verified in a real headless Obsidian on a cold start: delete now leaves 0 rows
in every workspace-keyed table and removes both stream directories, and
`rebuildCache()` brings nothing back. A stream that survives with a tombstone
(sync from another device, or a removal we failed and threw on) replays to
nothing too. Simulating a locked vault leaves the workspace fully intact and
the retry completes it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6aSoCAS5gNoew6n9qv6DJ
The nexus-storage "Data reappeared after a rebuild" entry blamed a missing
deletion event. Workspace deletion appended one and the data still came back,
because three other conditions were unmet: the applier handling the tombstone
deleted less than the live delete did, no declared ON DELETE CASCADE fires
(FK enforcement is off and nothing turns it on), and a workspace owns two
streams so a tombstone in one says nothing about the other.

Names the check that would have caught it: rebuildCache() plus a per-table
count in the running app, not "the row is gone from the UI".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6aSoCAS5gNoew6n9qv6DJ
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.

2 participants