Skip to main content

September 7, 2026

Convex session audio purge is terminal-only

A research product records session audio apart from chat. Deleting a thread must not wipe the rolls. Admin purge is terminal-only and deletes chunks first.

Sander Korf3 min read

A research product on Convex lets clinicians run threaded sessions with chat plus mic capture. Session audio rolls WebM chunks on a time and size cadence, uploads them to Convex storage, and finalizes as its own step later. Voice capture is gated by research-audio consent, not by whether someone typed in the thread. Delete the chat and the audio still has to sit there until compliance says otherwise.

On the dashboard, researchers clear threads when a visit ends. Legal and ops use a separate admin surface when consent says the rolls can go. Those two buttons look similar. They are not the same verb.

Consent says keep rolls until the team explicitly removes them. Product delete and compliance purge are different verbs. I kept purge off thread.delete. A product click is not a records request.

Chat delete is not a storage delete

Dashboard-only internal Convex mutation sessionAudio:purge({ threadId }) is the admin path. It doesn't ride along with chat delete, and it doesn't sit on the composer. Operators trigger it when legal or ops says the rolls can go. A researcher clearing a thread is not a records request.

Finalization is its own step later for a reason. Chunks are still arriving on a time and size cadence while status is recording. Purge in that window races the upload loop. The parent has to be finalized or abandoned first. Abandoned is the other terminal state: the session died, the rolls stopped, purge is allowed. Recording is neither.

I tried wiring purge next to thread delete once during a cleanup sprint. It felt tidy: thread gone, attachments gone. Compliance pushed back immediately. The thread was product state. The WebM rolls were evidence until someone with authority said otherwise. Splitting the mutations also made audit logs honest: you can see chat delete without implying storage delete.

Terminal parent state, then blobs

purgeSessionAudioByThreadId enforces terminal parent state. The parent row must be finalized or abandoned. If status is still recording, the mutation throws. Upload loops may still be running in that window. Idempotent when no row exists: safe to retry after a partial failure.

Per chunk the order is strict. I delete the _storage blob first, then the chunk row, then the parent session-audio row. Blob first so Convex storage does not keep files with no row. Chunk rows before parent so I do not leave dangling foreign keys.

An in-flight chunk upload writes into a hole if the parent is already gone. The mutation throws instead while status is recording.

State flow:

recording ──finalize──▶ finalized ──purge──▶ (rows + blobs gone)
    │                        ▲
    └── abandon ─────────────┘
         (purge allowed on abandoned too)
 
chat delete ──▶ thread gone, audio rows untouched
dashboard purge ──▶ terminal check ──▶ chunk blobs ──▶ parent

Admin purge lives on the dashboard

Audio rows stay keyed by thread. Chat delete never cascades into storage. The mutation requires finalized or abandoned before it touches _storage. Same blob-then-row order on every retry. Empty case is a no-op so a second run after a partial failure is safe.

If legal or ops says the rolls can go, I run the dashboard mutation. If someone deletes a thread, the WebM stays.

When ops asks "why is audio still here after delete?", the answer is intentional. Purge is the second decision, after terminal state, with blobs removed in a order that does not orphan storage. That is slower than one big cascade. It matches how consent actually works in the field.


Happy coding! Sander