[Backend] Archiving a campaign does not stop it creating sessions or sending Discord reminders #479
Closed
opened 2026-09-01 16:12:37 +00:00 by claude-bot
·
3 comments
No Branch/Tag specified
main
feat/1328-fill-it-in
feat/1328-fill-frontend
feat/1328-fill-backend
feat/9-foundry-push
fix/9-foundry-live
feat/9-foundry-push-frontend
renovate/celery-5.x
feat/5-foundry-connect
feat/9-foundry-push-backend
feat/9-foundry-push-module
feat/5-foundry-frontend
feat/5-foundry-backend
feat/5-foundry-module
release/v4.8.0-rc1
docs/d341-convert-draft
feat/958-update-to-version
feat/958-convert-sheet
feat/958-pathbuilder-import-frontend
fix/1360-heading-order
feat/958-pathbuilder-import-backend
feat/11-weekly-digest
feat/1341-auto-update
docs/d332-superseded
chore/1345-names-cleanup
feat/1342-mentioned-pages
feat/1328-recipes
fix/1346-pgdg-key
perf/1247-shared-recording-stream
chore/132-gitleaks-allowlist
release/v4.7.1
spike/foundry-module
release/v4.7.1-rc1
docs/decisions-d332-d337
fix/bot-certifi
chore/release-4.7.0
chore/release-4.7.0-rc4
chore/deps-4.7.0
chore/release-4.7.0-rc3
fix/1280-quote-must-say-it
fix/1257-names-row-squeeze
chore/release-4.7.0-rc2
fix/1245-pg-upgrade-count
chore/release-4.7.0-rc1
feat/1257-names-in-prep
feat/1257-be
feat/1257-fe
feat/1257-rating-fixes
design/1328-fill-it-in
feat/1257-name-splits
fix/1326-redis-503
feat/1280-fact-suggestions
feat/1280-be
feat/1280-fe
design/1280-fact-suggestions
feat/1279-infobox
feat/1279-be
feat/1279-fe
feat/1321-shelf-copies
design/1279-infobox
feat/1243-table-find
feat/1257-who-rated
feat/1243-prep-drawer
feat/1243-sheet-pickers
design/1243-browse-in-place
feat/1315-hotwords-default
feat/1242-system-filters
feat/1257-more-name-styles
feat/506-leg3-hotwords
design/1242-system-filters
fix/1302-packs-polish
feat/1241-rules-library
design/1241-rules-library
feat/1245-postgres-18
integ/4.6.2
fix/1263-map-ui-2
fix/1263-map-server
fix/1263-map-ui-1
fix/1261-editor-link-menu
fix/1229-axe-4.14-name-mismatch
renovate/axllent-mailpit-1.x
renovate/eslint-monorepo
renovate/authlib-1.x
chore/release-4.6.0
chore/release-4.6.0-rc2
fix/460-rc2-screens
fix/460-rc2-server
fix/release-prerelease-exit
chore/release-4.6.0-rc1
feat/125-campaign-templates
feat/126-calendar
feat/1181-pictures-and-files
feat/1190-event-people
integ/4.6.0
feat/1191-line-of-the-night
feat/1200-starter-packs
chore/bot-multidict-and-playwright-group
fix/1202-this-week-answered-count
fix/122-124-leftovers
fix/1178-get-routes-that-write
fix/1203-journeys-check-names
design/125-campaign-templates
feat/126-calendar-engine
design/126-in-world-calendar
fix/1189-cited-stamp-in-range
fix/1189-v4-looser-check
fix/1189-richer-beats
fix/1113-export-bind-lists
fix/1154-release-notes-by-file
chore/release-4.5.0
chore/release-4.5.0-rc12
fix/1135-wiki-markup-as-text
fix/demo-usage-cost-in-window
chore/release-4.5.0-rc11
fix/1121-sheet-consistency
fix/1121-sheet-5e-terms-and-card-hp
copy/1122-busiest-pages
fix/1121-relevant-choices
fix/1121-wiki-create-npc-save-sheet-links
fix/1029-menu-stays-on-screen
docs/prototype-transcript-bin-wording
fix/1023-editor-save-row-back-scroll
fix/1020-search-exact-first
fix/1020-table-prep
fix/1020-shell-wordmark-footer-bell
fix/1020-wiki-article-party
fix/872-admin-kept-until-phone-tables
feat/l13-milestone-level-waiting
fix/1000-1001-bell-older-setup-context
fix/999-pronouns-in-prompts
fix/l19-other-class-feats
fix/1010-transcript-window-ordinary-confirm
docs/d254-d269-decisions
fix/702-member-search
fix/680-transcript-bin
fix/993-unidentified-prompts
fix/841-redis-timeouts
fix/990-binned-not-exported
fix/830-export-prep-shelf
fix/deps-audit-2026-10-01
fix/877-title-reads
fix/798-audio-received-at
build/lane11-17-j7
build/lane11-16-levelup
build/lane11-14-seams
build/lane11-14-seed
build/lane11-15-start
build/lane11-13-old-block-out
fix/980-review-r3
build/lane11-12-block
build/lane11-8-packs
fix/demo-seed-canonical-names
build/lane11-11-hot-half
docs/l11-decisions
build/lane11-7-convert
build/lane11-10-editing
fix/npm-audit-brace-expansion
build/lane11-9-sheet-page
fix/bot-pyjwt
build/lane11-6-content
fix/lane11-rank-names
build/lane11-2b-language
build/lane11-4-js-evaluator
build/lane11-3-server
build/lane11-5a-pf2e
build/lane11-5b-5e
feat/design-admin
build/lane11-2-evaluator
build/lane11-1-sheet-pieces
build/lane11-0-drawing
fix/943-quest-objectives-fk
fix/prepsheet-shelf-flake
fix/945-leftovers
build/demo-gm-copy
fix/901-email-landings
build/lane10-8-journeys
build/demo-seed-lanes-8-10
fix/887-gm-own-confirmation
build/lane9-5-j1-admin
fix/918-audience-click
fix/useasync-overtaken
ci/939-critical-path
test/937-backend-suite-speed
chore/claude-orchestration
fix/924-xp-gm-only
fix/session-stale-poll
docs/decisions-d239-d242
fix/930-pypdf
build/lane8-journeys
build/925-quest-editor
fix/894-private-page-pictures
build/lane8-p4-drafts
build/lane8-p7-map
build/lane8-p6-handouts-rules
fix/wiki-fixture-names
build/lane10-d227-test-usage
fix/rsvp-horizon-test-tuesday
fix/media-volume-owner
build/lane8-p3-editor
fix/912-unidentified-rename
build/lane8-p5-party-quests
build/lane8-handout-takeback
build/lane10-pickers
build/lane8-p2-article
build/lane8-6-map
build/lane10-7b-email-pages
build/lane8-5-handouts
build/lane10-7a-test-the-bot-pages
build/lane8-p1-pages
build/lane8-4-quests
build/lane8-2b-feeds
build/lane8-3-parties
build/lane8-2a-links
build/lane10-5-commands
build/lane9-6-seed
build/lane10-4b-reactions
build/lane9-4d-pages
build/lane9-4c-pages
build/lane10-4a-posts
build/lane10-3-email
fix/891-web-fonts
build/lane9-4b-pages
build/lane10-6-test-the-bot
build/lane10-2-notify-out
build/lane8-1-wiki-text
build/lane9-4a-frame
design/lane10-drawings
fix/j6-budget
fix/j5-phone-flake
fix/869-test-leaks
fix/843-record-start-followups
build/lane7-all
build/lane6-all
build/lane9-b2-server
chore/changelog-fragments
docs/decisions-d218
build/lane9-a-owner-overview
fix/rule-card-level-label
fix/834-retention-semantics
fix/o3c-record-without-announcement
docs/decisions-d179
docs/decisions-d131-d170
fix/851-commit-before-response
build/lane6-j3-seed
build/lane9-b1-d67-identity
fix/841-blocking-loop
build/lane9-c-backups-audit
fix/vote-race-and-start-commit
fix/recording-announcement
fix/proposal-read-leak
fix/last-admin-guard
build/lane6-p4c-planning
build/lane6-p4b-margin
build/lane6-p4a-sheet
build/lane5-j4-seed
build/lane6-base
build/lane7-pages-talk
build/lane5-pages
build/lane7-pages-safety
build/lane7-p3-safety
build/lane7-p2-notify
build/lane6-p3-planning
build/lane6-p2-history
build/lane6-p1b-items
build/lane7-p1-threads
build/lane5-p6-faces
build/lane6-p1a-sheet
build/lane5-p5-find
build/lane5-p4a-board
build/lane5-p4b-scenes
build/lane5-p3-live
build/lane5-base
build/lane5-p2-cards
docs/lane5-decisions
build/review-r2-wide
fix/review-r1
build/lane5-p1-scenes
build/demo-seed
build/lane4-journeys-j5
build/lane4-page-review-b
build/lane4-page-review-a
build/lane4-page-read
build/lane4-journeys
build/lane4-page-sess2
build/lane4-page-sess1
build/lane4-notes
build/lane4-discord-on-approval
build/lane4-scheduling
build/lane4-versions
build/760-session-number
build/lane4-decisions
build/797-erase-no-regenerate
build/test-race-audit
build/lane3-completions
build/794-dark-only
build/796-titles-scroll
build/791-retention-anchor
build/a11y-sweep
build/shell-followups
build/retention-preview
build/753-backup-readback
docs/d91-d93
build/lane3-j1
build/ci-harness-paths-selftest
fix/useauth-test-race
build/ci-harness-paths
build/777-ai-settings-read
build/lane3-settings-c
build/lane3-settings-b
build/lane3-j6
build/lane3-sessionform
build/lane3-settings-a
build/lane3-members
build/lane3-thisweek
build/lane3-story
chore/87-align-python-3.12
v4.8.0-rc1
v4.7.1
v4.7.1-rc1
v4.7.0
v4.7.0-rc4
v4.7.0-rc3
v4.7.0-rc2
v4.7.0-rc1
v4.6.2
v4.6.2-rc4
v4.6.2-rc3
v4.6.2-rc2
v4.6.2-rc1
v4.6.1
v4.6.1-rc1
v4.6.0
v4.6.0-rc2
v4.6.0-rc1
v4.5.3
v4.5.3-rc1
v4.5.2
v4.5.2-rc1
v4.5.1
v4.5.1-rc2
v4.5.1-rc1
v4.5.0
v4.5.0-rc12
v4.5.0-rc11
v4.5.0-rc10
v4.5.0-rc9
v4.5.0-rc8
v4.5.0-rc7
v4.5.0-rc6
v4.5.0-rc5
v4.5.0-rc4
v4.5.0-rc3
v4.5.0-rc2
v4.5.0-rc1
v4.4.1
v4.4.0
v4.3.0
v4.2.3
v4.2.2
v4.2.1
v4.2.0
v4.1.2
v4.1.1
v4.1.0
v4.0.1
v4.0.0
v3.11.4
v3.11.3
v3.11.2
v3.11.1
v3.11.0
v3.10.0
v3.9.1
v3.9.0
v3.8.0
v3.7.0
v3.6.1
v3.6.0
v3.5.0
v3.4.1
v3.4.0
v3.3.0
v3.2.0
v3.1.0
v3.0.0
v2.1.0
v2.0.0
v1.1.0
v1.0.0
v0.12.0
v0.11.0
v0.10.0
v0.9.1
v0.3.3
v0.9.0
v0.8.0
v0.3.2
v0.3.1
v0.3.0
v0.7.0
v0.6.0
v0.5.0
v0.4.0
v0.2.1
v0.2.0
v0.1.1
v0.1.0
Labels
Clear labels
backend
Issue related to the backend
bot
Issue related to the bot
bug
Something is not working
duplicate
This issue or pull request already exists
enhancement
New feature
frontend
Issue related to the frontend
help wanted
Need some help
invalid
Something is wrong
question
More information is needed
recording
Issue related to the recording system
security
Security hardening / vulnerability work
wontfix
This won't be fixed
No labels
backend
bot
bug
duplicate
enhancement
frontend
help wanted
invalid
question
recording
security
wontfix
Milestone
Clear milestone
No items
Projects
Clear projects
No items
No project
Assignees
Clear assignees
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#479
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Severity: MEDIUM
Found while implementing #405, where the proposed fix was to route campaign deletion through the existing archive feature. That turned out not to work, and the reason is this issue.
What archiving does and does not do
Archiving is not a no-op, and this issue is not a claim that it is. It does exactly what its own UI text promises — "hidden from the default active dashboard list, but its history stays available here for reference and export":
webapp/frontend/src/pages/Dashboard.jsx:274-275splits active from archivedwebapp/frontend/src/pages/CampaignDetail.jsx:1400shows the archived bannerwebapp/backend/app/services/campaign_service.py:236blocks join-by-inviteWhat it does not do is suppress anything in the backend.
is_archivedappears in exactly three places server-side — the model (models/campaign.py:115), the schemas (schemas/campaign.py:250,288), andcampaign_service(the archive/restore functions plus that one join guard). There are zero references inapp/tasks/, inrouters/bot.py, or anywhere in thebot/package.So archiving is a display-level lifecycle state. Every scheduled task treats an archived campaign as fully live.
The root is session creation, not the reminders
The reminders are the symptom.
materialize_session_series(app/tasks/reminder_tasks.py, hourly Beat) selects:with no campaign check at all. An archived campaign with an active recurring series therefore keeps materialising new confirmed sessions, 28 days ahead, every hour. Those sessions are real rows, so
poll_session_remindersthen does its job on them correctly.Nothing prevents archiving a campaign that has a series or future sessions —
archive_campaignonly guards against double-archiving.Failure scenario
A GM archives a campaign at the end of a season, exactly as the feature invites them to. The group's weekly recurring series keeps generating sessions, and Discord keeps posting reminders for a campaign nobody is playing — 7-day, 24-hour and 1-hour pings, plus at-risk warnings when nobody RSVPs to a session that was never meant to exist. The GM's only recourse is to hunt down and deactivate the series, or delete the campaign outright, which is precisely the destructive action archiving exists to be an alternative to.
Proposed fix, and the scoping that matters
Two changes, in this order:
materialize_session_seriesshould skip series whose campaign is archived. This is the actual fix — no new sessions means no reminders for them.Do not implement (2) by adding the archived check to
campaign_service.get_live_campaign. That helper was added in #405 and is called at all 11 campaign-resolution sites inreminder_tasks.py, but those sites are not all outbound notifications — and suppressing the wrong ones loses data rather than noise:process_audio's campaign lookupgenerate_journal_entrywarn_before_audio_deletionThe suppression wants a separate predicate applied only to the genuinely outbound sites (session reminders, at-risk warnings, vote notifications, recap email, attendance posts), not a blanket filter on campaign resolution. That distinction is the substance of this issue; getting it wrong converts an annoyance into silent data loss.
This is deliberately not how #405 handled deletion. A trashed campaign is invisible to everyone including its GM, so a blanket filter is correct there. An archived campaign stays fully readable, so only its outbound behaviour should change.
Acceptance criteria
Also in scope: trashed campaigns keep materialising sessions
Found while implementing #405 and deliberately left out of PR #478, because it belongs with this issue —
materialize_session_seriesis the one function that needs a lifecycle check for both states, and settling them separately would mean touching it twice with two different predicates.#405 routed the scheduled tasks' campaign lookups through
campaign_service.get_live_campaign, which returnsNonefor a trashed campaign so theif not campaign: continueguard those tasks already had does the suppression. But the series materialiser does not use that lookup as a gate — it resolves the campaign only for the timezone:So a trashed campaign with an active series keeps generating sessions too, falling back to
tz_name = Nonerather than skipping.Why it was left rather than fixed in #478
It is close to harmless today, and the argument for leaving it is real enough to record rather than assume away:
So it is rows written for a campaign scheduled for deletion, not user-visible harm. The one wrinkle: a restored campaign would hold sessions that never had reminders sent, which is a quiet inconsistency rather than a bug.
What to do here
Whatever gate this issue adds for archived campaigns, apply the trashed case in the same place. Concretely, the series selection wants to exclude both — join
Campaignand filterdeleted_at IS NULLandis_archived IS false— rather than resolving the campaign afterwards purely for a timezone.Note the two states want the same treatment here, which is unusual: elsewhere in #405 they differ sharply (a trashed campaign is invisible to everyone; an archived one stays fully readable). Session creation is the one behaviour neither state should have.
Extra acceptance criterion
claude-bot referenced this issue2026-09-06 00:33:37 +00:00
Picking this up as part of v4.3.0 phase 1 (#514), shipping early as v4.2.3. Following the scoping in the body exactly: materialisation skips archived campaigns; a separate outbound-only predicate at the reminder, at-risk, vote, recap-email and attendance sites;
process_audio, journal generation and the audio-deletion warning stay live with a test each. For already-materialised future sessions the decision is warn, not refuse: archiving keeps them and reports how many will go quiet.Fixed in PR #516 (merged), shipping in v4.2.3.
The root, as the issue called it, was session creation.
materialize_session_seriesnow joins the campaign it was already fetching one row at a time for a timezone, and skips a series whose campaign is archived, or trashed, which turned out to share the hole: the oldget_live_campaignreturnedNonefor a trashed campaign and the loop materialised into it anyway. The series is not deactivated, so un-archiving resumes with nothing to backfill (rolling window plusON CONFLICT DO NOTHING).Suppression is a separate predicate,
campaign_service.campaign_accepts_outbound, applied at exactly five sites: session reminder, at-risk warning, three-day vote nudge, recap email, end-of-session attendance post, each logging the campaign id once at info. The line drawn is unprompted, not Discord-facing: a vote someone casts and a card a GM reveals still say what they were asked to say, because swallowing a button press would be a worse bug than this one. Two placements matter and are asserted on: the reminder check precedes theSessionReminderSentclaim and the vote check precedes thevote_reminder_sent_atstamp, so an archived campaign never burns a marker it did not use.process_audio,generate_journal_entryandwarn_before_audio_deletionare untouched and have a test each. To prove those tests bite, the exact wrong change this issue warns about (or campaign.is_archivedinsideget_live_campaign) was applied temporarily: those three go red and nothing else does.Decision on already-materialised future sessions: warn, not refuse. Deleting them would destroy rows a group may have notes and attendance on; refusing would make archiving fail on exactly the campaigns it exists for, since stopping mid-series is the common case. They stay and go quiet.
POST /campaigns/{id}/archivenow returnssilenced_future_sessions(additive, on a subclass ofCampaignResponse): the count of confirmed or in-progress sessions still ahead, which is precisely the set the poller would have announced. Also recorded in the audit context. No UI here; #382 can render it.No migration. No
/api/bot/*change, so noBOT_CONTRACT_VERSIONbump. Newtests/test_archived_campaign_outbound.py(13 tests); full backend suite 2349 passed / 13 skipped; ruff clean.One judgment call flagged for a second opinion:
send_vote_notification(the ping when a player actually casts a vote,routers/votes.py) is deliberately not suppressed, for the human-in-the-loop reason above. If the intent was to silence that too, it is a three-line change plus inverting one test that exists so the choice stays deliberate.