Skip to content

fix: give session deletion an event, so a deleted session stays deleted - #348

Open
ProfSynapse wants to merge 3 commits into
mainfrom
fix/session-delete-ownership
Open

fix: give session deletion an event, so a deleted session stays deleted#348
ProfSynapse wants to merge 3 commits into
mainfrom
fix/session-delete-ownership

Conversation

@ProfSynapse

Copy link
Copy Markdown
Owner

Companion to #347. Same family, three different failures.

Best reviewed after #347 — it reuses that branch's ownership-module pattern, and target 3 is deliberately left for a one-liner once it lands.

Sessions: the delete was cache-only by construction

session_deleted did not exist as an event type. grep -rn "session_deleted" src/ returned nothing, so deleteSession wrote no event, replay had nothing to apply, and the row came back on the next rebuild.

Measured on a seeded vault — 1 workspace, 2 sessions, 2 states + 2 traces + 1 trace-embedding row on the target:

pre-fix after delete pre-fix after rebuild post-fix
sessions 0 1 — resurrected 0, still 0 after rebuild
states 2 2 0
memory_traces 2 2 0
trace_embedding_metadata 1 1 0

Bystander session, its state and trace, the workspace, and the linked conversation are all untouched — pinned by tests.

A session owns no stream. Its events live in the parent workspace's shared stream, so none of #347's stream-removal machinery applies here: the tombstone is the removal. There is a test asserting the workspace stream and the sibling session survive.

Projects and tasks: the same unenforced-FK shape

Deleting a project with 3 tasks, 1 dependency edge and 1 note link left tasks 3, task_dependencies 1, task_note_links 1 — and the rebuild reproduced them.

Single-task delete leaked too: task_dependencies 1, task_note_links 1, and a child task's parentTaskId still pointing at the deleted parent.

taskOwnership.ts handles both. Note the parent link is SET NULL, not CASCADE, so sub-tasks are detached rather than deleted — that is the declared intent, and the fix honours it instead of cascading.

Conversations: two gaps, not one

The live delete left 1 orphan messages row that only self-healed on rebuild. Fixed here — it needed nothing from #347.

The stream-directory leak is deferred: it needs #347's deleteStream chain (4 files, ~90 lines), and re-implementing that on main would fork the mechanism and collide on merge. One-line follow-up once #347 lands.

Migration: none, deliberately

The only signal of a pre-fix delete is "present in JSONL, absent from SQLite" — which is indistinguishable from a cold or partial cache. Backfilling would turn rebuildable state into permanent loss.

Verified in both directions live: a stream written by the pre-fix build shows the session back, and one re-delete makes it permanent through a rebuild; and an unknown event type injected into a real stream is silently dropped with the rebuild succeeding — which is exactly how an older build will treat session_deleted.

Partial failure

Tombstone first, cache second, in both fixes. A failed tombstone throws before touching SQLite and is a retryable no-op (tested: zero statements issued). A failed purge leaves a stale cache over an event store that already says deleted, and the next rebuild converges on deleted. The reverse order converges on resurrection — the measured bug.

Evidence

npx jest tests/unit tests/integration — 342 suites, 4388 passing. npm run build green. New tests fail pre-fix, verified against the base commit's src/: 8/12, 7/8 and 1/2 respectively.

Live rebuild and cold-restart verification passed with dev:errors empty. Nothing tool-reachable was added — sessions are context fields, not tools, and no delete tool exists for any of these.

🤖 Generated with Claude Code

https://claude.ai/code/session_01C6aSoCAS5gNoew6n9qv6DJ


Generated by Claude Code

claude added 3 commits August 15, 2026 11:33
…ays deleted

`deleteSession` wrote no JSONL event at all — `session_deleted` did not exist
(`grep -rn "session_deleted" src/` returned nothing). It ran one
`DELETE FROM sessions WHERE id = ?` and trusted an `ON DELETE CASCADE` that never
fires, because SQLite foreign-key enforcement is off on the shared connection.

Measured in a real vault (Obsidian 1.13.7, headless), deleting a session holding
2 states / 2 traces / 1 trace embedding:

  after delete : sessions 0, states 2, memory_traces 2, trace_embedding_metadata 1
  after rebuild: sessions 1, states 2, memory_traces 2

The session itself came back: the workspace stream still held `session_created`
and replay had no tombstone to cancel it with.

This adds `SessionDeletedEvent` and applies it on replay, and puts the row purge
in `src/database/sessionOwnership.ts` — one declaration that both
`SessionRepository.delete` and `WorkspaceEventApplier.applySessionDeleted` call,
so the live delete and its replay cannot drift. Same machinery and the same
reasoning as `workspaceOwnership.ts` (#347), with one deliberate difference: a
session owns no stream of its own. Its events live in the parent workspace's
stream, shared with the workspace and its sibling sessions, so there is no file
to remove and the tombstone IS the removal.

Ordering, and what a partial failure does: the tombstone is written first, so a
failed SQLite purge leaves stale cache over an event store that already says
"deleted" and the next rebuild converges on deleted. A failed tombstone write
throws before SQLite is touched, leaving a retryable no-op. The reverse order is
what produced the measured resurrection.

No backfill for old JSONL. The only trace of a pre-fix delete is "in JSONL,
absent from SQLite", which is also what a cold or partial cache looks like;
inferring tombstones from it would turn rebuildable cache state into permanent
loss. Old streams carry no tombstone, the session returns once, and the user's
next delete is permanent — verified against a stream written by the pre-fix
build. Forward compatibility is unchanged: appliers switch with no default, so an
older build drops the unknown event instead of failing (verified live).

Post-fix, same vault: 0 states, 0 traces, 0 embedding rows, session gone, and
still gone after `rebuildCache()` and after a cold restart. The sibling session
and the workspace are untouched.

Internal/UI only — sessions are context fields and no tool deletes one; this does
not add a delete tool.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6aSoCAS5gNoew6n9qv6DJ
… own

Both the live delete and the replay of it said "CASCADE will handle it". It does
not: SQLite foreign-key enforcement is per-connection and off by default, and
nothing in this plugin turns it on (`grep -rn "foreign_keys" src/` is empty), so
every `ON DELETE CASCADE` in schema.ts is documentation.

Measured in a real vault (Obsidian 1.13.7, headless):

  delete a project with 3 tasks / 1 dependency edge / 1 note link
    after delete : projects 0, tasks 3, task_dependencies 1, task_note_links 1
    after rebuild: identical — the orphans are reproduced, not cleared

  delete a single task with 1 incoming edge / 1 note link / 1 child
    after delete : task_dependencies 1, task_note_links 1,
                   child.parentTaskId still pointing at the deleted task
    after rebuild: identical

The rebuild reproduces them because replay re-creates every task from
`task_created` and then drops only the parent row.

`src/database/taskOwnership.ts` declares the statement list once —
child-before-parent, edges resolved through `tasks` while those rows still exist
— and `ProjectRepository`, `TaskRepository` and `TaskEventApplier` all call it,
so the live delete and its replay cannot drift. Same shape as
`sessionOwnership.ts` and `workspaceOwnership.ts` (#347).

Sub-tasks are detached, not deleted: `parentTaskId` is declared `ON DELETE SET
NULL`, so a child of a removed task survives with a null parent. Deleting it
would destroy work nobody asked to delete, and neither path ever claimed to.

Ordering is unchanged (tombstone first, then the cache), which keeps a partial
failure converging on deleted: stale rows over an event store that says deleted
are cleared by the next rebuild.

Post-fix, same vault: 0 tasks, 0 edges, 0 links, child parentTaskId null, and all
still zero after `rebuildCache()` and a cold restart. The bystander project and
its task are untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6aSoCAS5gNoew6n9qv6DJ
The conversation tombstone was already right — the conversation does not come
back — and `ConversationEventApplier.applyConversationDeleted` has always removed
the messages explicitly. `ConversationRepository.delete` did not: it relied on the
`ON DELETE CASCADE` on `messages.conversationId`, which never fires.

Measured in a real vault (Obsidian 1.13.7, headless), deleting a 1-message
conversation left `conversations 0, messages 1`. The orphan self-healed at the
next `rebuildCache()` (the replay path does it correctly), so this is cache
hygiene rather than a resurrection bug — but the two paths should agree.

The other conversation gap measured in the same run is NOT fixed here: the stream
directory `Nexus/data/conversations/conv_<id>/` survives the delete and the
rebuild. Removing it needs the stream-removal helper from
`fix/workspace-delete-ownership` (#347) — `JSONLWriter.deleteStream` down through
`StorageRouter` / `VaultEventStore` / `ShardedJsonlStreamStore`. Re-implementing
that on top of main would fork the mechanism and collide with #347, so it is left
as a follow-up on that branch: one `deleteStream` call after the tombstone. The
replay self-cancels either way, so nothing resurrects meanwhile.

Also records what this pass learned in the nexus-storage skill: how to recognise
a deletion with no event type at all, where a tombstone goes when the entity owns
no stream of its own, and why a new deletion event must not be backfilled onto
old JSONL.

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