web: fold stored delete tombstones through the live delete path (#583) (#764)
A history row carrying tombstone_of (#581) now folds the identical chat.delete event the live frame produces — in chatAppFoldMessage (history open, scroll-back, reconnect/refocus catch-up) and foldAppThreadMessage (app-thread chat view). One delete path, no gap-inference. - event vector tombstone_delete pins the translation (npm test) - changelog: unsends now reach devices that were offline
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+58
−0
changelog/2026-07-12-delete-catchup.md+6 −0test/event-vectors.test.mjs+23 −0test/event-vectors/chat.json+15 −0web/static/shell.js+14 −0
diff --git a/changelog/2026-07-12-delete-catchup.md b/changelog/2026-07-12-delete-catchup.md| new file mode 100644 |
| index 0000000..5c0331b |
| --- /dev/null |
| +++ b/changelog/2026-07-12-delete-catchup.md |
| @@ -0,0 +1,6 @@ |
| +🤖 Unsending a message now reaches everyone — including devices that were |
| +offline at the time. Before, if you deleted a message while someone's app was |
| +closed, their screen could keep showing it until they left and reopened the |
| +chat. Now their device quietly drops it the moment it next catches up. As |
| +always, deleting really deletes: the message's encrypted content is gone from |
| +the server the instant you unsend it. |
diff --git a/test/event-vectors.test.mjs b/test/event-vectors.test.mjs| index e7db2b9..b966e81 100644 |
| --- a/test/event-vectors.test.mjs |
| +++ b/test/event-vectors.test.mjs |
| @@ -312,6 +312,29 @@ allVectors.chat.forEach((vector) => { |
| assert(!result.msgs.some((m) => m.sid === msgId), "deleted message should not be present"); |
| }); |
| } |
| + |
| + if (vector.name === "tombstone_delete") { |
| + test(`tombstone: ${vector.name} — stored row folds like the live delete frame`, () => { |
| + // The translation rule (shell.js chatAppFoldMessage / foldAppThreadMessage): |
| + // a history row carrying tombstone_of folds { type:"chat.delete", |
| + // msgId: tombstone_of } — byte-identical to the live frame's fold event, |
| + // so there is exactly one delete path. |
| + const row = vector.server_message; |
| + assert.strictEqual(row.blob, "", "tombstone rows carry no ciphertext"); |
| + assert(row.id > row.tombstone_of, "tombstone id is fresh — above the id it unsends"); |
| + const event = { type: "chat.delete", msgId: row.tombstone_of }; |
| + assert.deepStrictEqual(event, vector.expected_fold_event, "translation matches the pinned fold event"); |
| + |
| + const state = { msgs: [{ sid: row.tombstone_of, text: "unsent" }, { sid: row.tombstone_of + 1, text: "after" }] }; |
| + const result = testChatDeleteFold(state, event); |
| + assert(!result.msgs.some((m) => m.sid === row.tombstone_of), "target dropped from state"); |
| + assert.strictEqual(result.msgs.length, 1, "only the target dropped"); |
| + |
| + // A client that never held the message (fresh load) folds it as a no-op. |
| + const fresh = testChatDeleteFold({ msgs: [{ sid: 1, text: "x" }] }, event); |
| + assert.strictEqual(fresh.msgs.length, 1, "no-op on a client with no local copy"); |
| + }); |
| + } |
| }); |
| // Test todo vectors |
diff --git a/test/event-vectors/chat.json b/test/event-vectors/chat.json| index 8fd8274..ceda38d 100644 |
| --- a/test/event-vectors/chat.json |
| +++ b/test/event-vectors/chat.json |
| @@ -366,6 +366,21 @@ |
| "msgId": 12351 |
| } |
| }, |
| + { |
| + "name": "tombstone_delete", |
| + "description": "A stored delete tombstone (#581/#583): the server hard-deleted message 12345 and left a fresh empty row naming it in tombstone_of. History pages and reconnect catch-up must fold it to the SAME event the live delete frame produces — one delete path, no gap-inference.", |
| + "server_message": { |
| + "id": 12360, |
| + "created_at": 1751900000, |
| + "blob": "", |
| + "tombstone_of": 12345 |
| + }, |
| + "expected_fold_event": { |
| + "type": "chat.delete", |
| + "msgId": 12345 |
| + }, |
| + "note": "The tombstone's own id (12360) is deliberately fresh — above every client watermark — so forward-only after: sync always delivers it. Clients with no local copy of 12345 fold it as a no-op. Old clients that predate tombstone_of skip the row entirely (an empty blob fails decrypt)." |
| + }, |
| { |
| "name": "legacy_message_no_uid", |
| "description": "An older message (before uid was added) — both web and native fall back to matching by name.", |
diff --git a/web/static/shell.js b/web/static/shell.js| index 9e2fe18..bd56552 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -5556,6 +5556,14 @@ function chatAppFor(id) { |
| // edited flag). Shared by history open, scroll-back pages, and reconnect |
| // catch-up; foldEvent's per-message-id dedupe makes repeats no-ops. |
| async function chatAppFoldMessage(threadId, m) { |
| + // A delete tombstone (#583): the server hard-deleted this row's target and |
| + // left an empty marker so history/catch-up reads convey the delete. Fold it |
| + // through the exact event the live "delete" frame produces — same code path, |
| + // no parallel delete logic. (Nothing to decrypt; a tombstone's blob is empty.) |
| + if (m.tombstone_of) { |
| + foldHostEvent(threadId, "chat", { type: "chat.delete", msgId: m.tombstone_of }); |
| + return; |
| + } |
| try { |
| const data = await decrypt(threadKeys[threadId], m.blob); |
| if (data.type === "reaction") { |
| @@ -5674,6 +5682,12 @@ function foldGroupAppEventBubble(threadId, app, event, meta) { |
| // app's state (App view) and a bot bubble (Chat view). A plain untyped note you |
| // typed in the chat view folds as an ordinary chat message. |
| async function foldAppThreadMessage(threadId, ownerApp, m) { |
| + // Delete tombstones fold in app-threads too — a note unsent in the Chat view |
| + // must drop from a device that missed the live frame (#583). |
| + if (m.tombstone_of) { |
| + foldHostEvent(threadId, "chat", { type: "chat.delete", msgId: m.tombstone_of }); |
| + return; |
| + } |
| try { |
| const data = await decrypt(threadKeys[threadId], m.blob); |
| if (isAppType(data.type)) { |