Skip to content

Fix sync drift: mark every Contact edit as locally edited + filter merged-away from iMessage lookup - #44

Merged
mayeack merged 1 commit into
mainfrom
claude/sync-mark-locally-edited-and-imessage-filter
Jun 2, 2026
Merged

mayeack merged 1 commit into
mainfrom
claude/sync-mark-locally-edited-and-imessage-filter

Conversation

@mayeack

@mayeack mayeack commented Jun 2, 2026

Copy link
Copy Markdown
Owner

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 flipping syncStatus to .pending. The next push filters by syncStatus != .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 with updatedAt > 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.buildHandleLookup fetched 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

  • 17 edit sites updated to call markLocallyEdited() instead of bare updatedAt = Date() / unmarked field writes:
    • ContactDetailView (hide/unhide, metVia picker, metVia backlinks bulk)
    • ContactListView (hide alert + 3 quick-add bulk pickers)
    • ContactFormView (form save)
    • AddNoteView, LogInteractionView
    • ContactTagPickerView, ContactGroupPickerView, ContactLocationPickerView
    • DashboardView (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.store was patched directly:

UPDATE ZINTERACTION SET ZCONTACT=313, ZUPDATEDAT=<now> WHERE ZCONTACT=1210;
UPDATE ZCONTACT SET ZLASTNAME='Dooner', ZUPDATEDAT=<now> WHERE Z_PK=313;
UPDATE ZCONTACT SET ZUPDATEDAT=<now> WHERE Z_PK=1197;

Server restarted (epoch preserved, no client bootstrap triggered). Clients will pull these heals within 5 min.

Post-merge action

  1. CI deploys iOS build 119 + macOS build 100119 to TestFlight (~5 min after merge).
  2. Local: rebuild + reinstall /Applications/BlackbookServer.app from main so the iMessage-lookup fix is live (BlackbookServer is not on TestFlight).
  3. Existing 643-record backlog clears as the user naturally touches each contact (deliberately not auto-flipped — a bulk macOS-wins migration would overwrite iOS edits for any field where the two devices disagree, like Kail Walker's priority).

Test plan

  • iOS Simulator (iPhone 17) clean build
  • macOS clean build
  • BlackbookServer clean build
  • 13 Swift Testing tests pass
  • Manual on build 119: edit any contact on macOS (e.g. add tag) → within 5 min the change shows on iOS
  • Manual on build 119: toggle priority via DashboardView x-button on macOS → next sync propagates
  • Manual after BlackbookServer reinstall: send a fresh iMessage to a contact who has both a live and a merged-away duplicate → only the live contact gets the interaction

🤖 Generated with Claude Code

…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>
@mayeack
mayeack merged commit 96bd64b into main Jun 2, 2026
2 checks passed
@mayeack
mayeack deleted the claude/sync-mark-locally-edited-and-imessage-filter branch June 2, 2026 17:30
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>
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant