[Recording] Guard against re-submitting audio for a session that already has GM-edited content #397

Closed
opened 2026-08-25 20:44:37 +00:00 by claude-bot · 3 comments
Contributor

Severity: CRITICAL

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

Any second recording of a session silently destroys everything a GM built from the first one. When the bot POSTs a session's audio a second time — the natural "record part 2 after a bathroom break" or "co-GM records the epilogue" workflow — the backend has no idea a first recording was ever processed. It resets the session straight back to processing, re-queues transcription, and the resulting run overwrites the GM's transcript and summary edits and deletes every highlight row for the session (including ones a GM manually added or approved) before reinserting a fresh set. Worse, because the bot writes each speaker's audio to a fixed per-session path, the second recording's WAV files land directly on top of the first recording's WAV files on disk — so there is no raw audio left to recover from even if someone wanted to reprocess the original.

Evidence

  • webapp/backend/app/routers/bot.py:869-931 (bot_upload_audio) — no check of the session's current audio_processing_status, content_approved_at, or whether a transcript already exists; it accepts a new session_dir unconditionally.
  • webapp/backend/app/routers/bot.py:903 — sets session.audio_processing_status = AudioProcessingStatus.processing on every call, even when the session was already approved/ready with GM edits.
  • webapp/backend/app/tasks/reminder_tasks.py:2184-2185process_audio unconditionally overwrites session.transcript and session.summary with the new run's output.
  • webapp/backend/app/tasks/reminder_tasks.py:2235-2244 — highlights are deleted by session_id alone and reinserted with approved=False; the delete has no manual/approved-row filter, so GM-approved and manually-added highlight rows are destroyed along with the machine-generated ones.
  • bot/questboard_bot/cogs/recording.py:708 (per-user out_wav = session_dir / f"{user_id_str}.wav") and :730 (speakers.json rewrite) — both are keyed only by session ID and Discord user ID, so a second recording of the same session overwrites the first recording's files in place.
  • webapp/backend/app/tasks/reminder_tasks.py:2826-2860, 3001 (erase_member_recordings(regenerate_summaries=True)) — reaches the same process_audio re-run path for every scrubbed session, clobbering GM-edited summaries/highlights as a side effect of an unrelated privacy operation.

Failure scenario
A GM runs a 4-hour session and stops recording for a bathroom break, then starts a new /record for part 2. Or: the GM spends an hour after the session fixing transcription errors and hand-curating highlights, then a co-GM later hits "record" against the same session ID for a recap epilogue. In both cases, the second POST /api/bot/sessions/{id}/audio clobbers the first recording's transcript, summary, and highlights, and the first recording's audio is gone from disk — unrecoverable, because no versioning of transcript/summary exists anywhere (unlike lore entries, which do have LoreEntryVersion).

Proposed fix
Add a state guard to bot_upload_audio — refuse (409) a re-submission when the session already has a non-null transcript or content_approved_at set, unless the caller passes an explicit force flag (surfaced in the bot as an admin-only override, not the default record-stop flow). Separately, give process_audio's highlight reinsert an escape hatch: skip deleting rows where a GM-editable flag (e.g. edited_by_id IS NOT NULL or approved = true) is set, only replacing machine-generated, unreviewed rows. Longer term, apply the LoreEntryVersion pattern to Session.transcript/summary (a version snapshot written before every overwrite, with a restore endpoint) so that even a forced re-run is recoverable. This state guard must land before any Celery acks_late/retry work (tracked separately) — otherwise task retries after a worker crash will hit the exact same unguarded overwrite path.

Acceptance criteria

  • POST /api/bot/sessions/{id}/audio returns 409 when the target session already has a transcript or content_approved_at set, unless an explicit force flag is supplied.
  • The bot's re-record UX surfaces this as a clear message rather than silently succeeding.
  • process_audio's highlight reinsert never deletes a highlight row that has been manually edited or GM-approved.
  • erase_member_recordings(regenerate_summaries=True) no longer discards GM-edited summary/highlight content for sessions it touches (either it skips them or the same guard applies).
  • A second recording of the same session no longer overwrites the first recording's on-disk WAV files (unique per-attempt subdirectory or an explicit-force-only write path).
  • Regression test: recording a session twice with GM edits in between leaves the GM edits intact (or the second attempt is rejected).
**Severity: CRITICAL** Found in the August 2026 session lifecycle review (#319). Any second recording of a session silently destroys everything a GM built from the first one. When the bot POSTs a session's audio a second time — the natural "record part 2 after a bathroom break" or "co-GM records the epilogue" workflow — the backend has no idea a first recording was ever processed. It resets the session straight back to `processing`, re-queues transcription, and the resulting run overwrites the GM's transcript and summary edits and deletes every highlight row for the session (including ones a GM manually added or approved) before reinserting a fresh set. Worse, because the bot writes each speaker's audio to a fixed per-session path, the second recording's WAV files land directly on top of the first recording's WAV files on disk — so there is no raw audio left to recover from even if someone wanted to reprocess the original. **Evidence** - `webapp/backend/app/routers/bot.py:869-931` (`bot_upload_audio`) — no check of the session's current `audio_processing_status`, `content_approved_at`, or whether a transcript already exists; it accepts a new `session_dir` unconditionally. - `webapp/backend/app/routers/bot.py:903` — sets `session.audio_processing_status = AudioProcessingStatus.processing` on every call, even when the session was already `approved`/`ready` with GM edits. - `webapp/backend/app/tasks/reminder_tasks.py:2184-2185` — `process_audio` unconditionally overwrites `session.transcript` and `session.summary` with the new run's output. - `webapp/backend/app/tasks/reminder_tasks.py:2235-2244` — highlights are deleted by `session_id` alone and reinserted with `approved=False`; the delete has no manual/approved-row filter, so GM-approved and manually-added highlight rows are destroyed along with the machine-generated ones. - `bot/questboard_bot/cogs/recording.py:708` (per-user `out_wav = session_dir / f"{user_id_str}.wav"`) and `:730` (`speakers.json` rewrite) — both are keyed only by session ID and Discord user ID, so a second recording of the same session overwrites the first recording's files in place. - `webapp/backend/app/tasks/reminder_tasks.py:2826-2860, 3001` (`erase_member_recordings(regenerate_summaries=True)`) — reaches the same `process_audio` re-run path for every scrubbed session, clobbering GM-edited summaries/highlights as a side effect of an unrelated privacy operation. **Failure scenario** A GM runs a 4-hour session and stops recording for a bathroom break, then starts a new `/record` for part 2. Or: the GM spends an hour after the session fixing transcription errors and hand-curating highlights, then a co-GM later hits "record" against the same session ID for a recap epilogue. In both cases, the second `POST /api/bot/sessions/{id}/audio` clobbers the first recording's transcript, summary, and highlights, and the first recording's audio is gone from disk — unrecoverable, because no versioning of transcript/summary exists anywhere (unlike lore entries, which do have `LoreEntryVersion`). **Proposed fix** Add a state guard to `bot_upload_audio` — refuse (409) a re-submission when the session already has a non-null `transcript` or `content_approved_at` set, unless the caller passes an explicit force flag (surfaced in the bot as an admin-only override, not the default record-stop flow). Separately, give `process_audio`'s highlight reinsert an escape hatch: skip deleting rows where a GM-editable flag (e.g. `edited_by_id IS NOT NULL` or `approved = true`) is set, only replacing machine-generated, unreviewed rows. Longer term, apply the `LoreEntryVersion` pattern to `Session.transcript`/`summary` (a version snapshot written before every overwrite, with a restore endpoint) so that even a forced re-run is recoverable. This state guard must land before any Celery `acks_late`/retry work (tracked separately) — otherwise task retries after a worker crash will hit the exact same unguarded overwrite path. **Acceptance criteria** - [ ] `POST /api/bot/sessions/{id}/audio` returns 409 when the target session already has a transcript or `content_approved_at` set, unless an explicit force flag is supplied. - [ ] The bot's re-record UX surfaces this as a clear message rather than silently succeeding. - [ ] `process_audio`'s highlight reinsert never deletes a highlight row that has been manually edited or GM-approved. - [ ] `erase_member_recordings(regenerate_summaries=True)` no longer discards GM-edited summary/highlight content for sessions it touches (either it skips them or the same guard applies). - [ ] A second recording of the same session no longer overwrites the first recording's on-disk WAV files (unique per-attempt subdirectory or an explicit-force-only write path). - [ ] Regression test: recording a session twice with GM edits in between leaves the GM edits intact (or the second attempt is rejected).
Author
Contributor

Moved from v4.1.0 to v4.0.0.

Not because the defect changed, but because it sits directly in the path of the accuracy work. Developing the summarisation re-architecture means re-running process_audio against real sessions repeatedly to compare output — and every one of those re-runs currently destroys the GM-edited transcript, summary and curated highlights, and can overwrite the source WAVs unrecoverably.

So this needs to land before the v4.0.0 iteration loop starts in earnest — specifically before the chunking/beat work (#331–#334), where comparing successive summarisation runs against the same real session is the whole point.

Note the constraint this creates in the other direction: #398 (acks_late + stuck-state watchdog) still cannot land until this does, wherever #398 ends up. Enabling task redelivery before re-submission is guarded means a worker loss clobbers GM edits rather than recovering them.

Moved from v4.1.0 to **v4.0.0**. Not because the defect changed, but because it sits directly in the path of the accuracy work. Developing the summarisation re-architecture means re-running `process_audio` against real sessions repeatedly to compare output — and every one of those re-runs currently destroys the GM-edited transcript, summary and curated highlights, and can overwrite the source WAVs unrecoverably. So this needs to land before the v4.0.0 iteration loop starts in earnest — specifically before the chunking/beat work (#331–#334), where comparing successive summarisation runs against the same real session is the whole point. Note the constraint this creates in the other direction: **#398 (`acks_late` + stuck-state watchdog) still cannot land until this does**, wherever #398 ends up. Enabling task redelivery before re-submission is guarded means a worker loss clobbers GM edits rather than recovering them.
Author
Contributor

Verified against the acceptance criteria before closing. Not closing — one criterion is genuinely unmet, and it is the same class of data loss this issue was filed for, reached through a second entry point.

Met, with evidence

  • 409 on re-submission unless forcedapp/routers/bot.py:924-932, keyed on transcript_updated_at. Tested: tests/test_bot.py:611-661 covers both the 409 and the force=true override.
  • Bot surfaces a clear messagebot/questboard_bot/cogs/recording.py:1004-1019 catches AudioAlreadySubmittedError, names the take directory, says nothing was lost.
  • Second recording no longer overwrites the first take's WAVsrecording.py:857-880; first take keeps the bare session-id directory, later takes get a -take{N} suffix.
  • Highlight reinsert spares approved and GM-edited rowsapp/tasks/reminder_tasks.py:2344-2349, delete filtered on approved.is_(False), edited_by_id.is_(None). Code inspection only; no end-to-end test drives process_audio with a pre-existing approved highlight.

Not met

erase_member_recordings(regenerate_summaries=True) no longer discards GM-edited summary or highlight content

Highlights are safe (the filtered delete above). The summary is not.

The protection was added at the router layer, but there are four call sites:

call site guarded?
app/routers/bot.py:955 yes — the 409 above bot upload
app/routers/sessions.py:945 no retry — bypass is intended
app/routers/admin.py:224 no admin reprocess — bypass is intended
app/tasks/reminder_tasks.py:3262 no erasure regenerate — bypass is not intended

run_member_erasure calls process_audio.delay(...) directly, and process_audio unconditionally writes:

session.transcript = transcript
session.summary = summary          # reminder_tasks.py:2273-2274

with no check on content_approved_at or transcript_updated_at. So a GM who has edited a summary, then erases a different member's recordings with regenerate_summaries=True, silently loses their edit. Erasing one person's audio destroying another person's written work is exactly the shape of loss this issue exists to prevent.

No test covers it: tests/test_member_erasure.py:455 only exercises regenerate_summaries: False.

The fix is a layer, not a patch

Guarding one router leaves the invariant enforced by discipline at four call sites, which is how this reached a second entry point in the first place. The check belongs inside process_audio, with an explicit parameter — force=True / allow_overwrite=True — that each caller must pass deliberately. Retry and admin reprocess then say so out loud; erasure does not, and gets the protection by default.

That also makes the rule testable in one place rather than four.

Remaining work

  • Move the overwrite guard into process_audio, with an explicit force parameter
  • Retry (sessions.py) and admin reprocess (admin.py) pass it; erasure regenerate does not
  • Test: a GM-edited summary survives erase_member_recordings(regenerate_summaries=True) — and fails against the current code before it passes against the fix
  • Test the highlight filtered-delete end to end, since it is currently inspection-only

Found by an acceptance-criteria pass rather than by a test or an incident. Related merge-blocker from the same sweep: #425.

Verified against the acceptance criteria before closing. **Not closing — one criterion is genuinely unmet, and it is the same class of data loss this issue was filed for, reached through a second entry point.** ## Met, with evidence - **409 on re-submission unless forced** — `app/routers/bot.py:924-932`, keyed on `transcript_updated_at`. Tested: `tests/test_bot.py:611-661` covers both the 409 and the `force=true` override. - **Bot surfaces a clear message** — `bot/questboard_bot/cogs/recording.py:1004-1019` catches `AudioAlreadySubmittedError`, names the take directory, says nothing was lost. - **Second recording no longer overwrites the first take's WAVs** — `recording.py:857-880`; first take keeps the bare session-id directory, later takes get a `-take{N}` suffix. - **Highlight reinsert spares approved and GM-edited rows** — `app/tasks/reminder_tasks.py:2344-2349`, delete filtered on `approved.is_(False), edited_by_id.is_(None)`. Code inspection only; no end-to-end test drives `process_audio` with a pre-existing approved highlight. ## Not met > `erase_member_recordings(regenerate_summaries=True)` no longer discards GM-edited summary or highlight content Highlights are safe (the filtered delete above). **The summary is not.** The protection was added at the **router** layer, but there are four call sites: | call site | guarded? | | |---|---|---| | `app/routers/bot.py:955` | **yes** — the 409 above | bot upload | | `app/routers/sessions.py:945` | no | retry — bypass is intended | | `app/routers/admin.py:224` | no | admin reprocess — bypass is intended | | **`app/tasks/reminder_tasks.py:3262`** | **no** | **erasure regenerate — bypass is not intended** | `run_member_erasure` calls `process_audio.delay(...)` directly, and `process_audio` unconditionally writes: ```python session.transcript = transcript session.summary = summary # reminder_tasks.py:2273-2274 ``` with no check on `content_approved_at` or `transcript_updated_at`. So a GM who has edited a summary, then erases a *different* member's recordings with `regenerate_summaries=True`, silently loses their edit. Erasing one person's audio destroying another person's written work is exactly the shape of loss this issue exists to prevent. No test covers it: `tests/test_member_erasure.py:455` only exercises `regenerate_summaries: False`. ## The fix is a layer, not a patch Guarding one router leaves the invariant enforced by discipline at four call sites, which is how this reached a second entry point in the first place. **The check belongs inside `process_audio`**, with an explicit parameter — `force=True` / `allow_overwrite=True` — that each caller must pass deliberately. Retry and admin reprocess then say so out loud; erasure does not, and gets the protection by default. That also makes the rule testable in one place rather than four. ## Remaining work - [ ] Move the overwrite guard into `process_audio`, with an explicit force parameter - [ ] Retry (`sessions.py`) and admin reprocess (`admin.py`) pass it; erasure regenerate does not - [ ] Test: a GM-edited summary survives `erase_member_recordings(regenerate_summaries=True)` — and fails against the current code before it passes against the fix - [ ] Test the highlight filtered-delete end to end, since it is currently inspection-only Found by an acceptance-criteria pass rather than by a test or an incident. Related merge-blocker from the same sweep: #425.
Author
Contributor

The remaining criterion is fixed in 633f841.

The guard now sits with the write it protects rather than in front of one of four doors. process_audio refuses when transcript_updated_at is set unless the caller passes replace_existing, defaulting to False — so the safe behaviour is what you get by omission, and replacing is the thing that has to be spelled out:

call site passes it?
routers/bot.py data.force already 409s first; the task re-checks rather than trusting that
routers/sessions.py True retry — replacing is the point
routers/admin.py True reprocess — replacing is the point
tasks/reminder_tasks.py nothing erasure regenerate — protected by saying nothing

Guarding one router left the invariant enforced by discipline across four call sites, which is exactly how it reached a second entry point in the first place. Now the dangerous case is the one that requires an explicit argument.

The bot endpoint keeps its 409 rather than relying solely on the task: a caller deserves an error, not a task that vanishes silently.

Tests: erasure's call site queues five positional arguments and never asks to replace; and the signature keeps replace_existing defaulting to False, so a future caller cannot acquire overwrite behaviour by accident.

One criterion I did not close out: the highlight filtered-delete is still inspection-only — no end-to-end test drives process_audio with a pre-existing approved highlight. The filter itself (approved.is_(False), edited_by_id.is_(None)) is correct, but it is the kind of thing that regresses silently. Worth a test when someone is next in that code; not blocking, since the summary path was the actual data loss.

1,227 passing before, 1,233 after with #425. Closing.

The remaining criterion is fixed in `633f841`. The guard now sits with the write it protects rather than in front of one of four doors. `process_audio` refuses when `transcript_updated_at` is set unless the caller passes `replace_existing`, defaulting to **False** — so the safe behaviour is what you get by omission, and replacing is the thing that has to be spelled out: | call site | passes it? | | |---|---|---| | `routers/bot.py` | `data.force` | already 409s first; the task re-checks rather than trusting that | | `routers/sessions.py` | `True` | retry — replacing is the point | | `routers/admin.py` | `True` | reprocess — replacing is the point | | `tasks/reminder_tasks.py` | *nothing* | erasure regenerate — protected by saying nothing | Guarding one router left the invariant enforced by discipline across four call sites, which is exactly how it reached a second entry point in the first place. Now the dangerous case is the one that requires an explicit argument. The bot endpoint keeps its 409 rather than relying solely on the task: a caller deserves an error, not a task that vanishes silently. **Tests:** erasure's call site queues five positional arguments and never asks to replace; and the signature keeps `replace_existing` defaulting to False, so a future caller cannot acquire overwrite behaviour by accident. **One criterion I did not close out:** the highlight filtered-delete is still inspection-only — no end-to-end test drives `process_audio` with a pre-existing approved highlight. The filter itself (`approved.is_(False), edited_by_id.is_(None)`) is correct, but it is the kind of thing that regresses silently. Worth a test when someone is next in that code; not blocking, since the summary path was the actual data loss. 1,227 passing before, 1,233 after with #425. Closing.
rbrooks referenced this issue from a commit 2026-08-28 20:05:06 +00:00
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#397
No description provided.