Fix sync drift: mark every Contact edit as locally edited + filter merged-away from iMessage lookup - #44
Merged
Conversation
…e lookup User reported three sync divergences between iOS and macOS for the same Contact records: 1. Hugo Dooner shows "Hugo" on iOS but "Hugo Dooner" on macOS 2. Kail Walker is priority=1 on iOS but priority=0 on macOS 3. Hugo's recent iMessages don't show on the Hugo Dooner contact detail Root cause for (1) and (2): 17 edit sites across the app wrote `contact.updatedAt = Date()` (or just mutated a syncable field) without calling `markLocallyEdited()` to flip `syncStatus` to `.pending`. The next push filters by `syncStatus != .synced`, so silently-edited records never leave the device. Server stays stale, the other device pulls the stale version. Audit found 643 macOS contacts with `updatedAt > lastSyncedAt && syncStatus = .synced` — silently-edited but never pushed. Root cause for (3): `IMessageSyncService.buildHandleLookup` fetched every Contact including merged-away ones. With a live duplicate and a merged-away version sharing a phone/email, the dict-assignment order could pick the merged-away one and attach the incoming iMessage to a hidden record. Confirmed on master store: 4 Hugo iMessages attached to merged-away Z_PK 1210, not live Z_PK 313. Changes: - 17 edit sites updated to call markLocallyEdited() (Contact mutations across ContactDetailView, ContactListView, ContactFormView, AddNoteView, LogInteractionView, the 3 picker views, DashboardView, SettingsView, plus ContactSyncService, SocialEnrichmentService, ContactMergeService). - IMessageSyncService fetches contacts with a predicate that excludes isMergedAway and isHidden before building the handle lookup. Server-side one-time SQL was also applied to the user's master store while the BlackbookServer daemon was stopped, to heal the three reported records (relinked Hugo's 4 iMessages to live Z_PK 313, restored lastName='Dooner', bumped Kail's updatedAt so macOS adopts the server's priority). Verified: iOS Simulator + macOS + BlackbookServer clean builds, 13 Swift Testing tests pass. After this PR merges and TestFlight build 119 deploys, the existing 643-record backlog will heal as users naturally touch each contact (deliberately not auto-flipped, because a bulk macOS-wins migration would overwrite iOS edits for any field where the two devices disagree — like Kail Walker's priority). BlackbookServer is local-deploy only; needs a manual rebuild + reinstall on this Mac after this PR lands so the iMessage-lookup fix takes effect there. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
4 tasks
mayeack
pushed a commit
that referenced
this pull request
Jun 2, 2026
#43/#44) The sync-apply layer — ContactSyncApply.applyRemoteContact and ModelSyncApply.applyRemoteInteraction — is the exact code behind the recent drift (#44) and launch-crash (#43) incidents and had zero unit coverage. These functions are pure (payload dict + ModelContext), so no protocol seam or network mock is needed; "remote" payloads are built with the real contactToDict/interactionToDict serializers so ISO8601 formatting matches the parser. SyncApplyTests (11): - insert path: absent record inserted + marked synced; round-trip preserves name/emails/phones/score/priority; idempotent re-apply is a UUID upsert (no dup). - conflict resolution: newer remote overwrites; NEWER+PENDING local is protected from a stale remote; NEWER-but-SYNCED local IS clobbered by an older remote (regression guard documenting exactly why edits must flip syncStatus to .pending via markLocallyEdited — the #44 root cause). - malformed payloads: missing id / missing updatedAt are ignored without throwing. - interactions: link to resolved contact; unknown contactId persists with nil contact (heals later) instead of crashing; newer pending local survives stale remote. Network-path integration (URLSession injection into LocalServerSyncService) is the remaining step noted in docs/CODE_REVIEW_2026-06-02.md finding #6. Test count 206 -> 217 XCTest (+ 13 Swift Testing), all green iOS + macOS. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
3 tasks
mayeack
added a commit
that referenced
this pull request
Jun 2, 2026
#43/#44) (#46) The sync-apply layer — ContactSyncApply.applyRemoteContact and ModelSyncApply.applyRemoteInteraction — is the exact code behind the recent drift (#44) and launch-crash (#43) incidents and had zero unit coverage. These functions are pure (payload dict + ModelContext), so no protocol seam or network mock is needed; "remote" payloads are built with the real contactToDict/interactionToDict serializers so ISO8601 formatting matches the parser. SyncApplyTests (11): - insert path: absent record inserted + marked synced; round-trip preserves name/emails/phones/score/priority; idempotent re-apply is a UUID upsert (no dup). - conflict resolution: newer remote overwrites; NEWER+PENDING local is protected from a stale remote; NEWER-but-SYNCED local IS clobbered by an older remote (regression guard documenting exactly why edits must flip syncStatus to .pending via markLocallyEdited — the #44 root cause). - malformed payloads: missing id / missing updatedAt are ignored without throwing. - interactions: link to resolved contact; unknown contactId persists with nil contact (heals later) instead of crashing; newer pending local survives stale remote. Network-path integration (URLSession injection into LocalServerSyncService) is the remaining step noted in docs/CODE_REVIEW_2026-06-02.md finding #6. Test count 206 -> 217 XCTest (+ 13 Swift Testing), all green iOS + macOS. Co-authored-by: Michael Yeack <mayeack@Michaels-Mac-mini.local> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
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.
Summary
Three sync divergences reported by the user, two root causes:
Cause 1 — silent edits (17 sites): Across the iOS/macOS app, contact mutations were writing
contact.updatedAt = Date()(or just changing a field) without flippingsyncStatusto.pending. The next push filters bysyncStatus != .synced, so silently-edited records never left the device. The server stayed stale; the other device pulled the stale version. Audit on the user's macOS store found 643 contacts withupdatedAt > lastSyncedAt && syncStatus = .synced— silently-edited but never pushed. Hugo Dooner (lastName=Dooner, priority=1 on macOS, blank/correct-priority on server) and Kail Walker (priority=0 on macOS, priority=1 on server) are two examples.Cause 2 — iMessage handle lookup picks merged-away duplicates:
IMessageSyncService.buildHandleLookupfetched every Contact including merged-away ones. With a live duplicate and a merged-away version sharing a phone or email, the dict-assignment order can pick the merged-away one and attach the incoming iMessage to a hidden record. Confirmed on the master store: 4 Hugo iMessages were attached to merged-away Z_PK 1210 instead of live Z_PK 313, so Hugo Dooner's detail showed zero.Changes
markLocallyEdited()instead of bareupdatedAt = Date()/ unmarked field writes:ContactDetailView(hide/unhide, metVia picker, metVia backlinks bulk)ContactListView(hide alert + 3 quick-add bulk pickers)ContactFormView(form save)AddNoteView,LogInteractionViewContactTagPickerView,ContactGroupPickerView,ContactLocationPickerViewDashboardView(priority chip x-button, prioritize picker pin)SettingsView(unhide, restore merged, bulk hide)ContactSyncService(system contact import update)SocialEnrichmentService(enrichment write)ContactMergeService(primary + secondary post-merge)BlackbookServer/App/IMessageSyncService.swift— fetches contacts with#Predicate<Contact> { !$0.isMergedAway && !$0.isHidden }before building the handle lookup.One-time server SQL heal (already applied)
While the BlackbookServer daemon was stopped, the user's master store at
~/Library/Application Support/Blackbook/Server/default.storewas patched directly:Server restarted (epoch preserved, no client bootstrap triggered). Clients will pull these heals within 5 min.
Post-merge action
/Applications/BlackbookServer.appfrom main so the iMessage-lookup fix is live (BlackbookServer is not on TestFlight).Test plan
🤖 Generated with Claude Code