Repository navigation
Merge id-space repair duplicates into the copy that stays - #229
Merged
Merged
Conversation
MaxGhenis
added a commit
that referenced
this pull request
Oct 10, 2026
Review of #229 (GPT-6.1 Sol) found four gaps. A send that hasn't finished reads its local message inside its own conversation at dispatch (messageForLease) and at carry-over (ListCarryable). The planner could delete that row as a duplicate, more often now that dedupeWithin no longer skips cursor-named rows, or move it to another thread, which the base planner did too. The planner now leaves a row an open send names where it is, and ApplyRepairPlan refuses to delete it or move it out of the send's conversation. Confirmed, rejected and canceled sends don't pin their row. dedupeInPlace only tried the oldest copy of a row's content, so when that copy was moving out, two copies pinned by two devices' cursors both stayed. It now merges into any copy already left in place. ApplyRepairPlan accepted any same-account survivor. A delete step now requires the survivor to hold the same direction, sender, occurrence time and body, so a committed delete can't drop content whoever wrote the plan. The property tests now cover what they missed: message content and open or closed sends in the store model; two devices' cursors, cursors naming no message and sends in the end-to-end generator. A cursor naming no message goes with its thread when the repair drops the emptied thread, which predates this change; the invariant now says so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis
force-pushed
the
claude/idspace-repair-fk-merge
branch
from
October 10, 2026 14:09
2f84b0d to
76e0003
Compare
MaxGhenis
added a commit
that referenced
this pull request
Oct 10, 2026
Review of #229 (GPT-6.1 Sol) found four gaps. A send that hasn't finished reads its local message inside its own conversation at dispatch (messageForLease) and at carry-over (ListCarryable). The planner could delete that row as a duplicate, more often now that dedupeWithin no longer skips cursor-named rows, or move it to another thread, which the base planner did too. The planner now leaves a row an open send names where it is, and ApplyRepairPlan refuses to delete it or move it out of the send's conversation. Confirmed, rejected and canceled sends don't pin their row. dedupeInPlace only tried the oldest copy of a row's content, so when that copy was moving out, two copies pinned by two devices' cursors both stayed. It now merges into any copy already left in place. ApplyRepairPlan accepted any same-account survivor. A delete step now requires the survivor to hold the same direction, sender, occurrence time and body, so a committed delete can't drop content whoever wrote the plan. The property tests now cover what they missed: message content and open or closed sends in the store model; two devices' cursors, cursors naming no message and sends in the end-to-end generator. A cursor naming no message goes with its thread when the repair drops the emptied thread, which predates this change; the invariant now says so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis
force-pushed
the
claude/idspace-repair-fk-merge
branch
from
October 10, 2026 14:09
76e0003 to
741ee91
Compare
MaxGhenis
changed the base branch from
claude/fk-hazards-outbox-migrations
to
main
October 10, 2026 19:21
Owner
Author
|
#218 is merged (squash Two optional findings from #218's round-2 review (APPROVE at 9945e1d) are carried here, because this PR moves the merge into
Full report: |
openmessage repair google-idspace --apply deleted each content duplicate with a plain DELETE. A reaction or read-receipt intent on the duplicate (NO ACTION foreign keys) failed the whole plan with FOREIGN KEY constraint failed, on every re-run, and reactions, snapshot fences and attachments on it (ON DELETE CASCADE) were deleted instead of moving to the surviving copy. Both reproduced by new tests before the fix. Delete steps now name SurvivorMessageID, and ApplyRepairPlan merges the duplicate into it before deleting, through mergeDuplicateMessage, which generalizes #218's mergeEchoDuplicate to a survivor in another conversation: moved reactions and the intents' outbox rows take the survivor's conversation (the dispatcher only sends an intent whose target is in its own conversation), and a read cursor follows only within one conversation, since its foreign key is composite; otherwise the step fails loudly. Move steps carry intents along too, and refuse a message a read cursor names with a clear error instead of a bare FK failure. A survivor an earlier step already merged away resolves to the copy it went into. The property tests found three more planner defects, now fixed: - A move onto a remote id the target already holds failed the plan on UNIQUE(account_id, conversation_id, remote_message_id). Same content is now deleted into that row; other content is left in place and reported. - Two misfiled threads routed into each other, each holding a copy of one message, planned deletes that named each other, so the message vanished from both. planDelete now refuses a survivor that resolves back to its row, so one copy stays. - A row left in place that restated a row staying in its thread was only deleted on a second run. It is now deleted on the first, and dedupeWithin no longer skips rows a read cursor names: the cursor follows the row to the older copy in the same thread. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of #229 (GPT-6.1 Sol) found four gaps. A send that hasn't finished reads its local message inside its own conversation at dispatch (messageForLease) and at carry-over (ListCarryable). The planner could delete that row as a duplicate, more often now that dedupeWithin no longer skips cursor-named rows, or move it to another thread, which the base planner did too. The planner now leaves a row an open send names where it is, and ApplyRepairPlan refuses to delete it or move it out of the send's conversation. Confirmed, rejected and canceled sends don't pin their row. dedupeInPlace only tried the oldest copy of a row's content, so when that copy was moving out, two copies pinned by two devices' cursors both stayed. It now merges into any copy already left in place. ApplyRepairPlan accepted any same-account survivor. A delete step now requires the survivor to hold the same direction, sender, occurrence time and body, so a committed delete can't drop content whoever wrote the plan. The property tests now cover what they missed: message content and open or closed sends in the store model; two devices' cursors, cursors naming no message and sends in the end-to-end generator. A cursor naming no message goes with its thread when the repair drops the emptied thread, which predates this change; the invariant now says so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rebased onto #218's 9945e1d, which changed the echo merge's reaction rule: when either row has a snapshot fence, the newer reaction is the larger (source_seq_ms, occurred_at_ms), and a reactor still active below the merged fence is tombstoned. mergeDuplicateMessage carries that rule (in the rebased first commit); its step list differs from #218's only in the cursor filter and the moved reactions' conversation. Both reference models now implement the rule. The store model ranks by fence and tombstones below it. The end-to-end property replays the documented merge over the plan's delete steps in order, instead of the "latest (occurred_at_ms, source_seq_ms) wins" check, which the fenced rule no longer satisfies. Each catches a missing tombstone and fenced rows ordered like deltas. The repair tests seed their own snapshot and attachment instead of #218's fixture, which changed under them, and no longer put a delta below a fence. OPENMESSAGE_PROPERTY_CASES and OPENMESSAGE_PROPERTY_SEED override the property tests' cases and seed, so a sweep over seeds needs no rebuild. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Round 2 of the review found that merging several snapshot-fenced copies into one survivor could lose an active reaction. The first merge tombstoned reactors below the merged fence and stamped the tombstone with the repair's time; a later copy's real reaction at the same fence then lost to it. With copies at fences 100 (reactor active), 200 (nobody) and 200 (reactor active), the reactor ended removed or active depending on the order of the delete steps. mergeDuplicateMessage is now mergeDuplicateRows followed by tombstoneReactionsBelowFence. The echo path still does both for its one pair. ApplyRepairPlan merges rows per delete step and tombstones each survivor once, after the last step, so the survivor ends with what the copies' snapshots say taken together: per reactor the row with the largest (source_seq_ms, occurred_at_ms), removed only if still active below the largest fence. Both reference models repeated the mistake, because they replayed the merge. The store model now defers the tombstone as the executor does. A new property checks any number of snapshot copies merged in any order against an expectation computed from the original rows, and the end-to-end property does the same, with reactions generated as Google snapshots leave them and the repair applied after they were written. Also from the review: a cursor naming no message is now seeded directly, instead of through an update that could overwrite another generated cursor. The store model varies every field of the content key, and both generators cover queued, uncertain, store_failed, confirmed, rejected and canceled sends of both kinds. The re-plan check now requires no rebind, mint or drop either. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis
force-pushed
the
claude/idspace-repair-fk-merge
branch
from
October 10, 2026 19:21
1c750c5 to
2ce363c
Compare
Round 3 of the review found the round-2 reaction loss surviving across two repairs. A copy a read cursor pins stays through the first repair, which merges the other copies and tombstones a reactor below the merged fence, stamped with the merge time. Once the cursor moves, a second repair merges the pinned copy, and the reaction its snapshot lists at that same fence lost to the first repair's tombstone. Deferring the tombstone to the end of a plan could not fix that. The cause is the stamp: no snapshot wrote that removal, the merge inferred it from a fence, so it should not outrank a row a snapshot did write. It now carries the earliest time (inferredTombstoneOccurredAtMS), which ranks it below every stored row at its fence, in this plan or a later repair. Real tombstones keep their write time and still win. Only active reactions are read back, and Google messages take no deltas, so nothing else reads the value. The echo path's tombstones take the same stamp; its active reactions and fences are unchanged, and #218's model follows by one line. Tests: the reviewer's scenario through the planner across two repairs; two-repair variants of the store test and of the order-independence property; and a second-repair stage in the end-to-end property, which frees every pinned row, repairs again, and checks each reactor's state against the original snapshots of every copy either repair merged. Wording, from this review and #218's: at equal fences a reactor active in one copy stays active unless another holds a tombstone for it written later at that fence. Without a fence the merge keeps the larger (occurred_at_ms, source_seq_ms), which matches ApplyReaction whenever frames were applied in arrival order; the comment now says so and why a tie-break by updated_at_ms would not be exact either. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment-only. The round-4 review approved fa4436c and noted three places where the prose claimed more than the code does. The inferred tombstone's stamp is the smallest value the schema allows. It ranks below any row a snapshot wrote at the same fence, since those carry their write time, and would only tie a row written in the first millisecond of 1970; "below every stored row" was too strong. The comment now also says why nothing else depends on the value: reads select active reactions, and although ApplyReaction compares occurred_at_ms, the ingest worker sends Google reactions through ReplaceEmbeddedReactions only. The delta rule matches ApplyReaction when frames were applied in arrival order and arrived in different milliseconds; two frames in one millisecond tie on both fields. A property-test comment still said an inferred tombstone carries a later time than every stored reaction, which was the bug. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment-only. "Only that writer creates fences" overlooked the merge, which copies a duplicate's fence onto a survivor that has none. Fences originate only in ReplaceEmbeddedReactions; a merge carries one over. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Owner
Author
|
Merging at head
🤖 Generated with Claude Code |
MaxGhenis
added a commit
that referenced
this pull request
Oct 11, 2026
Review round 2 found the last migrating open a non-owner could reach: `openmessage repair google-idspace` without --apply skips the daemon probe and instance lock, yet opened the store with the migrating Open. A dry run now uses OpenWithoutMigrating (only --apply, which holds the lock, migrates), and --reference reads a migrated temporary copy, so a user's older backup is never rewritten. OpenWithoutMigrating connects with mode=rw, so a store removed after the existence check is not recreated. Rebased onto #218 and #229 and audited the statements they added: - outbox_local_message_idx now leads with local_message_id, so repair's two open-send lookups (local_message_id alone) seek it as well as the per-row outbox state (local_message_id and account_id); - readCursorDeviceOutside, run on every echo merge, seeks the message's own conversation instead of scanning read_cursors (the composite FK puts any cursor naming the message there), like MessageHasReadCursor; - every merge step already seeks an index (pinned by EXPLAIN in review); - #218's rebuild fixture recreates the two new messages indexes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
11 tasks done
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Builds on #218, now merged; rebased onto
main.The hazard
openmessage repair google-idspace --applydeleted each content duplicate with a plainDELETE FROM messages. Two failures, both reproduced by tests before the fix:outbox_reactions.target_message_id) or read-receipt intent (outbox_read_receipts.last_read_message_id) still named a planned-delete duplicate, the wholeApplyRepairPlantransaction failed withFOREIGN KEY constraint failed (787). Outbox rows are never deleted, so re-running failed the same way. This reproduces end to end throughPlanGoogleIDSpaceRepair→ApplyGoogleIDSpaceRepair(TestGoogleIDSpaceRepairMergesDuplicatesIntoSurvivors).The fix
SurvivorMessageID(survivor_message_idin the report).dedupeWithinuses the older copy.planMovesuses the planned or stored copy in the target.ApplyRepairPlanmerges the duplicate into its survivor, then deletes it. The merge ismergeDuplicateMessage, Merge echo duplicates into the local row, and run migrations with foreign keys off #218'smergeEchoDuplicategeneralized to a survivor that may be in another conversation (the echo path calls it unchanged):(source_seq_ms, occurred_at_ms), and a reactor still active below the merged fence is tombstoned; without a fence, newer means a larger(occurred_at_ms, source_seq_ms). Moved rows take the survivor'sconversation_id.inferredTombstoneOccurredAtMS), not the merge time. Snapshot rows carry their write time, so it ranks below any row a snapshot wrote at that fence. If a copy merged later, in the same plan or a later repair, holds a reaction that a snapshot at that fence listed, the reaction wins. Stamped with the merge time, the tombstone outranked it and an active reaction was lost. Nothing else depends on the value: reads select active reactions only, and althoughApplyReactioncomparesoccurred_at_ms, the ingest worker sends Google reactions throughReplaceEmbeddedReactionsonly.source_seq_ms. For attachments, per ordinal, a downloaded survivor row stays; otherwise the duplicate's row replaces it.outbox.conversation_idbecomes the survivor's. The dispatcher refuses an intent whose target is outside its conversation (targetRefForLease,dispatch.go:714), so an intent left behind would retry forever asnot_dispatched.(conversation_id, last_read_message_id). Across conversations the step fails loudly; the planner never plans that case.occurred_at_msand body. A committed delete can't drop content, whoever wrote the plan.messageForLease,dispatch.go:757) and at carry-over (ListCarryable). The planner leaves such a row where it is, and the executor refuses to delete it or move it out of the send's conversation. The base planner moved these rows.Planner defects the property tests found (fixed here)
UNIQUE constraint failed: messages.account_id, messages.conversation_id, messages.remote_message_id. IDs from the two device ID spaces collide by chance. Now a row with the same content is deleted into the holder; a row with other content is left in place and reported in the group'sdetail(TestGoogleIDSpaceRepairHandlesRemoteIDTakenInTarget).planDeletenow refuses a survivor that resolves back to its own row, so one copy stays and carries everything (TestGoogleIDSpaceRepairKeepsOneCopyWhenThreadsSwap).planMovesleft in place that restated a row staying in its thread was only deleted on a second run. It is now deleted on the first, into a pre-window row or any copy already left in place.dedupeWithin.dedupeWithinno longer skips rows a read cursor names. Its survivor is always in the same conversation, so the cursor moves to the older copy.planMovesstill leaves cursor-named rows in place, since their survivor is in another conversation.planMovesused to ignore errors fromMessageHasReadCursorand the duplicate lookup, which could plan a move or delete it shouldn't. It now returns them.Invariants
Stated for every input, and checked by the property tests below:
ApplyRepairPlaneither commits exactly the rows the reference model derives, or fails and leaves every row as it was.PRAGMA foreign_key_checkis clean, and every intent's target is in the intent's own conversation.root(its old target), whereroot(m)is the survivor a delete step names form, ormitself.occurred_at_msand body; the executor enforces this for any plan. The planner's survivors are never themselves deleted. Kept rows keep their content, and only delete-step rows disappear.(source_seq_ms, occurred_at_ms), and a reactor still active below F is tombstoned at F. So a reactor a snapshot at F lists stays active, unless another copy holds a tombstone for it written later at F. The largest fence stays. Each reaction is in its message's conversation. Each root keeps one attachment row per ordinal, downloaded if any copy was.Intended exceptions.
(occurred_at_ms, source_seq_ms). Invariant 5 is stated for snapshot-fenced copies, which is what Google writes; the store model covers mixed plans step by step.dropstep's cascade). That predates this change.Tests
TestApplyRepairPlanDeleteMergesDuplicateIntoSurvivorcovers a survivor in the same or another conversation, with only cascade children or with NO ACTION references.TestGoogleIDSpaceRepairMergesDuplicatesIntoSurvivorsruns plan and apply end to end. Both failed before the fix: the FK error, and missing reactions.TestApplyRepairPlanMatchesModelProperty(testing/quick, as in Merge echo duplicates into the local row, and run migrations with foreign keys off #218). Random messages across three conversations, with random content, reactions, fences, attachments, cursors, intents and open or closed sends, and random move/delete plans that include chains, invalid survivors and refusals. Each case is compared row-for-row with an independent Go-map interpreter (repairPlanModel), and the test requires every path to be covered. On this head it passed 12,000 cases across 3 extra seeds. Twelve injected bugs were each caught: reactions keeping the old conversation, the merge or the move skipping the intent retarget, ties going to the duplicate, no chain following, a delete or a move ignoring open sends, no content check, no tombstone below the fence, fenced rows ordered like deltas, a tombstone after every merge, and an inferred tombstone stamped with the merge time.TestApplyRepairPlanReactionMergeIsOrderIndependentProperty. One message stored as two to five copies, each as a Google snapshot leaves it, merged into a survivor in a random order, by one repair or split across two. The survivor's reactions are compared with invariant 5's expectation computed from the original rows, not from a replay. On this head it passed 12,000 cases across 3 extra seeds. It fails with a tombstone after every merge, and with the merge-time stamp.TestGoogleIDSpaceRepairInvariantsProperty. Random id-space damage: re-served duplicates, misfiled rows, and remote IDs that collide across the two device ID spaces, with every kind of child row, cursors from two devices (some naming no message) and open or closed sends. Reactions are generated as snapshots leave them, and the repair is applied an hour after they were written. It runs plan, apply and re-plan, and checks invariants 2–8, with invariant 5 computed from the original rows. It then frees every pinned row (cursors gone, sends confirmed), repairs again, and checks that each message's content is on its survivor and each reactor's state still follows the original snapshots of every copy either repair merged. On this head it passed 24,000 cases across 12 seeds. It is what found the cycle and the re-plan gap. Mutations caught: no cycle guard, no in-place dedupe, no remote-ID guard, delete steps without a survivor, and no survivor resolution.-racethe pure-Go SQLite engine runs about 10× slower, so the two properties run 40 and 120 cases there, gated by a build-taggedraceDetectorEnabledconstant. The plain Go Test job runs all 200 and 400 cases.OPENMESSAGE_PROPERTY_CASESandOPENMESSAGE_PROPERTY_SEEDoverride the cases and seed, for exampleOPENMESSAGE_PROPERTY_CASES=2000 OPENMESSAGE_PROPERTY_SEED=7 go test ./internal/ingest/ -run TestGoogleIDSpaceRepairInvariantsProperty.internal/storage/sqlite,internal/ingestandinternal/messagingpass in full,cmd's repair tests pass, and the repair tests pass under-race. Merge echo duplicates into the local row, and run migrations with foreign keys off #218's echo-merge tests, including its property tests, pass against the generalized merge.Review
Round 1 (GPT-6.1 Sol, hard tier, at the first commit) asked for changes; all four findings are fixed in the second commit, each with a test that fails without its fix:
TestGoogleIDSpaceRepairMergesPinnedCopiesIntoOneAnother).The third commit follows #218's
9945e1d, which changed the reaction rule after round 1: both reference models and the fixtures now implement the fenced rule.Round 2 (same reviewer, at the third commit) confirmed the four fixes and the port, and asked for one more change, fixed in the fourth commit:
Round 3 (at the fourth commit) confirmed the one-plan fix, the echo path's equivalence and everything earlier, and found the same loss across two repairs, fixed in the fifth commit:
Round 4 (at the fifth commit) returned APPROVE: the two-repair loss is fixed, reproduced before and after, and nothing regressed from rounds 1 to 3. It ran 400 randomized fenced groups (1,417 successive pair merges) against its own original-row oracle. Its three wording nits are fixed in the sixth commit, which changes comments only:
A confirmation pass on that commit verified it changes comments only and found one new sentence imprecise ("only that writer creates fences"; the merge copies a duplicate's fence to its survivor). The seventh commit rewrites that sentence, again comments only.
Two optional findings from #218's review came with the merge code:
(occurred_at_ms, source_seq_ms), whileApplyReactionlets a later write replace an equal-time row. I kept the rule and corrected the comment.source_seq_msis the frame's receive time in milliseconds, so the rule is exact whenever frames are applied in arrival order and arrived in different milliseconds. The suggested tie-break byupdated_at_msis not exact either: with writes local, echo, local at one occurrence time, the third identical to the first, one message keeps the local write, but the local row'supdated_at_msstill shows the first write (an identical rewrite leaves the row untouched), so that tie-break picks the echo row. I ran that case. Once frames are applied out of arrival order, the stored rows don't record which write one message would have kept.docs/agent-runbook.md("Repairing history after a reset") now says what a repair delete carries over, and when a row stays in place.🤖 Generated with Claude Code