fix(webapp): an archived campaign stops creating sessions and goes quiet #516

Merged
claude-bot merged 4 commits from fix/479-archived-campaign-outbound into main 2026-09-06 01:42:44 +00:00
Contributor

Closes #479. Phase 1 of the v4.3.0 build order (#514), shipping early as v4.2.3.

The root was session creation. materialize_session_series now 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: get_live_campaign returned None for a trashed campaign and the loop materialised into it anyway. The series is not deactivated, so un-archiving resumes with nothing to backfill.

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 logs the campaign id once at info. The line is unprompted, not Discord-facing: a vote a player 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 the one fixed. The reminder check precedes the SessionReminderSent claim and the vote check precedes the vote_reminder_sent_at stamp, so an archived campaign never burns a marker it did not use.

process_audio, generate_journal_entry and warn_before_audio_deletion are untouched and have a test each. The tests were proven to bite by making the exact wrong change the issue warns about (or campaign.is_archived inside get_live_campaign): those three go red and nothing else does.

Already-materialised future sessions: warn, not refuse. They stay and go quiet. POST /campaigns/{id}/archive now returns silenced_future_sessions on an additive subclass of CampaignResponse, the count of confirmed or in-progress sessions still ahead, and stamps it into the audit context. No UI here; #382 can render it.

No migration, no /api/bot/* change. New tests/test_archived_campaign_outbound.py (13 tests); full backend suite 2349 passed / 13 skipped; ruff clean.

🤖 Generated with Claude Code

Closes #479. Phase 1 of the v4.3.0 build order (#514), shipping early as v4.2.3. **The root was session creation.** `materialize_session_series` now 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: `get_live_campaign` returned `None` for a trashed campaign and the loop materialised into it anyway. The series is not deactivated, so un-archiving resumes with nothing to backfill. **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 logs the campaign id once at info. The line is *unprompted*, not *Discord-facing*: a vote a player 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 the one fixed. The reminder check precedes the `SessionReminderSent` claim and the vote check precedes the `vote_reminder_sent_at` stamp, so an archived campaign never burns a marker it did not use. `process_audio`, `generate_journal_entry` and `warn_before_audio_deletion` are untouched and have a test each. The tests were proven to bite by making the exact wrong change the issue warns about (`or campaign.is_archived` inside `get_live_campaign`): those three go red and nothing else does. **Already-materialised future sessions: warn, not refuse.** They stay and go quiet. `POST /campaigns/{id}/archive` now returns `silenced_future_sessions` on an additive subclass of `CampaignResponse`, the count of confirmed or in-progress sessions still ahead, and stamps it into the audit context. No UI here; #382 can render it. No migration, no `/api/bot/*` change. New `tests/test_archived_campaign_outbound.py` (13 tests); full backend suite 2349 passed / 13 skipped; ruff clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
The root of #479 is session creation, not the reminders. The hourly
materialiser selected `SessionSeries.active.is_(True)` with no campaign
check at all, so an archived campaign with a weekly game kept minting
confirmed sessions 28 days ahead, every hour — and the reminder poller
then correctly reminded a group that had stopped playing about every one
of them. Archiving could make a campaign the noisiest thing on a server.

The select now 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. Trashed shares the hole: the old `get_live_campaign` call
returned None for it and the loop carried on materialising into a
campaign queued for permanent deletion.

The series is not deactivated, so un-archiving resumes with nothing to
backfill — the window is rolling and the insert is ON CONFLICT DO
NOTHING, which makes a re-run free. The filter is applied in Python
rather than in the WHERE clause so the skip can be counted and logged;
an operator asking why a group's recurring game stopped appearing gets
an answer out of the task's own log.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Materialisation stopping fixes tomorrow's sessions; the ones a series
already created are still in the table and were still being announced.

The suppression is a separate predicate rather than a check inside
`get_live_campaign`, and that is the whole of the design. That helper is
the resolver eleven scheduled call sites share, and most of them are not
notifications — `process_audio` reads the campaign for speaker
attribution and the system-prompt context, `generate_journal_entry` for
the flag that decides whether the entry is written at all, and
`warn_before_audio_deletion` for the channel to warn in, which matters
*more* on a campaign nobody is watching because retention deletes its
audio on the same schedule as everyone else's. Folding "archived" into
the resolver would turn each of those into a silent no-op and trade an
annoyance for data loss nobody is told about.

So `campaign_accepts_outbound` is consulted at exactly five sites — the
session reminder, the at-risk warning, the three-day vote nudge, the
recap email and the end-of-session attendance post — which are the whole
of what fires with nobody in the loop. The line is *unprompted*, not
*Discord-facing*: a vote someone casts and a card a GM reveals still say
what they were asked to say.

Two placements are load-bearing. The reminder check comes before the
`SessionReminderSent` claim and the vote check before
`vote_reminder_sent_at` is stamped, so an archived campaign never burns
a marker it did not use — un-archive inside the window and the
notification is still available to send. Auto-complete still advances
the status; only the announcement stops.

Each suppression logs the campaign id once at info, because what it
replaces is silence, and "why did this group stop getting reminders"
should be answerable from the log.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The open question the issue left: what to do about sessions a series had
already materialised before the archive. Warn, not refuse. Deleting them
would destroy rows a group may have notes and attendance on, to save a
tidy-up they can do themselves; refusing would make archiving fail on
exactly the campaigns it exists for, because a group that stopped
playing mid-series is the common case, not the exception. So the
sessions stay and simply go quiet, and `POST /campaigns/{id}/archive`
now returns `silenced_future_sessions` — the count of confirmed or
in-progress sessions still in the future, which is exactly the set the
reminder poller would have announced. Recorded in the audit context too.
Additive on a superset of `CampaignResponse`, so an existing client is
unaffected; #382 can render it. No migration.

The tests are the point of the commit. Five say the outbound paths go
quiet; three say `process_audio`'s campaign lookup, the journal entry
and the audio-deletion warning still run for an archived campaign, and
those three are the ones that matter — they are what fails if anyone
"simplifies" this by teaching `get_live_campaign` about `is_archived`,
which was verified by making that exact change and watching them go red.
The reminder test runs one poll over two campaigns rather than two polls
over one, because a second poll takes the IntegrityError claim path and
rolls the test transaction back underneath the assertion.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
docs(webapp): drop a call-site count that this change made stale (#479)
Some checks failed
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 53s
CI / Docker image build (pull_request) Successful in 26s
CI / Bot/backend version sync (pull_request) Successful in 38s
CI / Backend lint (ruff) (pull_request) Successful in 46s
CI / Frontend tests, audit, and build (pull_request) Successful in 1m42s
CI / Bot tests and audit (pull_request) Successful in 1m52s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Has been cancelled
CI / Backend migration, tests, and audit (pull_request) Has been cancelled
3a43a4e3b5
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude-bot scheduled this pull request to auto merge when all checks succeed 2026-09-06 01:05:46 +00:00
rbrooks force-pushed fix/479-archived-campaign-outbound from 3a43a4e3b5
Some checks failed
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 53s
CI / Docker image build (pull_request) Successful in 26s
CI / Bot/backend version sync (pull_request) Successful in 38s
CI / Backend lint (ruff) (pull_request) Successful in 46s
CI / Frontend tests, audit, and build (pull_request) Successful in 1m42s
CI / Bot tests and audit (pull_request) Successful in 1m52s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Has been cancelled
CI / Backend migration, tests, and audit (pull_request) Has been cancelled
to b2e8afb156
Some checks failed
CI / Frontend tests, audit, and build (pull_request) Has been cancelled
CI / Backend migration, tests, and audit (pull_request) Has been cancelled
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Has been cancelled
CI / Bot tests and audit (pull_request) Has been cancelled
CI / Backend lint (ruff) (pull_request) Has been cancelled
CI / Bot/backend version sync (pull_request) Has been cancelled
CI / Docker image build (pull_request) Has been cancelled
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Has been cancelled
2026-09-06 01:12:34 +00:00
Compare
rbrooks force-pushed fix/479-archived-campaign-outbound from b2e8afb156
Some checks failed
CI / Frontend tests, audit, and build (pull_request) Has been cancelled
CI / Backend migration, tests, and audit (pull_request) Has been cancelled
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Has been cancelled
CI / Bot tests and audit (pull_request) Has been cancelled
CI / Backend lint (ruff) (pull_request) Has been cancelled
CI / Bot/backend version sync (pull_request) Has been cancelled
CI / Docker image build (pull_request) Has been cancelled
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Has been cancelled
to 0aab58675b
All checks were successful
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 1m23s
CI / Frontend tests, audit, and build (pull_request) Successful in 2m46s
CI / Bot/backend version sync (pull_request) Successful in 1m0s
CI / Backend lint (ruff) (pull_request) Successful in 1m7s
CI / Bot tests and audit (pull_request) Successful in 1m28s
CI / Docker image build (pull_request) Successful in 4m23s
CI / Backend migration, tests, and audit (pull_request) Successful in 7m26s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Successful in 22m26s
2026-09-06 01:12:55 +00:00
Compare
claude-bot deleted branch fix/479-archived-campaign-outbound 2026-09-06 01:42:44 +00:00
Sign in to join this conversation.
No description provided.