feat(campaigns): deleting a campaign no longer destroys everything in it (#405) #478

Merged
claude-bot merged 1 commit from feat/405-campaign-soft-delete into main 2026-09-01 16:18:21 +00:00
Contributor

Closes #405.

The gap

delete_campaign was db.delete(campaign) followed immediately by a commit, and 17 tables carry ON DELETE CASCADE back to campaigns.id. Every session, transcript, summary, wiki entry, note, NPC, arc, plot thread and ledger row a group had built over months of play went in a single statement — behind one native confirm(), available to any GM on the campaign rather than only its creator, with no undo, no grace, and no prompt to export first.

Campaigns now go to a trash with a 30-day grace (7-day floor, shared with the wiki trash), a typed-name confirmation, an export offered at the moment of deletion, and a purge_trashed_campaigns Beat task that runs the cascade once the window closes. Migration f4a5b6c7d8ea, verified up → down → up.

Why this is not #408's convention

A lore entry is only ever reached by selecting one, so filtering each of its 13 reads works and is checkable. A campaign is different in kind: its children are routinely selected without the query mentioning Campaign at all — 35 select(Session) sites alone, across 17 FK'd tables. "Filter every read" would mean auditing every session, note, ledger and lore query in the codebase, and would still silently fail to cover the next one anyone writes.

So the gate is _reject_if_trashed in app/auth/dependencies.py, called by all four campaign- and session-scoped authorisation dependencies. Every such route is covered at once, and a route added tomorrow is covered by construction — it cannot authorise itself without passing through it.

Five doors do not open with a session cookie, and filter explicitly:

the unauthenticated public analytics link · the bot's guild lookup (X-Bot-Key) · the invite-code join · the dashboard list · the scheduled tasks

The tasks go through campaign_service.get_live_campaign — a db.get that returns None for a trashed campaign, so the if not campaign: continue guard all 11 call sites already had does the work. I checked each of the 11 for that guard before routing them through it; without one, the change would have turned a skip into an AttributeError.

Three things checked rather than assumed

  • The issue's proposed fix does not hold up. It suggested routing deletion through the existing archive feature. But is_archived is not a read filter anywhere in the backend — it appears in the model, the schemas, archive/restore, and one join-by-invite guard. Archiving suppresses nothing, so it gave soft delete nothing to build on. deleted_at is a separate column, and the two states stay distinct: collapsing them would make "archive for the season" and "destroy everything" the same database state.
  • enforce_retention skips trashed campaigns in all three of its passes, because a restored campaign must come back intact. Notably not by dropping them from the policy map — I wrote that first and it was wrong: get_effective_retention(db, None) falls back to the instance defaults, which can be shorter than the campaign's own override, so that version deleted their audio sooner rather than not at all.
  • The issue's line numbers had drifted (endpoint 930-953 → 967, service 189-193 → 190, the frontend confirm() 747 → 839). The substance held.

Endpoints

DELETE now takes confirm_name and trashes (still 204 — the caller need not care which kind of gone it is), plus GET /campaigns/trash/mine and POST /campaigns/{id}/undelete. /undelete, because /restore already means un-archive — two states, two verbs. Neither trash route can sit under /{campaign_id} with require_gm, since that dependency 404s trashed campaigns by design; the trash has to live outside the gate it exists to see behind.

Frontend

The native confirm() is replaced by a typed-name modal matching the existing erase-member dialog (#118), with the export offered inside it — at the moment it is relevant, rather than somewhere it must be remembered. Deleted campaigns appear on the dashboard with a countdown and a Restore button, which is what makes the grace period something a GM can actually act on.

Verification

28 backend mutations and 3 frontend mutations, all caught — reverting to a hard delete, removing the gate from each of the four dependencies, each of the five explicit filters, a restore that does not clear the flag or that also un-archives, a purge that ignores the grace or sweeps live rows, a retention floor that is only a default, and each of the three retention passes.

One test initially survived its mutation: the detail-route test claimed to cover the dependency gate, but that route is defended twice (the gate and get_campaign filtering), so removing the gate changed nothing and the test could not tell which one was working. Added a member-list test — it reads campaign_members and never selects a Campaign, which is exactly the shape per-read filtering misses.

Backend 1609 passed, frontend 470 passed; ruff check/format and eslint clean. No BOT_CONTRACT_VERSION bump: the bot response shape is unchanged, and a trashed campaign's guild returning 404 is a path an older bot already handles correctly — it denies with "cannot establish who the GM is", which is the right answer for a deleted campaign.

Two pre-existing test-harness failures are unrelated and excluded from the local run (test_version_sync.py, test_backup_client_version.py): both resolve Path(__file__).parents[3] to the repo root, which the local container does not mount. They pass in CI.

🤖 Generated with Claude Code

Closes #405. ## The gap `delete_campaign` was `db.delete(campaign)` followed immediately by a commit, and **17 tables** carry `ON DELETE CASCADE` back to `campaigns.id`. Every session, transcript, summary, wiki entry, note, NPC, arc, plot thread and ledger row a group had built over months of play went in a single statement — behind one native `confirm()`, available to any GM on the campaign rather than only its creator, with no undo, no grace, and no prompt to export first. Campaigns now go to a trash with a 30-day grace (7-day floor, shared with the wiki trash), a typed-name confirmation, an export offered at the moment of deletion, and a `purge_trashed_campaigns` Beat task that runs the cascade once the window closes. Migration `f4a5b6c7d8ea`, verified up → down → up. ## Why this is not #408's convention A lore entry is only ever reached by *selecting one*, so filtering each of its 13 reads works and is checkable. A campaign is different in kind: its children are routinely selected without the query mentioning `Campaign` at all — **35 `select(Session)` sites alone**, across 17 FK'd tables. "Filter every read" would mean auditing every session, note, ledger and lore query in the codebase, and would still silently fail to cover the next one anyone writes. So the gate is `_reject_if_trashed` in `app/auth/dependencies.py`, called by all four campaign- and session-scoped authorisation dependencies. Every such route is covered at once, and a route added tomorrow is covered *by construction* — it cannot authorise itself without passing through it. Five doors do not open with a session cookie, and filter explicitly: > the unauthenticated public analytics link · the bot's guild lookup (`X-Bot-Key`) · the invite-code join · the dashboard list · the scheduled tasks The tasks go through `campaign_service.get_live_campaign` — a `db.get` that returns `None` for a trashed campaign, so the `if not campaign: continue` guard **all 11 call sites already had** does the work. I checked each of the 11 for that guard before routing them through it; without one, the change would have turned a skip into an `AttributeError`. ## Three things checked rather than assumed - **The issue's proposed fix does not hold up.** It suggested routing deletion through the existing archive feature. But `is_archived` is not a read filter *anywhere* in the backend — it appears in the model, the schemas, archive/restore, and one join-by-invite guard. Archiving suppresses nothing, so it gave soft delete nothing to build on. `deleted_at` is a separate column, and the two states stay distinct: collapsing them would make "archive for the season" and "destroy everything" the same database state. - **`enforce_retention` skips trashed campaigns in all three of its passes**, because a restored campaign must come back *intact*. Notably **not** by dropping them from the policy map — I wrote that first and it was wrong: `get_effective_retention(db, None)` falls back to the *instance defaults*, which can be **shorter** than the campaign's own override, so that version deleted their audio sooner rather than not at all. - **The issue's line numbers had drifted** (endpoint 930-953 → 967, service 189-193 → 190, the frontend `confirm()` 747 → 839). The substance held. ## Endpoints `DELETE` now takes `confirm_name` and trashes (still 204 — the caller need not care which kind of gone it is), plus `GET /campaigns/trash/mine` and `POST /campaigns/{id}/undelete`. `/undelete`, because `/restore` already means un-archive — two states, two verbs. Neither trash route can sit under `/{campaign_id}` with `require_gm`, since that dependency 404s trashed campaigns by design; the trash has to live outside the gate it exists to see behind. ## Frontend The native `confirm()` is replaced by a typed-name modal matching the existing erase-member dialog (#118), with the export offered *inside* it — at the moment it is relevant, rather than somewhere it must be remembered. Deleted campaigns appear on the dashboard with a countdown and a Restore button, which is what makes the grace period something a GM can actually act on. ## Verification **28 backend mutations and 3 frontend mutations, all caught** — reverting to a hard delete, removing the gate from each of the four dependencies, each of the five explicit filters, a restore that does not clear the flag or that also un-archives, a purge that ignores the grace or sweeps live rows, a retention floor that is only a default, and each of the three retention passes. One test **initially survived its mutation**: the detail-route test claimed to cover the dependency gate, but that route is defended twice (the gate *and* `get_campaign` filtering), so removing the gate changed nothing and the test could not tell which one was working. Added a member-list test — it reads `campaign_members` and never selects a `Campaign`, which is exactly the shape per-read filtering misses. Backend **1609 passed**, frontend **470 passed**; `ruff check`/`format` and eslint clean. No `BOT_CONTRACT_VERSION` bump: the bot response shape is unchanged, and a trashed campaign's guild returning 404 is a path an older bot already handles correctly — it denies with "cannot establish who the GM is", which is the right answer for a deleted campaign. Two pre-existing test-harness failures are unrelated and excluded from the local run (`test_version_sync.py`, `test_backup_client_version.py`): both resolve `Path(__file__).parents[3]` to the repo root, which the local container does not mount. They pass in CI. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(campaigns): deleting a campaign no longer destroys everything in it (#405)
All checks were successful
CI / Backend lint (ruff) (pull_request) Successful in 25s
CI / Bot/backend version sync (pull_request) Successful in 1m2s
CI / Bot tests and audit (pull_request) Successful in 1m29s
CI / Docker image build (pull_request) Successful in 1m53s
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 2m31s
CI / Frontend tests, audit, and build (pull_request) Successful in 4m27s
CI / Backend migration, tests, and audit (pull_request) Successful in 6m8s
225717025e
`delete_campaign` was `db.delete(campaign)` followed immediately by a commit,
and 17 tables carry ON DELETE CASCADE back to `campaigns.id`. Every session,
transcript, summary, wiki entry, note, NPC, arc and ledger row a group had
built over months went in a single statement, behind one native confirm(),
available to any GM on the campaign rather than only its creator.

Campaigns now go to a trash with a 30-day grace (7-day floor, shared with the
wiki trash), a typed-name confirmation, an export offered at the moment of
deletion, and a purge_trashed_campaigns Beat task that runs the cascade once
the window closes. Migration f4a5b6c7d8ea, verified up -> down -> up.

The suppression mechanism deliberately differs from #408's. A lore entry is
only ever reached by selecting one, so filtering each of its 13 reads works.
A campaign's children are routinely selected without the query mentioning
Campaign at all -- 35 select(Session) sites alone -- so "filter every read"
would mean auditing every session, note, ledger and lore query and would still
miss the next one written. The gate is instead `_reject_if_trashed` in
app/auth/dependencies.py, called by all four campaign/session authorisation
dependencies, so every campaign- and session-scoped route is covered at once
and a new route is covered by construction.

Five doors bypass that gate and filter explicitly: the unauthenticated public
analytics link, the bot's guild lookup (X-Bot-Key, not a cookie), the
invite-code join, the dashboard list, and the scheduled tasks -- which go
through campaign_service.get_live_campaign, a db.get returning None for a
trashed campaign so the `if not campaign: continue` guard all 11 task call
sites already had does the work.

Three things checked rather than assumed:

- The issue proposed routing deletion through the existing archive feature.
  That does not hold up: `is_archived` is not a read filter anywhere in the
  backend, so archiving suppresses nothing and gave soft delete nothing to
  build on. deleted_at is a separate column, and archive/delete stay distinct
  states -- collapsing them would make "archive for the season" and "destroy
  everything" the same database state.
- enforce_retention skips trashed campaigns in all three of its passes, since
  a restored campaign must come back intact. Notably NOT by dropping them from
  the policy map: get_effective_retention(db, None) falls back to instance
  defaults that can be *shorter* than the campaign's own override, so that
  version of the fix deleted their audio sooner rather than not at all.
- The issue's line numbers had drifted (endpoint 930-953 -> 967, service
  189-193 -> 190, frontend confirm() 747 -> 839). The substance held.

28 backend mutations and 3 frontend mutations, all caught. One test initially
survived: the detail-route test claimed to cover the dependency gate but is
defended twice (gate plus get_campaign filtering), so removing the gate changed
nothing. Added a member-list test, which reads campaign_members and never
selects a Campaign -- the exact shape per-read filtering misses.

Backend 1609 passed, frontend 470 passed; ruff and eslint clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign in to join this conversation.
No description provided.