I ship session audio for a research product where clinicians record visits on Convex. A vendor STT job turns speech into transcript overlay rows (separate from chat messages) so admins can purge sensitive speech without deleting the whole thread. When compliance triggers purge, the dashboard removes audio blobs and overlay rows together. The purge path worked. Then a slow STT callback landed minutes later and put the transcript back. The admin had just deleted exactly what the callback reinserted.
The admin screen showed a clean thread: no overlay text, no audio attachment. The vendor dashboard still said the job succeeded. My completion handler treated success as "write text," and wrote into a table that purge had already emptied.
The trap is treating async completion as authoritative. Vendor webhooks and scheduled actions assume "done" means upsert the overlay. Purge is an intentional delete, not a missing row waiting for backfill. If completion always inserts when the lookup fails, you race the admin every time STT outlives the purge click.
A second leak came from catch-up jobs that re-scheduled rows still marked pending while an in-flight STT job already owned the work. Purge plus double schedule meant duplicate vendor calls and another path back into the overlay table.
Completion handlers respect deletion
completeTranscript and the failure path now start with a read of the overlay row by session id. No row means return immediately. Do not recreate. The admin (or an earlier terminal step) removed it on purpose.
If the row exists and status is already complete, return without patching. Late retries from the vendor must not overwrite text an editor may have fixed upstream.
const row = await ctx.db
.query('transcriptOverlays')
.withIndex('by_session', (q) => q.eq('sessionId', sessionId))
.unique();
if (!row) return;
if (row.status === 'complete') return;
await ctx.db.patch(row._id, { status: 'complete', text, vendorJobId });Purge deletes overlay rows in the same mutation pass as the audio blobs, after the parent session reaches a terminal state. Blob first, row second, same ordering I use elsewhere so storage does not orphan files.
I did not add a soft-delete tombstone column. Absence is the tombstone. That keeps purge simple and forces every completion path to ask "does this row still exist?" before patch. Upsert-on-miss would have been fewer lines and would have brought the ghost text back every time.
Catch-up skips in-flight pending
The nightly catch-up scans for stuck transcripts. It skips pending rows because pending already means a vendor job is running or queued. Only failed or stale processing rows get a new schedule. That removed the double-fire where purge cleared the overlay but catch-up immediately scheduled STT again from a stale pending flag on a sibling table.
I added an integration test that purges while a mocked STT completion is mid-flight. Completion runs after purge. The overlay table stays empty. Another test sends a duplicate complete webhook after success and asserts the text field unchanged.
Vendor STT here is ElevenLabs Scribe-class async: upload audio, poll or webhook, patch text. The overlay table pattern keeps transcript separate from chat so purge can target sensitive speech without deleting the whole thread. That separation only holds if completion respects an empty lookup.
Async vendor work needs a tombstone story even when you hard-delete rows. Here the tombstone is absence: missing row is terminal for that session's transcript. Completion is idempotent when data remains, and a no-op when the admin already chose erasure. Schedule new STT only from explicit user action after purge, not from catch-up guessing that silence means "try again."
If you wire purge for GDPR-style erasure, assume the vendor is slower than the admin click. The handler that runs when the vendor finally says "done" is the one that decides whether erased stays erased.
Happy coding! Sander