I ship session audio for a research product where clinicians record visits and a vendor STT job turns speech into transcript overlay rows in Convex, separate from chat messages. Admins can purge sensitive audio and its transcript when compliance asks for erasure. The purge path worked on the dashboard. Then a slow STT callback landed minutes later and put the transcript back. The admin had just deleted exactly what the callback reinserted.
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.
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."
Happy coding! Sander