[Backend] Audio must never be deleted inline — every path goes through the trash with a 7-day grace #427
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: CRITICAL. Found by investigating why a real session's audio was gone two days after recording. This deliberately changes the spec — Ryan's call, 2026-08-28.
What happened
The Shifting Caverns, 2026-08-26:
process_audio: audio deleted after processing—shutil.rmtree, goneretain_days/ 21The retention change came 67 minutes too late, and the approval was irrelevant — the audio had already been destroyed during
process_audio, an hour before the GM touched anything.Confirmed unrecoverable: no WAVs anywhere on the host, volume dir mtime
04:08:41.324885769matching the worker log to the millisecond, no deleted-but-open handles, no filesystem snapshots. The nightly borg archive that morning ran 03:45:34 → 04:15:39 — straddling the deletion — and recorded the volume directory empty, having walked that path after the delete. It missed the only copy by minutes.The defect
_delete_audio_after_processing(app/tasks/reminder_tasks.py) does exactly what its docstring says:So the trash mechanism —
sessions.audio_trashed_atplusaudio_trash_retention_days(default 7) — applies only toretain_daysandretain_indefinitely. Underdelete_after_processingit is bypassed entirely.audio_trashed_atis NULL on every row in the production database, because that path has never trashed anything.The reasoning ("there is nothing left to approve or reprocess") is wrong in the case that matters. There is always something to reprocess: the pipeline changes. This milestone alone rewrote transcription, extraction, validation and composition, and the single most useful validation available — re-running a real recording through the new code — was impossible because every recording had already been destroyed at the moment its first transcript was written.
Proposed fix — the spec change
No code path deletes raw audio inline. Ever. Deletion happens only after a minimum grace period, in every retention mode:
delete_after_processingstops meaning delete now. It means trash on success — setaudio_trashed_at, leave the files, let the sweeper remove them once the grace has elapsed.audio_trash_retention_daysbelow 7 is clamped up, so a misconfiguration cannot reintroduce immediate loss.approvedon that path; it goes to whatever state means "processed, audio in trash, recoverable".admin.pyalready clearsaudio_trashed_at) must work for these sessions too.Worth deciding as part of this: whether
delete_after_processingshould be renamed, since it will no longer describe what it does.trash_after_processingis honest.Acceptance criteria
process_audio; the inlineshutil.rmtreeis goneaudio_trashed_atdelete_after_processingmode is restorable through the existing admin flowprocess_audiocompletes in every mode — it must fail against the current implementationNote
v4.1.0 - Data Durability & Recoveryalready lists "retention safety" in its description, and this belongs to that theme. It is in v4.0.1 because v4.0.0 has shipped a pipeline whose output nobody can re-verify against real audio, and every session recorded between now and the fix is another one lost.Implemented in
d4881b9onfix/v4.0.1-retention-safety.What changed
apply_post_processing_audio_retentionno longer deletes. It marks the sessiontrashed, stampsaudio_trashed_at, and keepsaudio_file_path— the sweeper needs it to find the directory and the admin restore flow needs it to bring the session back.The mode is renamed to
trash_after_processing, withdelete_after_processingaccepted on read and normalised throughnormalise_audio_retention_mode. Every install predating this has the old value written intoapp_settingsand intocampaigns.audio_retention_mode, so a rename without an alias would have silently dropped those installs back to the default. No data migration needed.Two things that would have made this cosmetic
Neither was in the issue, and both were found by reading the sweeper rather than by running anything.
Sweeper pass 2 would have deleted the audio anyway. It removes any session whose retention window has elapsed, and its only guard was
if not session.audio_file_path. A trashed session keeps its path — and trash-on-success stamps the trash at the exact moment the transcript is written — so the very next Beat tick would have deleted it, grace unobserved. Pass 2 now skipstrashedsessions; they belong to pass 1 and its grace.That race was already reachable without this change: a GM who trashed an older session by hand, whose retention window had passed, lost it on the next tick rather than after seven days.
An expiring window still deleted directly. The criterion "every mode routes through the trash" was not satisfied by
retain_days, which deleted straight from pass 2. It now trashes instead, so deletion is always reached through the same recoverable window.⚠️ This changes effective retention: audio under
retain_daysnow lives for the configured window plus the trash grace. That is the intent — an expiring window should give the GM a last chance to notice rather than delete the only copy on a scheduled task — but it is a disk-usage change worth knowing about, and it is called out in the CHANGELOG.Verification
The sweeper test was run against the code with the pass-2 guard reverted and fails there with "grace period was not honoured". The trash-on-success test fails against the old implementation on its first assert, since
shutil.rmtreehad already removed the directory.1,378 backend tests and 440 frontend tests pass.
Acceptance criteria
process_audio; the inlineshutil.rmtreeis goneaudio_trashed_atdelete_after_processingmode is restorable through the existing admin flow — it reaches the sametrashedstate the admin restore endpoint already clearsprocess_audiocompletes — and fails against the current implementationNot done here
Production is on
retain_days/21 globally with both campaigns inheriting it, and no session currently has audio on disk, so there is nothing to migrate or rescue. The exposure was mitigated by configuration; this makes it safe by construction.