eleven / messenger Public

server+bot: removing a member tombstones their purged messages (#765) (#788)

* server+bot: removing a member tombstones their purged messages (#765)

removeUser's cascade now inserts a tombstone per deleted message (same
mechanism as deleteMessage's #581) before the DELETE — devices offline for
the removal drop their local copies on the next forward catch-up, exactly
when that propagation matters most. Legacy NULL-thread rows are skipped;
threads torn down whole get none (their disappearance already clears every
client). Kept surgical around the tamper-evidence journal call — and clear
of the moderation stack's pending edits to the same file.

Bot sidecar: ThreadCtx.History over-fetches (2x, server-capped at 200) and
trims to the newest cputime         unlimited
filesize        unlimited
datasize        unlimited
stacksize       7MB
coredumpsize    0kB
addressspace    unlimited
memorylocked    unlimited
maxproc         10666
descriptors     1048576 real lines — tombstones, reactions and app
events no longer silently shrink the model's context window; tombstone rows
skip by field instead of a failed decrypt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* server: journal the purge's tombstone inserts (tamper-evidence contract)

Each purge tombstone is a real message row clients replay, so each gets a
'msg' journal row (set-based, same tx) — an unexplained row would read as
tampering to a verifier. The join on the still-present doomed rows can't
double-journal deleteMessage's older tombstones (their targets are already
gone). Contract tests updated: db.go write-site count 7→8; the purge's
pinned journal trace gains the two tombstone rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell cb694e7b7224e9c6797532389f8e3ca3d556d4ea parent 04dd5a0
4 files changed, +104 −5
  • checkpoint_test.go +5 −2
  • db.go +29 −0
  • db_test.go +52 −0
  • internal/bot/runtime.go +18 −3
diff --git a/checkpoint_test.go b/checkpoint_test.go
index 8e2cbd9..d3cc2b4 100644
--- a/checkpoint_test.go
+++ b/checkpoint_test.go
@@ -110,7 +110,10 @@ func TestJournalCoversRemoveUserPurge(t *testing.T) {
for _, r := range journalRows(t, srv.db) {
kinds = append(kinds, r.Kind)
}
- want := []string{"msg", "msg", "delete", "delete"}
+ // Two sends, their purge deletes, then the purge's tombstones (#765) —
+ // real rows clients replay, so each carries its own 'msg' journal row
+ // (an unexplained messages row would read as tampering to a verifier).
+ want := []string{"msg", "msg", "delete", "delete", "msg", "msg"}
if len(kinds) != len(want) {
t.Fatalf("journal kinds %v, want %v", kinds, want)
}
@@ -438,7 +441,7 @@ func TestCheckpointColumnsMatchPayload(t *testing.T) {
// write site, update the expected counts here.
func TestMessageWritesAreJournaled(t *testing.T) {
want := map[string]int{ // file → number of messages-table write statements
- "db.go": 7, // insertMessage, editMessage, deleteMessage (delete + tombstone insert), removeUser ×2, deliverScheduledMessage
+ "db.go": 8, // insertMessage, editMessage, deleteMessage (delete + tombstone insert), removeUser ×3 (purge tombstone insert #765 + delete ×2), deliverScheduledMessage
}
re := regexp.MustCompile(`INSERT INTO messages|UPDATE messages|DELETE FROM messages`)
files, err := filepath.Glob("*.go")
diff --git a/db.go b/db.go
index 24682c3..465673f 100644
--- a/db.go
+++ b/db.go
@@ -1600,6 +1600,35 @@ func (db *DB) removeUser(id string) error {
if err := journalMessageDeletesTx(tx, "author_hash = ?", id); err != nil {
return err
}
+ // Tombstone each doomed message (#765, same mechanism as deleteMessage's
+ // #581): members' devices that already hold these messages learn to drop
+ // them from ordinary forward catch-up — a purge is exactly when that
+ // propagation matters most. Skipped for legacy NULL-thread rows
+ // (unreachable by thread reads); the fresh tombstones have a NULL
+ // author_hash, so the cascade's DELETE below can't touch them. Threads
+ // torn down whole (above) need none — the thread's disappearance already
+ // clears every client's copy.
+ purgeNow := time.Now().Unix()
+ if _, err := tx.Exec(
+ "INSERT INTO messages(created_at, blob, thread_id, tombstone_of) SELECT ?, '', thread_id, id FROM messages WHERE author_hash = ? AND thread_id IS NOT NULL",
+ purgeNow, id,
+ ); err != nil {
+ return err
+ }
+ // Each tombstone is a real message row (clients replay it from catch-up),
+ // so each gets a journal row like any other insert — an unexplained row
+ // would read as tampering to a verifier. Set-based like the insert above:
+ // the fresh tombstones are exactly those pointing at a still-present row
+ // of the purged author (deleteMessage's older tombstones point at ids
+ // already gone, so the join can't double-journal them).
+ if _, err := tx.Exec(
+ "INSERT INTO event_journal(ts, kind, msg_id, thread_id, blob_hash) "+
+ "SELECT ?, 'msg', t.id, COALESCE(t.thread_id, ''), ? FROM messages t "+
+ "JOIN messages d ON d.id = t.tombstone_of AND d.author_hash = ?",
+ purgeNow, ciphertextHash(""), id,
+ ); err != nil {
+ return err
+ }
for _, s := range []struct {
q string
arg []any
diff --git a/db_test.go b/db_test.go
index 386b332..1ba5ed2 100644
--- a/db_test.go
+++ b/db_test.go
@@ -90,6 +90,58 @@ func TestDeleteMessage(t *testing.T) {
}
}
+// Removing a member tombstones every message the purge deletes (#765): a
+// device that was offline for the removal must still learn to drop its local
+// copies — the exact propagation #581 built for single unsends, at the moment
+// it matters most. Threads torn down whole need (and keep) no tombstones.
+func TestRemoveUserTombstonesMessages(t *testing.T) {
+ db := newTestDB(t)
+ uidA, _ := db.createUser("Alice")
+ uidB, _ := db.createUser("Bea")
+ db.setUserPublicKey(uidA, "pub-a")
+ db.setUserPublicKey(uidB, "pub-b")
+ shared, err := db.createThread("group", "n", []ThreadMemberKey{
+ {UserID: uidA, Wrapped: "wa"}, {UserID: uidB, Wrapped: "wb"},
+ })
+ if err != nil {
+ t.Fatalf("createThread: %v", err)
+ }
+ // author_hash stores the author's USER id (what hub.go passes) — the
+ // purge matches on it, despite the column's historical name.
+ m1, _ := db.insertMessage(shared, "bea-1", uidB)
+ db.insertMessage(shared, "alice-1", uidA)
+ m3, _ := db.insertMessage(shared, "bea-2", uidB)
+ // A thread only Bea was in — torn down whole on removal, no tombstones.
+ solo, err := db.createThread("self", "", []ThreadMemberKey{{UserID: uidB, Wrapped: "ws"}})
+ if err != nil {
+ t.Fatalf("createThread solo: %v", err)
+ }
+ db.insertMessage(solo, "solo-note", uidB)
+
+ if err := db.removeUser(uidB); err != nil {
+ t.Fatalf("removeUser: %v", err)
+ }
+ rows, _ := db.messagesAfter(shared, 0, 100)
+ var tombs []int64
+ for _, m := range rows {
+ if m.TombstoneOf != 0 {
+ tombs = append(tombs, m.TombstoneOf)
+ if m.Blob != "" {
+ t.Fatalf("tombstone %d carries a blob", m.ID)
+ }
+ } else if m.Blob != "alice-1" {
+ t.Fatalf("Bea's ciphertext survived the purge: %+v", m)
+ }
+ }
+ if len(tombs) != 2 || tombs[0] != m1.ID || tombs[1] != m3.ID {
+ t.Fatalf("tombstones %v, want [%d %d]", tombs, m1.ID, m3.ID)
+ }
+ // The solo thread went entirely — including any would-be tombstones.
+ if left, _ := db.messagesAfter(solo, 0, 100); len(left) != 0 {
+ t.Fatalf("solo thread rows survived teardown: %+v", left)
+ }
+}
+
// A delete must not read as fresh activity: the thread list's preview and
// recency ordering skip tombstone rows (#581) — deleting an old message
// neither blanks the preview nor bumps the thread above others.
diff --git a/internal/bot/runtime.go b/internal/bot/runtime.go
index 153bdfb..f75440e 100644
--- a/internal/bot/runtime.go
+++ b/internal/bot/runtime.go
@@ -126,16 +126,28 @@ type HistoryMsg struct {
func (tc *ThreadCtx) History(limit int) ([]HistoryMsg, error) {
var ml struct {
Messages []struct {
- ID int64 `json:"id"`
- Blob string `json:"blob"`
+ ID int64 `json:"id"`
+ Blob string `json:"blob"`
+ TombstoneOf int64 `json:"tombstone_of"`
} `json:"messages"`
}
- path := fmt.Sprintf("/api/messages?thread=%s&limit=%d", url.QueryEscape(tc.ID), limit)
+ // Over-fetch: delete tombstones (#581), reactions and app events ride the
+ // window too, and each would otherwise shrink the model's context — a
+ // thread with a burst of unsends could leave the brain near-blind (#765).
+ // The server caps limit at 200; trim back to `limit` real lines below.
+ fetch := limit * 2
+ if fetch > 200 {
+ fetch = 200
+ }
+ path := fmt.Sprintf("/api/messages?thread=%s&limit=%d", url.QueryEscape(tc.ID), fetch)
if err := tc.rt.get(path, &ml); err != nil {
return nil, err
}
var out []HistoryMsg
for _, m := range ml.Messages {
+ if m.TombstoneOf != 0 {
+ continue // an unsent message's marker, not a line anyone said
+ }
var p struct {
Type string `json:"type"`
Name string `json:"name"`
@@ -150,6 +162,9 @@ func (tc *ThreadCtx) History(limit int) ([]HistoryMsg, error) {
Mine: p.Name == tc.rt.Session.Name, ActionCount: len(p.Actions),
})
}
+ if len(out) > limit {
+ out = out[len(out)-limit:] // newest `limit` — oldest-first order kept
+ }
return out, nil
}