Repository navigation
Conversation
…d is dead Two groups with the same members, or two 1:1 threads with one person, are both live on the phone under their own wire ids. The #176 roster re-key moved one's id onto the other's row, so the id and title flipped on every event and both threads' messages piled into one row. A thread now takes a new wire id only when its own id is dead: displaced by a collision, or announced by a ConversationEvent only in an earlier device ID space. - Migration 0011 adds accounts.remote_idspace_epoch and, per conversation, remote_announced_epoch (NULL = provisional, minted from a message) and remote_bound_epoch. Existing and externally written rows default to epoch 0. - A new epoch starts when an id the phone announced in the current epoch arrives naming another thread. Declines made in the 15 minutes before that detection are merged into the threads they continue. - Group re-keys also require the title to match. - A provisional thread that hears from a second sender becomes a group instead of being scattered across its members' 1:1 threads. - Google reuses message ids across a reset; a continued thread's older message keeps its row under a displaced remote id instead of being overwritten. - A ConversationEvent after a message whose phone timestamp leads this clock no longer fails updated_at_ms >= created_at_ms and gets quarantined. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Migration-numbering heads-up from #210, which adds
No live store has applied any 0011 yet: Max's v2 ledger is at version 10. 🤖 Generated with Claude Code |
|
Coordination with #211: it edits
The live-inbox replay in #211 also attributes 59 of 66 replay quarantines on main to the |
|
Migration numbering: #215 (SQL plan audit) also adds a migration numbered 0011 ( Code overlap in |
|
Migration numbering: #223 (FTS5 trigram substring search, stacked on #215) adds migration 0012 |
|
Migration numbering update: #215 merged to main (29a9a8e) and took 0011 (
Two more things changed on main:
🤖 Generated with Claude Code |
Problem
The #176 roster re-key (
internal/ingest/idspace.go) treated a roster match as proof that a wire id had been re-keyed. Two groups with identical membership (5001 "Family", 5002 "Book club") therefore shared one v2 row. When 5002 first appeared it took 5001's row, then 5001's next event took it back. The wire id and title flipped on every event, both groups' messages piled into one row, andremote_rebindskept climbing. Two 1:1 threads with the same sole peer (6001 and 6002) merged the same way. The message path behaves identically: an unbound id from a known sender, or a sender contradicting a bound row, took that peer's direct thread. Reported in the 2026-10-08 probe (reader-worker-semantics.md§5). The defect reproduces onorigin/main: the six new tests inidspace_distinct_threads_test.gothat only need main's API fail there, for the reported reasons.This is common in real data. In a copy of the live v2 store, 53 Google roster sets have two or more live (non-displaced) group rows with identical rosters, and 16 peers have two or more live direct rows.
Rule
A roster match may take a new wire id only when its own id is dead (
rekeyable):A row announced in the current ID space is a live twin. It is never taken; the new id gets its own row and
rekeys_declinedincrements.ID spaces.
accounts.remote_idspace_epochcounts detected ID spaces. A new one starts when an id the phone announced in the current epoch arrives naming a different thread (noteIDSpaceContradiction). A contradiction against a row announced only earlier is the same reset still being discovered. A contradiction against a provisional row proves nothing.Late detection. A reset shows up at the first collision, and a re-paired phone can deliver fresh ids before that. On 2026-09-03, 14 threads arrived in the 36 seconds before the first collision. Those rows are revisited when the reset is detected. Each row minted that way in the previous 15 minutes (frame time) is merged into the thread it declined (
MergeConversationInto, one transaction). Messages move across, re-served duplicates are dropped, and the wire id moves with them. This is therekeys_recoveredcounter.Groups also need the event title to match, so a new group with an existing group's members never inherits that group's history.
Provisional threads. A thread minted from a message in the current ID space that hears from a second sender is a group whose snapshot hasn't arrived. It keeps the message and becomes a group (
adoptSecondSender) instead of being scattered across its members' 1:1 threads. The live inbox has such a thread, wire 3097: 21 messages and never a ConversationEvent.Message ids are device-local too. When a thread continued across a reset receives a new message under an id one of its old messages holds, the old message keeps its row under
displaced:<id>:<message_id>instead of being overwritten (retireReusedRemoteMessageID). This applies only when the stored row has a different primary key and either the opposite direction or a timestamp at least an hour away. On the replayed inbox, real reuse was 454 to 553 hours apart; one message seen twice was at most 16 s apart.Updated-at fix. A ConversationEvent arriving after a row minted from a message whose phone timestamp leads this clock no longer fails the
updated_at_ms >= created_at_mscheck and gets quarantined.refreshConversationno longer movesupdated_at_msbackwards.Schema: migration
0011_remote_idspace_epochsadds three columns.accounts.remote_idspace_epochconversations.remote_announced_epoch: defaults to 0; NULL means provisional, and only ingest's message mint writes NULL.conversations.remote_bound_epochEvery existing row, and every row written by legacy migration, mirroring or repair, starts announced and bound in epoch 0. Ingest maintains the columns through dedicated store methods, and generic upserts never touch them.
Real-data differential replay
All 8,934 Google frames in the live v2 inbox (2026-07-19 to 2026-10-08, which includes the 9/3 phone swap) were fed in inbox order through
origin/mainand through this branch. Each run started from a fresh store, using the realGoogleDecoderand worker. The harness is not committed; it and its outputs are in~/reviews/openmessage-roster-rebind-2026-10-08/replay/.remote_rebindsidspace_resetsrekeys_declined(live twins kept apart)rekeys_recovered(pre-detection splits merged back)remote_message_ids_retiredBreakdown:
updated_atCHECK failure, which drops real ConversationEvents. The other 6 are unchanged on both sides (see Follow-ups).Invariants, with property tests
idspace_properties_test.goruns a real worker and store against random phones (default 60 cases each; run withOPENMESSAGE_PROPERTY_CASES=1500locally, all passing). The generators reuse rosters on purpose so that several live threads share one. Direct peers and group members are drawn from disjoint pools, and any two group rosters are either identical or disjoint. The overlapping-but-unequal case is treated as one evolving group by design (rostersConsistent) and is outside these properties.With no reset:
With one reset followed by repeated listings from the new phone:
Tests
Worker tests (
idspace_distinct_threads_test.go, real store):Store tests (
rebind_test.go):MergeConversationInto(move, duplicate, collision and drop cases)Existing suites.
idspace_collision_test.goandrepair_idspace_test.gopass unmodified. Migration-count pins are bumped from 10 to 11, as #166 does. The fullgo test ./...andgo test -raceon ingest, sqlite and migration pass locally.Design review
Before implementation, GPT-6.1 Sol (
subfleet run --task review --tier hard) adversarially reviewed the first version of this rule and returned UNSOUND with 14 counterexample families (~/reviews/openmessage-roster-rebind-2026-10-08/design-review-full.md). Several of them showed up in the replay, and this version answers them:Known ambiguities (residual, by design)
TestGoogleIDSpaceResetRoutesIncomingBySenderNotByCollidingIDrequires this: its 4001 step cannot be told apart from a second live 1:1 using frame contents alone.openmessage repair google-idspacerebinds keep their stored epochs. The repair tool exists for resets processed by pre-fix code; with this change the live worker advances the epoch itself.Interactions
0011(0011_outbox_expiry). Whichever of the two lands second must renumber, and updatemigration0011Checksumand the count pins.fix/google-history-to-v2(no PR yet). That branch applies fetched ConversationEvents only for unbound wire ids, and those go through the same unbound roster path changed here. Before this PR, a fetched snapshot of group B could take live group A's row; with it, B gets its own row. History frames for bound ids are skipped, so they never mark a row announced. Separately, that branch'shistoryMessageExiststreats any stored message with the same remote id in the bound thread as already present. After this PR, a fetched new message whose id an old message holds across a reset would be skipped. It should applydifferentMessagebefore skipping.Follow-ups (not in this PR)
message_idalready exists in another conversation, and the primary key collides (reported in the probe, §2).IdentityKey, so the whole snapshot is quarantined.🤖 Generated with Claude Code