crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

FriendController.swift (28239B)


      1 import CloudKit
      2 import CoreData
      3 import Foundation
      4 
      5 /// Sibling of `ShareController`, but for *friend* zones rather than game
      6 /// zones. A friendship is a durable, pairwise channel: one custom zone
      7 /// (`friend-<pairKey>`) carrying a zone-wide `CKShare` with the other user
      8 /// added as a `.readWrite` participant. The zone is created in the elected
      9 /// owner's private database and accepted into the other user's shared
     10 /// database; thereafter either side can write `.invite` `Ping`s into it.
     11 ///
     12 /// Bootstrap rides the *game* zone the two users already share: the owner
     13 /// enqueues a `.friend` `Ping` whose `payload` carries the friend-zone share
     14 /// URL. The other device applies it (`applyFriendPing`) and accepts the
     15 /// share without any out-of-band link.
     16 @MainActor
     17 final class FriendController {
     18     let container: CKContainer
     19     private let persistence: PersistenceController
     20     private let syncEngine: SyncEngine
     21     private let syncMonitor: SyncMonitor?
     22     private let eventLog: EventLog?
     23     private let fetchAccountDecisionRecord: (CKRecord.ID) async throws -> CKRecord
     24 
     25     init(
     26         container: CKContainer,
     27         persistence: PersistenceController,
     28         syncEngine: SyncEngine,
     29         syncMonitor: SyncMonitor? = nil,
     30         eventLog: EventLog? = nil,
     31         fetchAccountDecisionRecord: ((CKRecord.ID) async throws -> CKRecord)? = nil
     32     ) {
     33         self.container = container
     34         self.persistence = persistence
     35         self.syncEngine = syncEngine
     36         self.syncMonitor = syncMonitor
     37         self.eventLog = eventLog
     38         self.fetchAccountDecisionRecord = fetchAccountDecisionRecord
     39             ?? { try await container.privateCloudDatabase.record(for: $0) }
     40     }
     41 
     42     enum FriendError: Error {
     43         case invalidShareRecord
     44         case missingShareURL
     45         case participantNotFound
     46         case missingShareURLInPayload
     47         case friendNotFound
     48         case friendBlocked
     49         case payloadEncodingFailed
     50     }
     51 
     52     // MARK: - Owner side
     53 
     54     /// Creates the friendship if this device is the elected owner and no
     55     /// friendship for the pair exists yet. No-ops for the non-owner (it
     56     /// waits for the `.friend` Ping) and for an already-established pair.
     57     /// `viaGameID` is the shared game whose zone carries the bootstrap Ping.
     58     func establishIfOwner(
     59         localAuthorID: String,
     60         remoteAuthorID: String,
     61         localDisplayName: String?,
     62         viaGameID: UUID
     63     ) async {
     64         guard !remoteAuthorID.isEmpty,
     65               localAuthorID != remoteAuthorID,
     66               FriendZone.isOwner(localAuthorID: localAuthorID, remoteAuthorID: remoteAuthorID)
     67         else { return }
     68 
     69         let pairKey = FriendZone.pairKey(localAuthorID, remoteAuthorID)
     70         if friendExists(pairKey: pairKey) { return }
     71 
     72         syncMonitor?.recordStart("establish friendship")
     73         do {
     74             let zoneName = FriendZone.zoneName(pairKey: pairKey)
     75             let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: CKCurrentUserDefaultName)
     76 
     77             // Every field of the local friendship is deterministic or already
     78             // in hand, so any owner-side device can record it without reading
     79             // anything back from the share.
     80             let recordLocalFriendship = {
     81                 self.persistFriend(
     82                     authorID: remoteAuthorID,
     83                     pairKey: pairKey,
     84                     zoneName: zoneName,
     85                     zoneOwnerName: CKCurrentUserDefaultName,
     86                     databaseScope: 0
     87                 )
     88             }
     89 
     90             try await createZone(zoneID)
     91 
     92             // A sibling device on this same iCloud account may have already
     93             // created the zone-wide share. `FriendEntity` is local-only, so
     94             // the `friendExists` check above can't see its work and we still
     95             // reach here. If the share exists we adopt the friendship locally
     96             // *without* re-creating the share or re-enqueuing the `.friend`
     97             // Ping — the sibling already delivered it to the friend.
     98             if try await existingZoneWideShare(zoneID: zoneID) != nil {
     99                 recordLocalFriendship()
    100                 await seedOwnNameDecision(
    101                     localAuthorID: localAuthorID,
    102                     localDisplayName: localDisplayName,
    103                     zoneID: zoneID,
    104                     scope: 0
    105                 )
    106                 syncMonitor?.recordSuccess("establish friendship")
    107                 return
    108             }
    109 
    110             let share: CKShare
    111             do {
    112                 share = try await saveZoneWideShare(
    113                     zoneID: zoneID,
    114                     addingParticipant: remoteAuthorID
    115                 )
    116             } catch let error as CKError where error.code == .serverRecordChanged {
    117                 // Lost the create race to a sibling between the check above
    118                 // and this save. The share now exists; adopt it as above.
    119                 recordLocalFriendship()
    120                 await seedOwnNameDecision(
    121                     localAuthorID: localAuthorID,
    122                     localDisplayName: localDisplayName,
    123                     zoneID: zoneID,
    124                     scope: 0
    125                 )
    126                 syncMonitor?.recordSuccess("establish friendship")
    127                 return
    128             }
    129             guard let url = share.url else { throw FriendError.missingShareURL }
    130 
    131             recordLocalFriendship()
    132             await seedOwnNameDecision(
    133                 localAuthorID: localAuthorID,
    134                 localDisplayName: localDisplayName,
    135                 zoneID: zoneID,
    136                 scope: 0
    137             )
    138 
    139             let payload = FriendZone.BootstrapPayload(
    140                 friendShareURL: url.absoluteString,
    141                 pairKey: pairKey,
    142                 ownerAuthorID: localAuthorID
    143             )
    144             await syncEngine.enqueuePing(
    145                 kind: .friend,
    146                 gameID: viaGameID,
    147                 authorID: localAuthorID,
    148                 playerName: localDisplayName ?? "",
    149                 payload: payload.encodedString()
    150             )
    151             syncMonitor?.recordSuccess("establish friendship")
    152         } catch {
    153             syncMonitor?.recordError("establish friendship", error)
    154         }
    155     }
    156 
    157     /// Writes the local user's current name into a just-recorded friend zone
    158     /// as a `name` Decision, at the current (un-bumped) generation: a seed is
    159     /// "the name as of this friendship", never a rename, so it must lose to
    160     /// any real rename racing it. This is how a friend made *after* the last
    161     /// rename learns the name — the rename fan-out only reaches zones that
    162     /// existed at the time.
    163     private func seedOwnNameDecision(
    164         localAuthorID: String,
    165         localDisplayName: String?,
    166         zoneID: CKRecordZone.ID,
    167         scope: Int16
    168     ) async {
    169         let name = (localDisplayName ?? "")
    170             .trimmingCharacters(in: .whitespacesAndNewlines)
    171         guard !name.isEmpty else { return }
    172         await syncEngine.enqueueNameDecision(
    173             authorID: localAuthorID,
    174             name: name,
    175             version: NameVersionStore.current(authorID: localAuthorID),
    176             zoneID: zoneID,
    177             scope: scope
    178         )
    179     }
    180 
    181     // MARK: - Participant side
    182 
    183     /// Handles an inbound `.friend` Ping: accepts the friend-zone share and
    184     /// records the friendship. Idempotent — a duplicate Ping for an
    185     /// already-established pair is dropped. `localAuthorID`/`localDisplayName`
    186     /// let the acceptor seed its own name Decision into the just-joined zone
    187     /// so the owner learns this side's name without waiting for a rename.
    188     func applyFriendPing(
    189         _ ping: Ping,
    190         localAuthorID: String?,
    191         localDisplayName: String?
    192     ) async {
    193         guard ping.kind == .friend,
    194               let payload = FriendZone.BootstrapPayload.decode(ping.payload)
    195         else { return }
    196         guard FriendZone.canAcceptBootstrap(payload, localAuthorID: localAuthorID) else { return }
    197         if friendExists(pairKey: payload.pairKey) { return }
    198         guard let url = URL(string: payload.friendShareURL) else { return }
    199 
    200         syncMonitor?.recordStart("accept friendship")
    201         do {
    202             let metadata = try await fetchShareMetadata(url: url)
    203             try await accept(metadata)
    204             let zoneID = metadata.share.recordID.zoneID
    205             persistFriend(
    206                 authorID: payload.ownerAuthorID,
    207                 pairKey: payload.pairKey,
    208                 zoneName: zoneID.zoneName,
    209                 zoneOwnerName: zoneID.ownerName,
    210                 databaseScope: 1
    211             )
    212             if let localAuthorID, !localAuthorID.isEmpty {
    213                 await seedOwnNameDecision(
    214                     localAuthorID: localAuthorID,
    215                     localDisplayName: localDisplayName,
    216                     zoneID: zoneID,
    217                     scope: 1
    218                 )
    219             }
    220             syncMonitor?.recordSuccess("accept friendship")
    221             // `applyFriendPing` runs inside the `onPings` CKSyncEngine
    222             // delegate callback. Awaiting a call back into CKSyncEngine from
    223             // there trips its serialization guard and crashes
    224             // (CKSyncEngine.swift:293: "Cannot await a call into CKSyncEngine
    225             // from within a delegate callback"). Detach the post-accept
    226             // refresh so the delegate callback returns first; it only needs
    227             // to land eventually.
    228             // Detached (not a plain `Task {}`): a `Task {}` here inherits this
    229             // @MainActor context and can run `fetchChanges()` at one of the
    230             // delegate callback's own suspension points — before `handleEvent`
    231             // returns — so CKSyncEngine's guard still trips. A detached task
    232             // runs off this actor, after the callback unwinds. (`Task {}` was
    233             // the original, insufficient fix; the trap recurred in 2026.310.)
    234             Task.detached { [syncEngine, syncMonitor] in
    235                 await syncMonitor?.run("friendship accept fetch") {
    236                     try await syncEngine.fetchChanges()
    237                 }
    238             }
    239         } catch {
    240             syncMonitor?.recordError("accept friendship", error)
    241         }
    242     }
    243 
    244     // MARK: - Re-invite
    245 
    246     /// Writes an `.invite` Ping carrying the game's share URL into the friend
    247     /// zone. The friend must already be added as a participant on the game's
    248     /// `CKShare` (the caller does that via `ShareController` and passes the
    249     /// resulting URL in). No-ops for an unknown or blocked friend. The optional
    250     /// `gridSilhouette` is a `GridSilhouette`-encoded segment that lets the
    251     /// recipient's "Invited" row preview the puzzle's shape.
    252     func sendInvite(
    253         toFriendAuthorID friendAuthorID: String,
    254         gameID: UUID,
    255         gameTitle: String,
    256         inviterAuthorID: String,
    257         inviterName: String,
    258         gameShareURL: URL,
    259         gridSilhouette: String? = nil,
    260         puzzleSource: String? = nil
    261     ) async throws {
    262         let ctx = persistence.viewContext
    263         let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity")
    264         req.predicate = NSPredicate(format: "authorID == %@", friendAuthorID)
    265         req.fetchLimit = 1
    266         guard let friend = try ctx.fetch(req).first else {
    267             throw FriendError.friendNotFound
    268         }
    269         guard !friend.isBlocked else { throw FriendError.friendBlocked }
    270         guard let zoneName = friend.friendZoneName,
    271               let ownerName = friend.friendZoneOwnerName
    272         else { throw FriendError.friendNotFound }
    273 
    274         let payload = FriendZone.InvitePayload(
    275             gameShareURL: gameShareURL.absoluteString,
    276             gridSilhouette: gridSilhouette,
    277             puzzleSource: puzzleSource
    278         )
    279         guard let encoded = payload.encodedString() else {
    280             throw FriendError.payloadEncodingFailed
    281         }
    282 
    283         await syncEngine.enqueueFriendZonePing(
    284             kind: .invite,
    285             gameID: gameID,
    286             gameTitle: gameTitle,
    287             authorID: inviterAuthorID,
    288             playerName: inviterName,
    289             addressee: friendAuthorID,
    290             friendZoneID: CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName),
    291             friendZoneScope: friend.databaseScope,
    292             payload: encoded
    293         )
    294     }
    295 
    296     /// Writes a `.decline` Ping into the friend zone telling the inviter we
    297     /// turned down their game invite, so their device frees our seat on the
    298     /// game's `CKShare` and surfaces a banner. Mirrors `sendInvite` reversed:
    299     /// `declinerAuthorID` is us (the sender), `inviterAuthorID` the addressee.
    300     /// Carries no payload — `(gameID, declinerAuthorID)` fully identify the seat
    301     /// to free. No-ops for an unknown or blocked friend.
    302     func sendDecline(
    303         toInviterAuthorID inviterAuthorID: String,
    304         gameID: UUID,
    305         gameTitle: String,
    306         declinerAuthorID: String,
    307         declinerName: String
    308     ) async throws {
    309         let ctx = persistence.viewContext
    310         let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity")
    311         req.predicate = NSPredicate(format: "authorID == %@", inviterAuthorID)
    312         req.fetchLimit = 1
    313         guard let friend = try ctx.fetch(req).first else {
    314             throw FriendError.friendNotFound
    315         }
    316         guard !friend.isBlocked else { throw FriendError.friendBlocked }
    317         guard let zoneName = friend.friendZoneName,
    318               let ownerName = friend.friendZoneOwnerName
    319         else { throw FriendError.friendNotFound }
    320 
    321         await syncEngine.enqueueFriendZonePing(
    322             kind: .decline,
    323             gameID: gameID,
    324             gameTitle: gameTitle,
    325             authorID: declinerAuthorID,
    326             playerName: declinerName,
    327             addressee: inviterAuthorID,
    328             friendZoneID: CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName),
    329             friendZoneScope: friend.databaseScope
    330         )
    331     }
    332 
    333     /// Consume-deletes a directed Ping from the pairwise friend zone — an
    334     /// `.invite` once it has been accepted or found stale, or a `.decline` once
    335     /// the addressed inviter has freed the seat. Removing the source record
    336     /// stops it re-creating on the recipient's devices and withdraws any banner
    337     /// a sibling showed. `friendAuthorID` is the *other* party on the zone (the
    338     /// inviter for an invite we consume, the decliner for a decline we consume).
    339     func deleteFriendZonePing(fromFriendAuthorID friendAuthorID: String, recordName: String) async {
    340         let ctx = persistence.viewContext
    341         let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity")
    342         req.predicate = NSPredicate(format: "authorID == %@", friendAuthorID)
    343         req.fetchLimit = 1
    344         guard let friend = try? ctx.fetch(req).first,
    345               let zoneName = friend.friendZoneName,
    346               let ownerName = friend.friendZoneOwnerName
    347         else { return }
    348 
    349         await syncEngine.deletePing(
    350             recordName: recordName,
    351             zoneID: CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName),
    352             databaseScope: friend.databaseScope
    353         )
    354     }
    355 
    356     // MARK: - Block
    357 
    358     /// Marks the friend blocked and tears down the channel so nothing further
    359     /// can arrive from them: the owner deletes the friend zone outright; a
    360     /// participant leaves by deleting the zone-wide share. The `FriendEntity`
    361     /// row is kept (as a blocked tombstone) so future `.invite` Pings are
    362     /// suppressed and the zone stays out of `knownZones`.
    363     func blockAndTeardown(friendAuthorID: String) async throws {
    364         let ctx = persistence.viewContext
    365         let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity")
    366         req.predicate = NSPredicate(format: "authorID == %@", friendAuthorID)
    367         req.fetchLimit = 1
    368         guard let friend = try ctx.fetch(req).first else { return }
    369         let scope = friend.databaseScope
    370         let zoneName = friend.friendZoneName
    371         let ownerName = friend.friendZoneOwnerName
    372         friend.isBlocked = true
    373         try ctx.save()
    374 
    375         // Make the block authoritative across the user's own devices. Written
    376         // before the channel teardown so the durable fact survives even a
    377         // partial teardown; `applyDecisionRecord` projects it onto a blocked
    378         // `FriendEntity` on every other device.
    379         await syncEngine.enqueueDecision(kind: "block", key: friendAuthorID)
    380 
    381         guard let zoneName, let ownerName else { return }
    382         let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName)
    383         syncMonitor?.recordStart("block friend")
    384         do {
    385             if scope == 0 {
    386                 // We own the friend zone — deleting it revokes the share for
    387                 // both sides.
    388                 try await deleteZone(zoneID, in: container.privateCloudDatabase)
    389             } else {
    390                 // Participant — leave by deleting the zone-wide share record.
    391                 let shareID = CKRecord.ID(
    392                     recordName: CKRecordNameZoneWideShare,
    393                     zoneID: zoneID
    394                 )
    395                 do {
    396                     try await container.sharedCloudDatabase.deleteRecord(withID: shareID)
    397                 } catch let error as CKError
    398                     where error.code == .unknownItem || error.code == .zoneNotFound {
    399                     // Already gone — nothing to do.
    400                 }
    401             }
    402             syncMonitor?.recordSuccess("block friend")
    403         } catch {
    404             syncMonitor?.recordError("block friend", error)
    405         }
    406     }
    407 
    408     // MARK: - Rename
    409 
    410     /// Sets (or, with an empty string, clears) the user's private nickname
    411     /// for a friend. The nickname lives on the local `FriendEntity` and is
    412     /// made authoritative across the user's own devices via a versioned
    413     /// `nickname` Decision in the account zone — the same channel `block`
    414     /// rides; it is never written into the friend zone, so the friend never
    415     /// sees it. Each rename bumps the per-friend generation so the newest
    416     /// rename wins any cross-device race (`applyDecisionRecord`).
    417     func setNickname(friendAuthorID: String, nickname: String) async throws {
    418         let ctx = persistence.viewContext
    419         let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity")
    420         req.predicate = NSPredicate(format: "authorID == %@", friendAuthorID)
    421         req.fetchLimit = 1
    422         guard let friend = try ctx.fetch(req).first else {
    423             throw FriendError.friendNotFound
    424         }
    425         let trimmed = nickname.trimmingCharacters(in: .whitespacesAndNewlines)
    426         let version = friend.nicknameVersion + 1
    427         friend.nickname = trimmed.isEmpty ? nil : trimmed
    428         friend.nicknameVersion = version
    429         try ctx.save()
    430         FriendEntity.rebuildNicknameDirectory(in: ctx)
    431         // An empty payload propagates the clear: the Decision record stays
    432         // (preserving the version) but applies as "no nickname".
    433         await syncEngine.enqueueDecision(
    434             kind: RecordSerializer.nicknameDecisionKind,
    435             key: friendAuthorID,
    436             payload: trimmed.isEmpty ? nil : trimmed,
    437             version: version
    438         )
    439     }
    440 
    441     // MARK: - CloudKit helpers
    442 
    443     private func deleteZone(
    444         _ zoneID: CKRecordZone.ID,
    445         in database: CKDatabase
    446     ) async throws {
    447         try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
    448             let op = CKModifyRecordZonesOperation(
    449                 recordZonesToSave: nil,
    450                 recordZoneIDsToDelete: [zoneID]
    451             )
    452             op.qualityOfService = .userInitiated
    453             op.modifyRecordZonesResultBlock = { result in cont.resume(with: result) }
    454             database.add(op)
    455         }
    456     }
    457 
    458     private func createZone(_ zoneID: CKRecordZone.ID) async throws {
    459         try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
    460             let op = CKModifyRecordZonesOperation(
    461                 recordZonesToSave: [CKRecordZone(zoneID: zoneID)],
    462                 recordZoneIDsToDelete: nil
    463             )
    464             op.qualityOfService = .userInitiated
    465             op.modifyRecordZonesResultBlock = { result in cont.resume(with: result) }
    466             self.container.privateCloudDatabase.add(op)
    467         }
    468     }
    469 
    470     private func saveZoneWideShare(
    471         zoneID: CKRecordZone.ID,
    472         addingParticipant remoteAuthorID: String
    473     ) async throws -> CKShare {
    474         let share = CKShare(recordZoneID: zoneID)
    475         share.publicPermission = .none
    476         let participant = try await fetchParticipant(forUserRecordName: remoteAuthorID)
    477         participant.permission = .readWrite
    478         share.addParticipant(participant)
    479         let saved = try await container.privateCloudDatabase.save(share)
    480         guard let savedShare = saved as? CKShare else {
    481             throw FriendError.invalidShareRecord
    482         }
    483         return savedShare
    484     }
    485 
    486     /// The zone-wide `CKShare` for `zoneID` in our private database, or `nil`
    487     /// if it doesn't exist yet. Lets an owner-side device detect a friend zone
    488     /// a sibling device on the same iCloud account already shared.
    489     private func existingZoneWideShare(
    490         zoneID: CKRecordZone.ID
    491     ) async throws -> CKShare? {
    492         let shareID = CKRecord.ID(
    493             recordName: CKRecordNameZoneWideShare,
    494             zoneID: zoneID
    495         )
    496         do {
    497             let record = try await container.privateCloudDatabase.record(for: shareID)
    498             return record as? CKShare
    499         } catch let error as CKError
    500             where error.code == .unknownItem || error.code == .zoneNotFound {
    501             return nil
    502         }
    503     }
    504 
    505     private func fetchParticipant(
    506         forUserRecordName recordName: String
    507     ) async throws -> CKShare.Participant {
    508         let lookup = CKUserIdentity.LookupInfo(
    509             userRecordID: CKRecord.ID(recordName: recordName)
    510         )
    511         return try await withCheckedThrowingContinuation { cont in
    512             var found: CKShare.Participant?
    513             let op = CKFetchShareParticipantsOperation(userIdentityLookupInfos: [lookup])
    514             op.perShareParticipantResultBlock = { _, result in
    515                 if case .success(let participant) = result { found = participant }
    516             }
    517             op.fetchShareParticipantsResultBlock = { result in
    518                 switch result {
    519                 case .success:
    520                     if let found {
    521                         cont.resume(returning: found)
    522                     } else {
    523                         cont.resume(throwing: FriendError.participantNotFound)
    524                     }
    525                 case .failure(let error):
    526                     cont.resume(throwing: error)
    527                 }
    528             }
    529             self.container.add(op)
    530         }
    531     }
    532 
    533     private func fetchShareMetadata(url: URL) async throws -> CKShare.Metadata {
    534         try await withCheckedThrowingContinuation { cont in
    535             var metadata: CKShare.Metadata?
    536             let op = CKFetchShareMetadataOperation(shareURLs: [url])
    537             op.shouldFetchRootRecord = false
    538             op.perShareMetadataResultBlock = { _, result in
    539                 if case .success(let m) = result { metadata = m }
    540             }
    541             op.fetchShareMetadataResultBlock = { result in
    542                 switch result {
    543                 case .success:
    544                     if let metadata {
    545                         cont.resume(returning: metadata)
    546                     } else {
    547                         cont.resume(throwing: FriendError.invalidShareRecord)
    548                     }
    549                 case .failure(let error):
    550                     cont.resume(throwing: error)
    551                 }
    552             }
    553             self.container.add(op)
    554         }
    555     }
    556 
    557     private func accept(_ metadata: CKShare.Metadata) async throws {
    558         try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
    559             let op = CKAcceptSharesOperation(shareMetadatas: [metadata])
    560             op.acceptSharesResultBlock = { result in cont.resume(with: result) }
    561             self.container.add(op)
    562         }
    563     }
    564 
    565     // MARK: - Core Data
    566 
    567     /// True if *any* `FriendEntity` for the pair exists — including a blocked
    568     /// one. This is deliberate for v1: a blocked friendship is permanent, so
    569     /// `establishIfOwner` / `applyFriendPing` short-circuit here and never
    570     /// re-bootstrap a channel the user has torn down. Re-friending a blocked
    571     /// collaborator is intentionally out of scope; lifting it later means
    572     /// adding an explicit unblock path, not changing this guard.
    573     private func friendExists(pairKey: String) -> Bool {
    574         let ctx = persistence.viewContext
    575         let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity")
    576         req.predicate = NSPredicate(format: "pairKey == %@", pairKey)
    577         req.fetchLimit = 1
    578         return ((try? ctx.count(for: req)) ?? 0) > 0
    579     }
    580 
    581     /// Records the friendship row. The display name is deliberately *not*
    582     /// written here — it arrives exclusively via the friend's `name` Decision
    583     /// (`RecordSerializer.applyDecisionRecord`); until that syncs, the invite
    584     /// surfaces fall back to the freshest per-game Player snapshot, then
    585     /// "Player".
    586     private func persistFriend(
    587         authorID: String,
    588         pairKey: String,
    589         zoneName: String,
    590         zoneOwnerName: String,
    591         databaseScope: Int16
    592     ) {
    593         let ctx = persistence.viewContext
    594         let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity")
    595         req.predicate = NSPredicate(format: "pairKey == %@", pairKey)
    596         req.fetchLimit = 1
    597         let entity = (try? ctx.fetch(req).first) ?? FriendEntity(context: ctx)
    598         entity.authorID = authorID
    599         entity.pairKey = pairKey
    600         entity.friendZoneName = zoneName
    601         entity.friendZoneOwnerName = zoneOwnerName
    602         entity.databaseScope = databaseScope
    603         if entity.createdAt == nil { entity.createdAt = Date() }
    604         do {
    605             try ctx.save()
    606             Task { [weak self] in
    607                 await self?.applyAccountNicknameDecisionIfPresent(for: authorID)
    608             }
    609         } catch {
    610             eventLog?.note("FriendController: persistFriend save failed — \(error)", level: "error")
    611         }
    612     }
    613 
    614     /// A sibling can set a nickname before this device has bootstrapped the
    615     /// `FriendEntity`. CKSyncEngine will then advance past the account-zone
    616     /// Decision without applying it because there is no friend row yet. After
    617     /// bootstrap creates the row, fetch the deterministic Decision directly
    618     /// and replay it once.
    619     func applyAccountNicknameDecisionIfPresent(for friendAuthorID: String) async {
    620         let recordName = RecordSerializer.decisionRecordName(
    621             kind: RecordSerializer.nicknameDecisionKind,
    622             key: friendAuthorID
    623         )
    624         let recordID = CKRecord.ID(
    625             recordName: recordName,
    626             zoneID: RecordSerializer.accountZoneID
    627         )
    628         do {
    629             let record = try await fetchAccountDecisionRecord(recordID)
    630             let ctx = persistence.viewContext
    631             let wrote = RecordSerializer.applyDecisionRecord(
    632                 record,
    633                 to: ctx,
    634                 localAuthorID: nil,
    635                 databaseScope: 0
    636             )
    637             if wrote {
    638                 try ctx.save()
    639                 FriendEntity.rebuildNicknameDirectory(in: ctx)
    640                 eventLog?.note(
    641                     "FriendController: replayed nickname decision \(recordName)",
    642                     level: "info"
    643                 )
    644             }
    645         } catch let error as CKError
    646             where error.code == .unknownItem || error.code == .zoneNotFound {
    647             // Most friendships will not have a private nickname yet.
    648         } catch {
    649             eventLog?.note(
    650                 "FriendController: nickname decision replay failed for \(friendAuthorID) — \(error)",
    651                 level: "error"
    652             )
    653         }
    654     }
    655 }