[Backend] Add friction and a soft-delete window to campaign hard-delete #405

Closed
opened 2026-08-25 20:44:44 +00:00 by claude-bot · 1 comment
Contributor

Severity: MEDIUM

Found in the August 2026 session lifecycle review (#319).

Deleting a campaign — every session, every note, every wiki entry, every bit of recorded history the group has ever built — is one browser confirm() dialog away from any GM on the campaign (not just its creator), with an immediate, irreversible cascade delete and no soft-delete window, despite the app already having both an archive feature and an export endpoint that this destructive action bypasses entirely.

Evidence

  • webapp/backend/app/routers/campaigns.py:930-953 (delete_campaign endpoint) and webapp/backend/app/services/campaign_service.py:189-193 (db.delete(campaign) followed immediately by await db.commit()) — an unconditional cascade delete with no soft-delete/grace period.
  • webapp/frontend/src/pages/CampaignDetail.jsx:747 — the only guard is a native confirm() dialog reading "Delete {campaign name}? This cannot be undone." — no typed confirmation of the campaign name.
  • Any campaign member with the GM role can trigger this — not gated to the campaign's original creator.
  • The campaign export endpoint (campaigns.py:3505) and the existing archive mode already in the schema are both available features that this flow does not route through or even mention.

Failure scenario
A GM meaning to archive an inactive campaign for the season instead clicks "Delete" by mistake (or a co-GM does it without realising the scope), confirms the browser dialog on reflex, and every session, transcript, summary, wiki entry, and note the group has produced over months of play is gone in one request — with no undo, no grace period, and no prompt to export first.

Proposed fix
Route campaign deletion through the existing archive feature plus a delayed purge (7-30 days) rather than an immediate cascade — archive now, hard-delete later via a scheduled job, with an "undo" available in that window. Require typing the campaign's name to confirm (matching the weight of the action), and auto-offer the existing export endpoint before the delete is finalised.

Acceptance criteria

  • Deleting a campaign moves it into an archived, soft-deleted state rather than immediately cascading a hard delete.
  • A scheduled purge permanently deletes campaigns only after a configurable grace period (7-30 days) with no GM action to cancel it.
  • The delete confirmation requires typing the campaign name, not just a generic browser confirm dialog.
  • The GM is offered the existing export endpoint before finalising a delete.
  • A campaign restored within the grace period is fully intact (sessions, notes, wiki, everything).
**Severity: MEDIUM** Found in the August 2026 session lifecycle review (#319). Deleting a campaign — every session, every note, every wiki entry, every bit of recorded history the group has ever built — is one browser `confirm()` dialog away from any GM on the campaign (not just its creator), with an immediate, irreversible cascade delete and no soft-delete window, despite the app already having both an archive feature and an export endpoint that this destructive action bypasses entirely. **Evidence** - `webapp/backend/app/routers/campaigns.py:930-953` (`delete_campaign` endpoint) and `webapp/backend/app/services/campaign_service.py:189-193` (`db.delete(campaign)` followed immediately by `await db.commit()`) — an unconditional cascade delete with no soft-delete/grace period. - `webapp/frontend/src/pages/CampaignDetail.jsx:747` — the only guard is a native `confirm()` dialog reading "Delete {campaign name}? This cannot be undone." — no typed confirmation of the campaign name. - Any campaign member with the GM role can trigger this — not gated to the campaign's original creator. - The campaign export endpoint (`campaigns.py:3505`) and the existing archive mode already in the schema are both available features that this flow does not route through or even mention. **Failure scenario** A GM meaning to archive an inactive campaign for the season instead clicks "Delete" by mistake (or a co-GM does it without realising the scope), confirms the browser dialog on reflex, and every session, transcript, summary, wiki entry, and note the group has produced over months of play is gone in one request — with no undo, no grace period, and no prompt to export first. **Proposed fix** Route campaign deletion through the existing archive feature plus a delayed purge (7-30 days) rather than an immediate cascade — archive now, hard-delete later via a scheduled job, with an "undo" available in that window. Require typing the campaign's name to confirm (matching the weight of the action), and auto-offer the existing export endpoint before the delete is finalised. **Acceptance criteria** - [ ] Deleting a campaign moves it into an archived, soft-deleted state rather than immediately cascading a hard delete. - [ ] A scheduled purge permanently deletes campaigns only after a configurable grace period (7-30 days) with no GM action to cancel it. - [ ] The delete confirmation requires typing the campaign name, not just a generic browser confirm dialog. - [ ] The GM is offered the existing export endpoint before finalising a delete. - [ ] A campaign restored within the grace period is fully intact (sessions, notes, wiki, everything).
Author
Contributor

Implemented in PR #478 (awaiting CI).

One correction to this issue worth recording, because it changed the design. The proposed fix was to "route campaign deletion through the existing archive feature plus a delayed purge". That does not work: is_archived is not a read filter anywhere in the backend — it appears in the model, the schemas, archive_campaign/restore_campaign, and one guard on join-by-invite, and nothing else. Archiving a campaign today does not stop its reminders, its beats, or its bot surface, 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.

The other design point is why this does not reuse #408's convention. 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, across 17 CASCADE'd tables — 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 at the four authorisation dependencies every campaign- and session-scoped route already passes through, plus explicit filters on the five doors that do not open with a session cookie (public analytics link, bot guild lookup, invite join, dashboard list, scheduled tasks).

Against the acceptance criteria:

  • Deletion moves the campaign to a soft-deleted state rather than cascading.
  • purge_trashed_campaigns permanently deletes after a configurable grace, floored at 7 days and defaulting to 30.
  • The confirmation requires typing the campaign name — checked server-side, not only in the browser.
  • The export is offered inside the delete dialog, at the moment it is relevant.
  • A restored campaign is fully intact; enforce_retention is exempted in all three of its passes so nothing erodes underneath it while it waits.

Line numbers in the evidence had drifted (endpoint 930-953 → 967, service 189-193 → 190, frontend confirm() 747 → 839); the substance held.

Implemented in PR #478 (awaiting CI). One correction to this issue worth recording, because it changed the design. The proposed fix was to "route campaign deletion through the existing archive feature plus a delayed purge". That does not work: **`is_archived` is not a read filter anywhere in the backend** — it appears in the model, the schemas, `archive_campaign`/`restore_campaign`, and one guard on join-by-invite, and nothing else. Archiving a campaign today does not stop its reminders, its beats, or its bot surface, 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. The other design point is why this does not reuse #408's convention. 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, across 17 CASCADE'd tables — 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 at the four authorisation dependencies every campaign- and session-scoped route already passes through, plus explicit filters on the five doors that do not open with a session cookie (public analytics link, bot guild lookup, invite join, dashboard list, scheduled tasks). Against the acceptance criteria: - [x] Deletion moves the campaign to a soft-deleted state rather than cascading. - [x] `purge_trashed_campaigns` permanently deletes after a configurable grace, floored at 7 days and defaulting to 30. - [x] The confirmation requires typing the campaign name — checked server-side, not only in the browser. - [x] The export is offered inside the delete dialog, at the moment it is relevant. - [x] A restored campaign is fully intact; `enforce_retention` is exempted in all three of its passes so nothing erodes underneath it while it waits. Line numbers in the evidence had drifted (endpoint 930-953 → 967, service 189-193 → 190, frontend `confirm()` 747 → 839); the substance held.
Sign in to join this conversation.
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
rbrooks/Quest-Board#405
No description provided.