v4.0.1 — retention safety and secrets at rest #435
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/v4.0.1-retention-safety"
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?
Closes the v4.0.1 milestone's code: #427, #428, #429, #430, #432.
No migration. The only model change is a one-line comment, so there is no schema change, no backfill, and no rehearsal needed — a far lower-risk deploy than v4.0.0.
⚠️ Images must be rebuilt, not pulled. #429 changes the backend
Dockerfile; a pull-and-restart would not apply it. Deploy withdocker compose up -d --build.#427 — raw audio is never deleted inline
The recommended retention mode destroyed the per-speaker WAVs during
process_audio, 27 seconds after the transcript was written in the case that prompted this. Its reasoning — "there is nothing left to approve or reprocess" — is wrong in the case that matters, because the pipeline changes: v4.0.0 rewrote transcription, extraction, validation and composition, and no recording survived to validate any of it against.The mode is now
trash_after_processing. The audio is marked trashed, the bytes stay, and the sweeper removes them once the grace elapses.audio_file_pathis deliberately kept — the sweeper needs it to find the directory and admin restore needs it to bring the session back.Two further changes were required, or the fix would have been cosmetic. Neither was in the issue; both came from reading the sweeper rather than running it:
retain_daysnever routed through the trash at all.⚠️ 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 rather than delete the only copy on a scheduled task — but it is a disk-usage change.The old mode name is accepted on read and normalised. Every install predating this has
delete_after_processinginapp_settingsand incampaigns.audio_retention_mode; renaming without an alias would have silently dropped them to the default.#432 — a silent speaker reported as having spoken
merge_uncaptured_membersadded a backend-dropped track's owner to the uncaptured list correctly, and it made no difference: the same owner was still inspeakers, andspokeis assigned beforein_channel_silent. #425 tested that function in isolation, where it behaves — the defect lived only in the pairing.Extracted as
narrow_speakers_to_capturedbeside its counterpart, deliberately rather than inlining three lines: the missing step would otherwise live only insideprocess_audio, which no test drives end to end.#430 — a restore onto a different
SECRET_KEYReading an undecryptable row raised
InvalidTagstraight out ofget_setting, past the four "not configured" policies the module's own docstring commits to. Now it degrades toNone, logs once per key, and is reported at startup and viaGET /api/admin/settings/secrets-health— which is what lets Admin tell "never configured" from "configured but unreadable". Those render identically in every settings form, which is how a restore produced a system that looked complete and was not.The startup audit is non-fatal by design: an operator who has just restored onto a fresh host needs the app up to re-enter the settings the report is telling them about.
#428 — plaintext at rest
Encryption only happened on write, and legacy plaintext rows were passed through on read forever, so a credential never rotated since encryption was introduced was still in clear text — in the database, in every dump, in every off-host archive — with nothing reporting it. The startup audit rewrites such rows and names each one, because the operator needs to know which credential to rotate.
#429 — backups restorable by the server that made them
pg_dumpran from the backend image whilepg_restoreruns against the db image, on different major versions — so every dump was rejected withunsupported version (1.16) in file headerwhile the job recorded success. Production has eight such files.The image now pins
postgresql-client-16to matchpostgres:16-alpine, and a test asserts theDockerfileanddocker-compose.ymlstay in step. Both backup paths now compare client and server majors before writing, so a mismatch is a failed backup rather than an unrestorable one.Shipping that check without the alignment would have turned "unrestorable backups" into "no backups at all" — strictly worse — which is why they are one commit.
Verified end to end rather than by inspection: built the image, confirmed
pg_dump 16.15, dumped in custom format with it, restored withpostgres:16-alpine'spg_restore, row counts matched.Verification
1,407 backend tests (from 1,362), 440 frontend, ruff clean at CI's pinned 0.4.4.
Every new guard was reverted individually and its test confirmed to fail — the sweeper one reports "grace period was not honoured", and both decryption tests fail with the exception escaping exactly as production did. Two existing retention tests were rewritten to assert the two-stage trash-then-delete explicitly, which is stronger than the single assertion they replaced.
Still needs a human before this milestone is done
Knock-on findings, recorded not fixed
wav.unlink(), which #427 deliberately did not touch — a mis-targeted erasure destroys audio with no grace while every other route now has one. Options laid out on that issue.🤖 Generated with Claude Code
The backup ran pg_dump from the backend image; a restore runs pg_restore against the db image. Debian's postgresql-client metapackage tracks the distro (trixie ships 17) while the db service is pinned to postgres:16-alpine, so every dump was written in an archive format the server's own pg_restore refuses outright — "unsupported version (1.16) in file header", reading nothing at all. The job exited zero, backup_logs recorded success, and the files were the right size. Production has eight such dumps. Two changes, and the second is only safe because of the first. Align the versions. The image now installs postgresql-client-${PG_MAJOR} with PG_MAJOR=16, matching docker-compose's postgres:16-alpine. Debian 13 carries only 17, so this needs the PGDG repo; gnupg is purged afterwards so the build dependency does not persist into the runtime image. A test parses ARG PG_MAJOR out of the Dockerfile and the image tag out of docker-compose.yml and asserts they agree — they live in different files and nothing else connected them, which is how they drifted apart. Refuse an unrestorable backup. Both the scheduled task and the manual CLI path compare pg_dump's major against the server's server_version_num before writing anything, so a mismatch is a failed backup with an explanation rather than a success recording a file nobody can read. Shipping that check without the alignment above would have turned "unrestorable backups" into "no backups", which is worse; they go together. A client newer than the server is the dangerous direction and raises. An older client produces an archive the newer server reads fine, so it is allowed and merely noted. Unknown versions proceed with a warning: refusing to back up because a version string could not be parsed would be a worse failure than the one being guarded against. Verified end to end rather than by inspection. Built the image, confirmed pg_dump 16.15, dumped a database in custom format with it, and restored that dump with postgres:16-alpine's pg_restore — the exact operation that used to fail. Row counts matched. ffmpeg and libmagic still work in the built image. docs/OPERATIONS.md now states the version requirement, how to check it, and that a failing backup with a mismatch message means the two have drifted. 1,407 backend tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>