commit 32997a08f7f55e0994bb5c4574c90132a517cb45
parent c10e1fd9bbe85fe934ed35144277394f41809e80
Author: Michael Camilleri <[email protected]>
Date: Sat, 13 Jun 2026 22:15:49 +0900
Fix nickname Decisions never syncing across a user's devices
The nickname apply path gated on `record.recordID.zoneID ==
accountZoneID`, a full zone-ID equality that includes the owner name.
The account zone is created with the `CKCurrentUserDefaultName`
placeholder owner, but a record fetched back from CloudKit carries the
concrete user-record ID as its owner. The `==` could therefore be false
on the receiving device and every synced nickname was silently dropped —
set locally, never applied anywhere else.
This was unique to nickname: block, the push decisions and name all
avoid full-zone-ID equality, which is why only nicknames failed to sync.
Match on zone name + private database scope instead, mirroring the name
case. The private-scope gate preserves the anti-relabel guarantee — a
friend can only reach us through the shared DB, so an 'account'-named
zone they share in is rejected by scope rather than by the brittle
owner-name check.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat:
2 files changed, 61 insertions(+), 4 deletions(-)
diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift
@@ -990,10 +990,19 @@ enum RecordSerializer {
return true
case nicknameDecisionKind:
// The user's private nickname for a friend, authoritative across
- // their own devices. Honored only from the account zone: friend
- // zones are writable by the other participant, who must not be
- // able to relabel people in this user's friends list.
- guard record.recordID.zoneID == accountZoneID else { return false }
+ // their own devices. Honored only from the account zone in our own
+ // private database: friend zones are writable by the other
+ // participant, who must not be able to relabel people in this
+ // user's friends list. Match on zone *name* + private scope, not a
+ // full `zoneID ==`: a record fetched back from CloudKit does not
+ // reliably carry the `CKCurrentUserDefaultName` owner placeholder
+ // `accountZoneID` is built with — its `ownerName` often comes back
+ // as the concrete user-record ID, so an `==` silently rejects every
+ // synced nickname. Scoping to the private DB keeps the anti-relabel
+ // guarantee (a friend can only reach us through the shared DB).
+ guard record.recordID.zoneID.zoneName == accountZoneID.zoneName,
+ databaseScope == 0
+ else { return false }
let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity")
req.predicate = NSPredicate(format: "authorID == %@", key)
req.fetchLimit = 1
diff --git a/Tests/Unit/RecordSerializerTests.swift b/Tests/Unit/RecordSerializerTests.swift
@@ -960,6 +960,54 @@ struct RecordSerializerTests {
#expect(friend.nickname?.isEmpty != false)
}
+ @Test("applyDecisionRecord(.nickname) applies a record fetched with a concrete owner name")
+ @MainActor func applyNicknameDecisionConcreteOwnerName() throws {
+ let persistence = makeTestPersistence()
+ let ctx = persistence.viewContext
+ let friend = try makeFriend(in: ctx, authorID: "_bob", pairedWith: "_alice")
+
+ // A Decision written with the `CKCurrentUserDefaultName` placeholder
+ // comes back from CloudKit with the concrete user-record ID as its
+ // zone owner. The apply path must still recognise it as the account
+ // zone (zone *name* + private scope), or no nickname ever syncs.
+ let fetchedZone = CKRecordZone.ID(
+ zoneName: RecordSerializer.accountZoneID.zoneName,
+ ownerName: "_alice"
+ )
+ let record = nicknameDecisionRecord(
+ subject: "_bob", nickname: "Bobby", version: 1, zone: fetchedZone
+ )
+ let wrote = RecordSerializer.applyDecisionRecord(
+ record, to: ctx, localAuthorID: "_alice", databaseScope: 0
+ )
+ #expect(wrote)
+ #expect(friend.nickname == "Bobby")
+ #expect(friend.nicknameVersion == 1)
+ }
+
+ @Test("applyDecisionRecord(.nickname) rejects an account-named zone in the shared DB")
+ @MainActor func applyNicknameDecisionRejectsSharedAccountZone() throws {
+ let persistence = makeTestPersistence()
+ let ctx = persistence.viewContext
+ let friend = try makeFriend(in: ctx, authorID: "_bob", pairedWith: "_alice")
+
+ // A friend cannot reach our private DB; spoofing the relabel means
+ // sharing a zone they named "account" into our *shared* DB. The
+ // private-scope gate must reject it even though the zone name matches.
+ let spoofZone = CKRecordZone.ID(
+ zoneName: RecordSerializer.accountZoneID.zoneName,
+ ownerName: "_bob"
+ )
+ let record = nicknameDecisionRecord(
+ subject: "_bob", nickname: "Gotcha", version: 9, zone: spoofZone
+ )
+ let wrote = RecordSerializer.applyDecisionRecord(
+ record, to: ctx, localAuthorID: "_alice", databaseScope: 1
+ )
+ #expect(!wrote)
+ #expect(friend.nickname?.isEmpty != false)
+ }
+
@Test("applyDecisionRecord(.nickname) writes no row for an unknown friend")
@MainActor func applyNicknameDecisionSkipsUnknownFriend() throws {
let persistence = makeTestPersistence()