crossmate

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

RecordSerializer.swift (56575B)


      1 import CloudKit
      2 import CoreData
      3 import CryptoKit
      4 import Foundation
      5 
      6 /// Pure-function helpers for converting between the app's Core Data / in-memory
      7 /// models and CloudKit `CKRecord` objects. Stateless — all context is passed in.
      8 enum RecordSerializer {
      9 
     10     // MARK: - Direct fetch key sets
     11 
     12     static let gameDesiredKeys: [CKRecord.FieldKey] = [
     13         "title",
     14         "completedAt",
     15         "completedBy",
     16         "shareRecordName",
     17         "engagement",
     18         "notification",
     19         "puzzleSource",
     20     ]
     21 
     22     static let movesDesiredKeys: [CKRecord.FieldKey] = [
     23         "authorID",
     24         "deviceID",
     25         "cells",
     26         "updatedAt",
     27     ]
     28 
     29     static let playerDesiredKeys: [CKRecord.FieldKey] = [
     30         "authorID",
     31         "name",
     32         "updatedAt",
     33         "selRow",
     34         "selCol",
     35         "selDir",
     36         "readAt",
     37         "readThrough",
     38         "sessionSnapshot",
     39         "timeLog",
     40         "pushAddress",
     41     ]
     42 
     43     static let pingDesiredKeys: [CKRecord.FieldKey] = [
     44         "authorID",
     45         "deviceID",
     46         "playerName",
     47         "puzzleTitle",
     48         "kind",
     49         "payload",
     50         "addressee",
     51     ]
     52 
     53     static let pingDeletionDesiredKeys: [CKRecord.FieldKey] = [
     54         "authorID",
     55         "kind",
     56     ]
     57 
     58     // MARK: - Device identity
     59 
     60     /// A stable per-device identifier appended to move and snapshot record
     61     /// names to prevent two devices owned by the same iCloud user from
     62     /// producing identical record names when both assign the same Lamport
     63     /// clock value while offline.
     64     ///
     65     /// Stored in UserDefaults so it survives app restarts but resets on
     66     /// reinstall (which is fine — a reinstalled app has no local moves to
     67     /// conflict with).
     68     static let localDeviceID: String = {
     69         let key = "crossmate.localDeviceID"
     70         if let stored = UserDefaults.standard.string(forKey: key) {
     71             return stored
     72         }
     73         let new = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased()
     74         UserDefaults.standard.set(new, forKey: key)
     75         return new
     76     }()
     77 
     78     // MARK: - Record names
     79 
     80     static func recordName(forGameID gameID: UUID) -> String {
     81         "game-\(gameID.uuidString)"
     82     }
     83 
     84     /// Recovers the game UUID from a `"game-<UUID>"` record or zone name — the
     85     /// inverse of `recordName(forGameID:)`. Returns nil when the name isn't a
     86     /// game name or the UUID doesn't parse. A game's zone name and its root
     87     /// record name are identical, so this also resolves a share's zone.
     88     static func gameID(fromGameRecordName name: String) -> UUID? {
     89         guard name.hasPrefix("game-") else { return nil }
     90         return UUID(uuidString: String(name.dropFirst("game-".count)))
     91     }
     92 
     93     /// One Moves record per `(game, authorID, deviceID)`. Each device only
     94     /// writes to its own slot, so there are no write-write conflicts on the
     95     /// `cells` field.
     96     static func recordName(
     97         forMovesInGame gameID: UUID,
     98         authorID: String,
     99         deviceID: String
    100     ) -> String {
    101         "moves-\(gameID.uuidString)-\(authorID)-\(deviceID)"
    102     }
    103 
    104     /// One Journal record per `(game, authorID, deviceID)` — this device's
    105     /// whole local move log, uploaded once at completion (Phase 2). Same
    106     /// `(game, author, device)` shape as the Moves record so collaborators'
    107     /// uploads stay distinct and mergeable by timestamp for replay.
    108     static func recordName(
    109         forJournalInGame gameID: UUID,
    110         authorID: String,
    111         deviceID: String
    112     ) -> String {
    113         "journal-\(gameID.uuidString)-\(authorID)-\(deviceID)"
    114     }
    115 
    116     /// One player record per (game, author). Each participant only ever
    117     /// writes to their own slot, so there are no write-write conflicts on
    118     /// the field.
    119     static func recordName(forPlayerInGame gameID: UUID, authorID: String) -> String {
    120         "player-\(gameID.uuidString)-\(authorID)"
    121     }
    122 
    123     /// One Ping record per event. `deviceID` keeps cross-device writes from
    124     /// the same iCloud user unique (authorID is identical across that user's
    125     /// devices), and the event timestamp covers repeated pings from the same
    126     /// device.
    127     static func recordName(
    128         forPingInGame gameID: UUID,
    129         authorID: String,
    130         deviceID: String,
    131         eventTimestampMs: Int64
    132     ) -> String {
    133         "ping-\(gameID.uuidString)-\(authorID)-\(deviceID)-\(eventTimestampMs)"
    134     }
    135 
    136     /// One `Decision` record per `(kind, key)`. A durable, per-user fact that
    137     /// must agree across a single iCloud user's own devices — the durable
    138     /// counterpart to the transient `Ping`. Lives in the account zone; the
    139     /// deterministic name makes every write an idempotent upsert. `kind`
    140     /// carries no dashes (so the first dash after the prefix splits cleanly);
    141     /// `key` may contain dashes.
    142     static func decisionRecordName(kind: String, key: String) -> String {
    143         "decision-\(kind)-\(key)"
    144     }
    145 
    146     /// Parses `decision-<kind>-<key>`. `kind` is the segment up to the first
    147     /// dash after the prefix; `key` is the remainder.
    148     static func parseDecisionRecordName(_ name: String) -> (kind: String, key: String)? {
    149         let prefix = "decision-"
    150         guard name.hasPrefix(prefix) else { return nil }
    151         let rest = name.dropFirst(prefix.count)
    152         guard let dash = rest.firstIndex(of: "-") else { return nil }
    153         let kind = String(rest[rest.startIndex..<dash])
    154         let key = String(rest[rest.index(after: dash)...])
    155         guard !kind.isEmpty, !key.isEmpty else { return nil }
    156         return (kind, key)
    157     }
    158 
    159     /// Kind for the display-name Decision: `decision-name-<authorID>`, payload
    160     /// = the display name, `version` = the author's monotonic rename
    161     /// generation. The author writes their own copy into their account zone
    162     /// (own-device convergence and restore durability) and into every friend
    163     /// zone they participate in (the friend's devices read it from there) —
    164     /// names never sync through any other channel.
    165     static let nameDecisionKind = "name"
    166 
    167     static func nameDecisionName(authorID: String) -> String {
    168         decisionRecordName(kind: nameDecisionKind, key: authorID)
    169     }
    170 
    171     /// Parses a display-name Decision into its subject author, name, and
    172     /// version. Returns `nil` for any other decision or an empty payload.
    173     static func parseNameDecision(
    174         _ record: CKRecord
    175     ) -> (authorID: String, name: String, version: Int64)? {
    176         guard record.recordType == "Decision",
    177               let (kind, key) = parseDecisionRecordName(record.recordID.recordName),
    178               kind == nameDecisionKind,
    179               ((record["kind"] as? String) ?? nameDecisionKind) == nameDecisionKind,
    180               let name = record["payload"] as? String,
    181               !name.isEmpty
    182         else { return nil }
    183         return (key, name, decisionVersion(record))
    184     }
    185 
    186     /// Kind for the friend-nickname Decision: `decision-nickname-<authorID>`,
    187     /// payload = the nickname this user privately calls that friend (absent or
    188     /// empty = cleared, fall back to the friend's own name), `version` = this
    189     /// user's monotonic rename generation for that friend. Lives only in the
    190     /// account zone — it's the user's own label, never shared with the friend.
    191     static let nicknameDecisionKind = "nickname"
    192 
    193     static let accountDecisionKind = "account"
    194     static let accountPushAddressDecisionKey = "pushAddress"
    195     /// Key for the account-wide push *secret* decision. The secret is the HMAC
    196     /// key from which every per-game push address is derived (see
    197     /// `deriveGameAddress`); it converges across the account's own devices the
    198     /// same way the account address does, and is never sent to peers or the
    199     /// push worker — only the derived per-game addresses are.
    200     static let accountPushSecretDecisionKey = "pushSecret"
    201 
    202     static var accountPushAddressDecisionName: String {
    203         decisionRecordName(kind: accountDecisionKind, key: accountPushAddressDecisionKey)
    204     }
    205 
    206     static var accountPushSecretDecisionName: String {
    207         decisionRecordName(kind: accountDecisionKind, key: accountPushSecretDecisionKey)
    208     }
    209 
    210     static func parseAccountPushAddressDecision(_ record: CKRecord) -> String? {
    211         guard record.recordType == "Decision",
    212               record.recordID.recordName == accountPushAddressDecisionName,
    213               (record["kind"] as? String) == accountDecisionKind,
    214               let address = record["payload"] as? String,
    215               !address.isEmpty
    216         else { return nil }
    217         return address
    218     }
    219 
    220     /// Default generation for a `version`-less Decision — any record written by
    221     /// the pre-rotation code. Matched to the value a fresh mint uses so legacy
    222     /// and freshly-minted secrets share a generation and converge via the
    223     /// equal-version "server wins" rule, while a deliberate rotation (2+)
    224     /// supersedes them. Mapping to 0 instead would let the first post-update
    225     /// mint clobber an already-converged legacy secret.
    226     static let decisionBaseVersion: Int64 = 1
    227 
    228     /// The monotonic generation of a Decision. Higher wins: an inbound or
    229     /// conflicting record at a higher version supersedes the local value; equal
    230     /// versions converge on whoever reached the server first. Absent (legacy)
    231     /// records report `decisionBaseVersion`.
    232     static func decisionVersion(_ record: CKRecord) -> Int64 {
    233         (record["version"] as? Int64) ?? decisionBaseVersion
    234     }
    235 
    236     static func parseAccountPushSecretDecision(
    237         _ record: CKRecord
    238     ) -> (secret: String, version: Int64)? {
    239         guard record.recordType == "Decision",
    240               record.recordID.recordName == accountPushSecretDecisionName,
    241               (record["kind"] as? String) == accountDecisionKind,
    242               let secret = record["payload"] as? String,
    243               !secret.isEmpty
    244         else { return nil }
    245         return (secret, decisionVersion(record))
    246     }
    247 
    248     /// Derives this account's push address for one game as
    249     /// `HMAC-SHA256(secret, gameID)`, base64url-encoded. Deterministic, so every
    250     /// one of the account's devices computes the identical address for a game
    251     /// without any negotiation, and per-game scoped: a peer holding one game's
    252     /// address can't compute another's without the secret, which never leaves
    253     /// the account's devices. Rotation is by changing the secret.
    254     static func deriveGameAddress(secret: String, gameID: UUID) -> String {
    255         let key = SymmetricKey(data: Data(secret.utf8))
    256         let mac = HMAC<SHA256>.authenticationCode(
    257             for: Data(gameID.uuidString.utf8),
    258             using: key
    259         )
    260         return Data(mac).base64EncodedString()
    261             .replacingOccurrences(of: "+", with: "-")
    262             .replacingOccurrences(of: "/", with: "_")
    263             .replacingOccurrences(of: "=", with: "")
    264     }
    265 
    266     // MARK: - Zone
    267 
    268     /// Zone ID for a per-game zone. `ownerName` defaults to the current user
    269     /// placeholder; pass an explicit value for shared games where the zone is
    270     /// owned by another iCloud account.
    271     static func zoneID(
    272         for gameID: UUID,
    273         ownerName: String = CKCurrentUserDefaultName
    274     ) -> CKRecordZone.ID {
    275         CKRecordZone.ID(zoneName: "game-\(gameID.uuidString)", ownerName: ownerName)
    276     }
    277 
    278     /// Zone ID for the user's account-wide zone in the private database. Holds
    279     /// records that coordinate state between a single iCloud user's own
    280     /// devices — never shared with collaborators, since the private database
    281     /// itself isn't reachable to anyone else.
    282     static let accountZoneID = CKRecordZone.ID(
    283         zoneName: "account",
    284         ownerName: CKCurrentUserDefaultName
    285     )
    286 
    287     // MARK: - Moves record building
    288 
    289     static func movesRecord(
    290         from view: MovesValue,
    291         zone: CKRecordZone.ID,
    292         systemFields: Data?
    293     ) throws -> CKRecord {
    294         let movesName = recordName(
    295             forMovesInGame: view.gameID,
    296             authorID: view.authorID,
    297             deviceID: view.deviceID
    298         )
    299         let record = restoreOrCreate(
    300             recordType: "Moves",
    301             recordName: movesName,
    302             zone: zone,
    303             systemFields: systemFields
    304         )
    305 
    306         record["authorID"] = view.authorID as CKRecordValue
    307         record["deviceID"] = view.deviceID as CKRecordValue
    308         record["updatedAt"] = view.updatedAt as CKRecordValue
    309         record["cells"] = try MovesCodec.encode(view.cells) as CKRecordValue
    310 
    311         return record
    312     }
    313 
    314     // MARK: - Journal record building
    315 
    316     /// Builds the `Journal` record carrying this device's full move log as a
    317     /// `CKAsset`. Write-once at completion, so there is no system-fields
    318     /// archive (mirrors `Ping`/`Decision`): a fresh record each build, and a
    319     /// re-send of an already-uploaded journal is a benign conflict the send
    320     /// path drops. The encoded entries are written to a temp file the same way
    321     /// `populateGameRecord` stages `puzzleSource` — CloudKit copies the asset
    322     /// on upload and the OS reaps the temporary directory.
    323     static func journalRecord(
    324         gameID: UUID,
    325         authorID: String,
    326         deviceID: String,
    327         updatedAt: Date,
    328         entries: [JournalValue],
    329         zone: CKRecordZone.ID
    330     ) throws -> CKRecord {
    331         let name = recordName(forJournalInGame: gameID, authorID: authorID, deviceID: deviceID)
    332         let recordID = CKRecord.ID(recordName: name, zoneID: zone)
    333         let record = CKRecord(recordType: "Journal", recordID: recordID)
    334         record["authorID"] = authorID as CKRecordValue
    335         record["deviceID"] = deviceID as CKRecordValue
    336         record["updatedAt"] = updatedAt as CKRecordValue
    337 
    338         let data = try JournalCodec.encode(entries)
    339         let url = FileManager.default.temporaryDirectory
    340             .appendingPathComponent(UUID().uuidString)
    341             .appendingPathExtension("json")
    342         try data.write(to: url, options: .atomic)
    343         record["entries"] = CKAsset(fileURL: url)
    344 
    345         return record
    346     }
    347 
    348     static func gameRecord(
    349         from entity: GameEntity,
    350         recordID: CKRecord.ID,
    351         includePuzzleSource: Bool
    352     ) -> CKRecord? {
    353         guard entity.ckRecordName != nil else { return nil }
    354         let record: CKRecord
    355         if let fields = entity.ckSystemFields,
    356            let restored = decodeRecord(from: fields) {
    357             record = restored
    358         } else {
    359             record = CKRecord(recordType: "Game", recordID: recordID)
    360         }
    361         populateGameRecord(record, from: entity, includePuzzleSource: includePuzzleSource)
    362         return record
    363     }
    364 
    365     static func populateGameRecord(
    366         _ record: CKRecord,
    367         from entity: GameEntity,
    368         includePuzzleSource: Bool
    369     ) {
    370         // `title` and the `shareRecordName` marker are owner-authoritative: the
    371         // title comes from the puzzle the owner authored, and only owner devices
    372         // track the share record. A participant only ever re-saves this record to
    373         // mint the engagement/notification creds below, and at join time its
    374         // local `title` is still the transient "Joining…" placeholder
    375         // (`SyncEngine.handleFetchedDatabaseChanges`) until the owner's Game
    376         // record lands. Writing it from a participant would LWW-clobber the real
    377         // title on the shared record for everyone — so a non-owner leaves these
    378         // fields untouched and the server keeps the owner's value.
    379         let isOwner = entity.databaseScope == 0
    380         if isOwner {
    381             record["title"] = entity.title as CKRecordValue?
    382             // Owner-side share marker. Propagated so other owner-devices can flip
    383             // their `isShared` flag without reading the zone's CKShare directly.
    384             record["shareRecordName"] = entity.ckShareRecordName as CKRecordValue?
    385         }
    386         record["completedAt"] = entity.completedAt as CKRecordValue?
    387         // Solver's authorID on a win; nil for a resignation. Single-writer
    388         // (the device that first completes the game) so plain LWW is safe.
    389         record["completedBy"] = entity.completedBy as CKRecordValue?
    390         // The shared live-engagement room credentials (encoded
    391         // EngagementRoomCredentials). Any present participant may mint these
    392         // when the field is empty; convergence is plain record-level LWW, and
    393         // peers connect to whatever creds the field currently holds.
    394         record["engagement"] = entity.engagement as CKRecordValue?
    395         // The shared per-game notification credentials (encoded
    396         // GamePushCredentials: the push auth secret + credID, plus the
    397         // worker-blind content key the payload is encrypted under). Synced to
    398         // participants like `engagement`; any participant may mint it when
    399         // empty, and record-level LWW converges concurrent mints.
    400         record["notification"] = entity.notification as CKRecordValue?
    401         guard includePuzzleSource, let source = entity.puzzleSource else { return }
    402         let url = FileManager.default.temporaryDirectory
    403             .appendingPathComponent(UUID().uuidString)
    404             .appendingPathExtension("xd")
    405         try? source.write(to: url, atomically: true, encoding: .utf8)
    406         record["puzzleSource"] = CKAsset(fileURL: url)
    407     }
    408 
    409     /// Builds a freshly-minted Ping record. Pings are write-once — they have
    410     /// no Core Data equivalent and no system-fields archive.
    411     /// - `authorID` + `deviceID` together let receivers filter out self-sends.
    412     ///   authorID alone is insufficient for kinds (e.g. `.opened`) that fire
    413     ///   between a single user's own devices, where authorID is identical.
    414     /// - `playerName` and `puzzleTitle` let receivers render the alert body.
    415     /// - `kind` distinguishes the remaining bootstrap kinds (.join /
    416     ///   .friend / .invite / .hail).
    417     static func pingRecord(
    418         gameID: UUID,
    419         authorID: String,
    420         deviceID: String,
    421         playerName: String,
    422         puzzleTitle: String,
    423         eventTimestampMs: Int64,
    424         kind: PingKind,
    425         payload: String? = nil,
    426         addressee: String? = nil,
    427         zone: CKRecordZone.ID
    428     ) -> CKRecord {
    429         let name = recordName(
    430             forPingInGame: gameID,
    431             authorID: authorID,
    432             deviceID: deviceID,
    433             eventTimestampMs: eventTimestampMs
    434         )
    435         let recordID = CKRecord.ID(recordName: name, zoneID: zone)
    436         let record = CKRecord(recordType: "Ping", recordID: recordID)
    437         record["authorID"] = authorID as CKRecordValue
    438         record["deviceID"] = deviceID as CKRecordValue
    439         record["playerName"] = playerName as CKRecordValue
    440         record["puzzleTitle"] = puzzleTitle as CKRecordValue
    441         record["kind"] = kind.rawValue as CKRecordValue
    442         // Directed pings target one player by authorID; nil ⇒ broadcast (every
    443         // recipient acts on it).
    444         if let addressee {
    445             record["addressee"] = addressee as CKRecordValue
    446         }
    447         if let payload {
    448             record["payload"] = payload as CKRecordValue
    449         }
    450         return record
    451     }
    452 
    453     /// Builds a `Decision` record. The identity (`kind` + `key`) lives in the
    454     /// record name, which keeps every write an idempotent upsert; `key` is not
    455     /// duplicated as a field. `payload` is the generic, kind-specific extra
    456     /// slot — empty for `block`, where presence alone is the fact — mirroring
    457     /// `Ping.payload`. `systemFields` is the archived server record (with its
    458     /// change tag): pass it so a re-send carries the current tag and CloudKit
    459     /// accepts the update (e.g. rotating the push secret) instead of rejecting
    460     /// it as a colliding create. Decisions are therefore upsertable, not
    461     /// write-once; a payload-less write clears any value a restored record held.
    462     static func decisionRecord(
    463         kind: String,
    464         key: String,
    465         payload: String? = nil,
    466         zone: CKRecordZone.ID,
    467         systemFields: Data? = nil,
    468         version: Int64? = nil
    469     ) -> CKRecord {
    470         let name = decisionRecordName(kind: kind, key: key)
    471         let record = restoreOrCreate(
    472             recordType: "Decision",
    473             recordName: name,
    474             zone: zone,
    475             systemFields: systemFields
    476         )
    477         record["kind"] = kind as CKRecordValue
    478         record["payload"] = payload.map { $0 as CKRecordValue }
    479         record["createdAt"] = Date() as CKRecordValue
    480         record["version"] = version.map { $0 as CKRecordValue }
    481         return record
    482     }
    483 
    484     static func playerRecord(
    485         gameID: UUID,
    486         authorID: String,
    487         name: String,
    488         updatedAt: Date,
    489         selection: PlayerSelection?,
    490         readAt: Date? = nil,
    491         readThrough: Date? = nil,
    492         sessionSnapshot: Data? = nil,
    493         timeLog: Data? = nil,
    494         pushAddress: String? = nil,
    495         zone: CKRecordZone.ID,
    496         systemFields: Data?
    497     ) -> CKRecord {
    498         let recordName = recordName(forPlayerInGame: gameID, authorID: authorID)
    499         let record = restoreOrCreate(
    500             recordType: "Player",
    501             recordName: recordName,
    502             zone: zone,
    503             systemFields: systemFields
    504         )
    505 
    506         record["authorID"] = authorID as CKRecordValue
    507         record["name"] = name as CKRecordValue
    508         record["updatedAt"] = updatedAt as CKRecordValue
    509         if let selection {
    510             record["selRow"] = Int64(selection.row) as CKRecordValue
    511             record["selCol"] = Int64(selection.col) as CKRecordValue
    512             record["selDir"] = Int64(selection.direction.rawValue) as CKRecordValue
    513         } else {
    514             record["selRow"] = nil
    515             record["selCol"] = nil
    516             record["selDir"] = nil
    517         }
    518         // `readAt` is the forward-dated presence lease (see `GameStore`'s
    519         // TODO(v4): this field should be renamed to a presence name).
    520         if let readAt {
    521             record["readAt"] = readAt as CKRecordValue
    522         } else {
    523             record["readAt"] = nil
    524         }
    525         // `readThrough` is the true read watermark — the latest other-author
    526         // move time this account has actually seen. Never forward-dated, so a
    527         // peer's session-end summary windows only on moves we genuinely missed.
    528         if let readThrough {
    529             record["readThrough"] = readThrough as CKRecordValue
    530         } else {
    531             record["readThrough"] = nil
    532         }
    533         if let sessionSnapshot {
    534             record["sessionSnapshot"] = sessionSnapshot as CKRecordValue
    535         } else {
    536             record["sessionSnapshot"] = nil
    537         }
    538         // `timeLog` is the device-keyed solve-time log (encoded `TimeLog`):
    539         // each device's active-play intervals plus its open session. Unlike the
    540         // other LWW fields here, a device only ever mutates its own slot, so
    541         // concurrent sibling writes converge by device once merged on apply.
    542         if let timeLog, !timeLog.isEmpty {
    543             record["timeLog"] = timeLog as CKRecordValue
    544         } else {
    545             record["timeLog"] = nil
    546         }
    547         if let pushAddress, !pushAddress.isEmpty {
    548             record["pushAddress"] = pushAddress as CKRecordValue
    549         } else {
    550             record["pushAddress"] = nil
    551         }
    552 
    553         return record
    554     }
    555 
    556     /// Reads `readAt` off an inbound Player record — the per-account horizon
    557     /// for collaborator moves this user has read or is actively watching.
    558     /// Active puzzle sessions may lease the horizon into the near future and
    559     /// close it with a lower current-time write, so callers must apply this
    560     /// only after accepting the Player record under last-writer-wins
    561     /// freshness checks.
    562     /// Returns `nil` if the field is missing — older records, or a slot that
    563     /// has not yet recorded a view.
    564     static func parsePlayerReadAt(from record: CKRecord) -> Date? {
    565         record["readAt"] as? Date
    566     }
    567 
    568     /// Reads `readThrough` off an inbound Player record — the per-account read
    569     /// watermark: the latest other-author move time this user has actually
    570     /// seen. Unlike `readAt` it is never leased into the future, so the
    571     /// session-end push uses it to decide what a recipient still hasn't seen.
    572     /// Returns `nil` for records that predate the field or a slot that has not
    573     /// recorded a read yet.
    574     static func parsePlayerReadThrough(from record: CKRecord) -> Date? {
    575         record["readThrough"] as? Date
    576     }
    577 
    578     /// Reads `selRow`/`selCol`/`selDir` off an inbound Player record. These
    579     /// fields carry the peer's cursor track start, not their exact local
    580     /// reticle. Returns `nil` if any field is missing — the peer either hasn't
    581     /// published a track yet or has cleared theirs (e.g. left the puzzle view).
    582     static func parsePlayerSelection(from record: CKRecord) -> PlayerSelection? {
    583         guard let row = record["selRow"] as? Int64,
    584               let col = record["selCol"] as? Int64,
    585               let dirRaw = record["selDir"] as? Int64,
    586               let direction = PlayerSelection.Direction(rawValue: Int(dirRaw))
    587         else { return nil }
    588         return PlayerSelection(row: Int(row), col: Int(col), direction: direction)
    589     }
    590 
    591     /// Reads `sessionSnapshot` off an inbound Player record — the encoded
    592     /// `SeenBaseline` (this account's "last viewed" cutoff), written on leave.
    593     /// Shared across the author's own devices so a sibling converges on the
    594     /// latest view time rather than recomputing the catch-up baseline from its
    595     /// own (possibly stale) view. Returns `nil` on older records or when the
    596     /// account has not yet left a game with peers. (Pre-unification builds wrote
    597     /// a per-peer Moves-snapshot map here, which simply fails to decode as a
    598     /// `SeenBaseline` and is ignored — a per-device fallback.)
    599     static func parsePlayerSessionSnapshot(from record: CKRecord) -> Data? {
    600         record["sessionSnapshot"] as? Data
    601     }
    602 
    603     /// Reads `timeLog` off an inbound Player record — the encoded `TimeLog`
    604     /// of device-keyed solve-time intervals. Returns `nil` for records that
    605     /// predate the field (or before the schema deploy), which the clock treats
    606     /// as a zero contribution.
    607     static func parsePlayerTimeLog(from record: CKRecord) -> Data? {
    608         record["timeLog"] as? Data
    609     }
    610 
    611     /// Reads `pushAddress` off an inbound Player record — the per-(account,
    612     /// game) capability token a co-participant uses to address a push to this
    613     /// player for this game. Possession is gated by the share ACL (only
    614     /// participants can read the record), so the token, not an identity, is
    615     /// the authorisation. Returns `nil` for older records or a slot that has
    616     /// not yet minted one.
    617     static func parsePlayerPushAddress(from record: CKRecord) -> String? {
    618         record["pushAddress"] as? String
    619     }
    620 
    621     /// Parses an incoming `Player` record name back into its `(gameID,
    622     /// authorID)` components. Returns `nil` if the name doesn't match the
    623     /// `player-<UUID>-<authorID>` shape.
    624     static func parsePlayerRecordName(_ name: String) -> (UUID, String)? {
    625         guard name.hasPrefix("player-") else { return nil }
    626         let rest = name.dropFirst("player-".count)
    627         let uuidLength = 36
    628         guard rest.count > uuidLength,
    629               rest[rest.index(rest.startIndex, offsetBy: uuidLength)] == "-"
    630         else { return nil }
    631         let uuidPart = String(rest[rest.startIndex..<rest.index(rest.startIndex, offsetBy: uuidLength)])
    632         guard let gameID = UUID(uuidString: uuidPart) else { return nil }
    633         let authorPart = String(rest.suffix(from: rest.index(rest.startIndex, offsetBy: uuidLength + 1)))
    634         guard !authorPart.isEmpty else { return nil }
    635         return (gameID, authorPart)
    636     }
    637 
    638     /// Parses an incoming `Moves` CKRecord into a `MovesValue`. Returns `nil`
    639     /// if the record name doesn't match the `moves-<gameUUID>-<authorID>-<deviceID>`
    640     /// shape or the cells payload fails to decode.
    641     static func parseMovesRecord(_ record: CKRecord) -> MovesValue? {
    642         guard record.recordType == "Moves" else { return nil }
    643         guard let (gameID, authorID, deviceID) = parseMovesRecordName(
    644             record.recordID.recordName
    645         ) else { return nil }
    646         guard let data = record["cells"] as? Data,
    647               let cells = try? MovesCodec.decode(data)
    648         else { return nil }
    649         let updatedAt = record["updatedAt"] as? Date
    650             ?? record.modificationDate
    651             ?? Date()
    652         return MovesValue(
    653             gameID: gameID,
    654             authorID: authorID,
    655             deviceID: deviceID,
    656             cells: cells,
    657             updatedAt: updatedAt
    658         )
    659     }
    660 
    661     /// Parses `moves-<gameUUID>-<authorID>-<deviceID>` into its three parts.
    662     /// `deviceID` is the suffix after the final `-`; `authorID` may itself
    663     /// contain dashes (e.g. CloudKit user record names with no dashes today,
    664     /// but we don't want to assume).
    665     static func parseMovesRecordName(_ name: String) -> (UUID, String, String)? {
    666         let prefix = "moves-"
    667         guard name.hasPrefix(prefix) else { return nil }
    668         let rest = name.dropFirst(prefix.count)
    669         let uuidLength = 36
    670         guard rest.count > uuidLength,
    671               rest[rest.index(rest.startIndex, offsetBy: uuidLength)] == "-"
    672         else { return nil }
    673         let uuidPart = String(rest[rest.startIndex..<rest.index(rest.startIndex, offsetBy: uuidLength)])
    674         guard let gameID = UUID(uuidString: uuidPart) else { return nil }
    675         let afterUUID = rest.index(rest.startIndex, offsetBy: uuidLength + 1)
    676         let tail = rest[afterUUID...]
    677         guard let lastDash = tail.lastIndex(of: "-") else { return nil }
    678         let authorID = String(tail[tail.startIndex..<lastDash])
    679         let deviceID = String(tail[tail.index(after: lastDash)...])
    680         guard !authorID.isEmpty, !deviceID.isEmpty else { return nil }
    681         return (gameID, authorID, deviceID)
    682     }
    683 
    684     /// Parses `journal-<gameUUID>-<authorID>-<deviceID>` into its three parts.
    685     /// Same decomposition as `parseMovesRecordName` (deviceID is the suffix
    686     /// after the final `-`; authorID may itself contain dashes).
    687     static func parseJournalRecordName(_ name: String) -> (UUID, String, String)? {
    688         let prefix = "journal-"
    689         guard name.hasPrefix(prefix) else { return nil }
    690         let rest = name.dropFirst(prefix.count)
    691         let uuidLength = 36
    692         guard rest.count > uuidLength,
    693               rest[rest.index(rest.startIndex, offsetBy: uuidLength)] == "-"
    694         else { return nil }
    695         let uuidPart = String(rest[rest.startIndex..<rest.index(rest.startIndex, offsetBy: uuidLength)])
    696         guard let gameID = UUID(uuidString: uuidPart) else { return nil }
    697         let afterUUID = rest.index(rest.startIndex, offsetBy: uuidLength + 1)
    698         let tail = rest[afterUUID...]
    699         guard let lastDash = tail.lastIndex(of: "-") else { return nil }
    700         let authorID = String(tail[tail.startIndex..<lastDash])
    701         let deviceID = String(tail[tail.index(after: lastDash)...])
    702         guard !authorID.isEmpty, !deviceID.isEmpty else { return nil }
    703         return (gameID, authorID, deviceID)
    704     }
    705 
    706     // MARK: - Applying incoming CKRecords to Core Data
    707 
    708     /// Returns the `GameEntity` for `gameID`, creating an unpopulated stub if
    709     /// none exists yet. Moves and Player records can arrive in a different
    710     /// fetch batch than the Game record that created the zone — on
    711     /// a fresh device CKSyncEngine paginates the initial pull and there is no
    712     /// guarantee that Game comes first. Without this stub the parent lookup
    713     /// fails, the inbound record is dropped, but CKSyncEngine still advances
    714     /// its change token, so the gap is invisible until the next state reset.
    715     /// The stub uses empty `title` / `puzzleSource` so `GameSummary.init?`
    716     /// filters it out of the library until `applyGameRecord` arrives with
    717     /// the real metadata and updates the same row (matched by `ckRecordName`).
    718     static func ensureGameEntity(
    719         forGameID gameID: UUID,
    720         zoneID: CKRecordZone.ID,
    721         in ctx: NSManagedObjectContext
    722     ) -> GameEntity {
    723         let name = recordName(forGameID: gameID)
    724         let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    725         req.predicate = NSPredicate(format: "ckRecordName == %@", name)
    726         req.fetchLimit = 1
    727         if let existing = try? ctx.fetch(req).first { return existing }
    728         let entity = GameEntity(context: ctx)
    729         entity.id = gameID
    730         entity.ckRecordName = name
    731         entity.ckZoneName = zoneID.zoneName
    732         let ownerName = zoneID.ownerName
    733         let isOwner = ownerName == CKCurrentUserDefaultName
    734         entity.ckZoneOwnerName = isOwner ? nil : ownerName
    735         entity.databaseScope = isOwner ? 0 : 1
    736         entity.title = ""
    737         entity.puzzleSource = ""
    738         entity.createdAt = Date()
    739         entity.updatedAt = Date()
    740         return entity
    741     }
    742 
    743     static func applyGameRecord(
    744         _ record: CKRecord,
    745         to context: NSManagedObjectContext,
    746         databaseScope: Int16 = 0,
    747         onEngagementChange: ((UUID) -> Void)? = nil,
    748         onCompletedTransition: ((UUID) -> Void)? = nil,
    749         onContentKeyChange: ((UUID) -> Void)? = nil
    750     ) -> GameEntity {
    751         let recordName = record.recordID.recordName
    752         let entity = fetchOrCreate(
    753             entityName: "GameEntity",
    754             recordName: recordName,
    755             in: context
    756         ) as! GameEntity
    757 
    758         // Recover the UUID from the record name ("game-<UUID>") so the
    759         // library query, which filters on `entity.id`, doesn't silently drop
    760         // newly-synced games.
    761         if entity.id == nil {
    762             let uuidString = String(recordName.dropFirst("game-".count))
    763             entity.id = UUID(uuidString: uuidString)
    764         }
    765 
    766         // Drop fetched snapshots older than what we already have; adopting
    767         // them downgrades the local etag and OpLock-fails the next save
    768         // (same rationale as `applyMovesRecord` / `applyPlayerRecord`).
    769         if entity.ckSystemFields != nil,
    770            !incomingIsAtLeastAsFresh(record, existingFields: entity.ckSystemFields) {
    771             return entity
    772         }
    773 
    774         // Always adopt the fresher etag and zone identity so the next outbound
    775         // push uses a current change tag and routes to the right zone.
    776         entity.ckRecordName = recordName
    777         entity.ckSystemFields = encodeSystemFields(of: record)
    778         entity.ckZoneName = record.recordID.zoneID.zoneName
    779         let ownerName = record.recordID.zoneID.ownerName
    780         entity.ckZoneOwnerName = ownerName == CKCurrentUserDefaultName ? nil : ownerName
    781         entity.databaseScope = databaseScope
    782 
    783         // Local mutable fields take precedence while a push is in flight.
    784         // The flag is set atomically with the local write (in `markCompleted`,
    785         // `resignGame`, `persistShareName`) and cleared once `SyncEngine`
    786         // confirms the push landed. Writing server values here would clobber
    787         // the pending change and the next outbound push would then serialise
    788         // the clobbered value, permanently losing it server-side.
    789         guard !entity.hasPendingSave else { return entity }
    790 
    791         // Seed createdAt/updatedAt from the server record only on first sight,
    792         // so a newly-arrived game has something for the library to order by.
    793         // After that, the library timestamp tracks *gameplay* (Moves) alone.
    794         // The Game record's modificationDate advances on non-gameplay writes
    795         // too — engagement/push credentials, share metadata, the notification
    796         // field — so adopting it on every fetch made a game look freshly
    797         // "updated" when nothing was played (e.g. a peer, or this device,
    798         // merely moved the cursor or re-minted a credential). Gameplay flows
    799         // through the Moves path, which sets updatedAt independently; the
    800         // winning move is itself a move, so completion still advances it.
    801         if entity.createdAt == nil {
    802             entity.createdAt = record.creationDate ?? Date()
    803         }
    804         if entity.updatedAt == nil {
    805             entity.updatedAt = record.modificationDate ?? Date()
    806         }
    807 
    808         entity.title = record["title"] as? String ?? entity.title
    809         // Capture the prior completion state before overwriting it: a
    810         // not-completed → completed transition learned purely via sync (this
    811         // device wasn't present when the puzzle was finished) does NOT run the
    812         // local completion path, so it never uploads this device's journal.
    813         // Replay's strict completeness would then wait on it forever. Signal
    814         // the transition so the caller can enqueue the upload.
    815         let wasCompleted = entity.completedAt != nil
    816         entity.completedAt = record["completedAt"] as? Date
    817         entity.completedBy = record["completedBy"] as? String
    818         if !wasCompleted, entity.completedAt != nil, let id = entity.id {
    819             onCompletedTransition?(id)
    820         }
    821         // Owner-side share marker — set on the device that created the share
    822         // and round-tripped via the Game record so other owner-devices learn
    823         // the game is shared. On participant devices `databaseScope == 1`
    824         // already implies shared, but keeping the field in sync is harmless.
    825         if let shareRecordName = record["shareRecordName"] as? String {
    826             entity.ckShareRecordName = shareRecordName
    827         }
    828 
    829         // Adopt the engagement creds (skipped above while a local mint is
    830         // still pushing, via the hasPendingSave guard). A change here is the
    831         // signal a peer minted/rotated the room, so the receiver reconciles
    832         // its live connection toward the new creds.
    833         let incomingEngagement = record["engagement"] as? String
    834         if entity.engagement != incomingEngagement {
    835             entity.engagement = incomingEngagement
    836             if let id = entity.id { onEngagementChange?(id) }
    837         }
    838 
    839         // Adopt the shared notification credentials. The credential is read
    840         // lazily at registration/publish time, so converging the field is
    841         // enough — but a change may carry a new embedded content key, so fire
    842         // `onContentKeyChange` to re-mirror the App Group key directory the NSE
    843         // reads. This is what lets a freshly-joined participant decrypt the
    844         // first push it receives, rather than waiting for the next launch heal.
    845         let incomingNotification = record["notification"] as? String
    846         if entity.notification != incomingNotification {
    847             entity.notification = incomingNotification
    848             if let id = entity.id { onContentKeyChange?(id) }
    849         }
    850 
    851         if let asset = record["puzzleSource"] as? CKAsset,
    852            let fileURL = asset.fileURL {
    853             do {
    854                 let source = try String(contentsOf: fileURL, encoding: .utf8)
    855                 entity.puzzleSource = source
    856                 if let xd = try? XD.parse(source) {
    857                     let puzzle = Puzzle(xd: xd)
    858                     entity.puzzleCmVersion = Int64(XD.currentCmVersion)
    859                     // The title is always derived from the puzzle content (there
    860                     // is no custom game title), so trust the asset over the
    861                     // record's `title` field — which was already applied above.
    862                     // This re-derives the real title even when `record["title"]`
    863                     // carried a stale value, e.g. a participant's transient
    864                     // "Joining…" placeholder that a prior build wrote to the
    865                     // shared record, so the title self-heals on the next sync
    866                     // that carries the asset.
    867                     entity.title = puzzle.title
    868                     entity.populateCachedSummaryFields(from: puzzle)
    869                 }
    870             } catch {
    871                 // CKSyncEngine has already committed this batch by the time
    872                 // the delegate returns, so re-throwing wouldn't redeliver.
    873                 // Surface the dropped puzzle source instead of silently
    874                 // leaving the entity without playable content.
    875                 let nsError = error as NSError
    876                 print(
    877                     "RecordSerializer: puzzleSource asset read failed for \(recordName) " +
    878                     "— domain=\(nsError.domain) code=\(nsError.code) " +
    879                     "\(nsError.localizedDescription)"
    880                 )
    881             }
    882         }
    883 
    884         return entity
    885     }
    886 
    887     /// Upserts the `MovesEntity` for `value`. The cells blob is taken straight
    888     /// off the record so any forward-compat fields the encoder added are
    889     /// preserved verbatim. Bumps the parent `GameEntity.updatedAt` if the
    890     /// record is fresher. Returns `true` when cells/updatedAt were adopted,
    891     /// `false` when the local-device-row guard short-circuited the body so
    892     /// callers can skip the downstream grid refresh.
    893     ///
    894     /// `onNewAuthor` fires with the authorID when this record creates the first
    895     /// `MovesEntity` row by a *remote* contributor — i.e. a participant the
    896     /// roster can only discover from their moves (no `Player` record yet, see
    897     /// `PlayerRoster.refresh`). Callers use it to trigger a one-off roster
    898     /// refresh on a new collaborator's first move without refreshing on every
    899     /// subsequent keystroke or sibling-device row.
    900     @discardableResult
    901     static func applyMovesRecord(
    902         _ record: CKRecord,
    903         value: MovesValue,
    904         to ctx: NSManagedObjectContext,
    905         localAuthorID: String? = nil,
    906         onNewAuthor: ((String) -> Void)? = nil
    907     ) -> Bool {
    908         let ckName = record.recordID.recordName
    909         let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity")
    910         req.predicate = NSPredicate(format: "ckRecordName == %@", ckName)
    911         req.fetchLimit = 1
    912 
    913         let entity: MovesEntity
    914         let foundExisting: Bool
    915         let authorAlreadyKnown: Bool
    916         if let existing = try? ctx.fetch(req).first {
    917             entity = existing
    918             foundExisting = true
    919             authorAlreadyKnown = true
    920         } else {
    921             let authorReq = NSFetchRequest<MovesEntity>(entityName: "MovesEntity")
    922             authorReq.predicate = NSPredicate(
    923                 format: "game.id == %@ AND authorID == %@",
    924                 value.gameID as CVarArg,
    925                 value.authorID
    926             )
    927             authorReq.fetchLimit = 1
    928             authorAlreadyKnown = ((try? ctx.fetch(authorReq).first) != nil)
    929 
    930             let game = ensureGameEntity(
    931                 forGameID: value.gameID,
    932                 zoneID: record.recordID.zoneID,
    933                 in: ctx
    934             )
    935             entity = MovesEntity(context: ctx)
    936             entity.game = game
    937             foundExisting = false
    938         }
    939 
    940         // Drop fetched snapshots that are older than what we already have.
    941         // The writeback after a successful push advances `ckSystemFields` to
    942         // the latest server etag; a query that started before that push
    943         // landed can return the prior server state, and adopting it here
    944         // would downgrade the etag and OpLock-fail the next save.
    945         if foundExisting,
    946            !incomingIsAtLeastAsFresh(record, existingFields: entity.ckSystemFields) {
    947             return false
    948         }
    949 
    950         // Adopt system fields so future saves target the server's current
    951         // change tag. If this is our own per-device row and it already
    952         // exists locally, the local value state is authoritative; tokenless
    953         // push-driven direct fetches can re-deliver an older server copy while
    954         // newer edits are still queued for upload.
    955         entity.ckRecordName = ckName
    956         entity.ckSystemFields = encodeSystemFields(of: record)
    957         entity.authorID = value.authorID
    958         entity.deviceID = value.deviceID
    959         let isLocalDeviceRow = value.authorID == localAuthorID
    960             && value.deviceID == localDeviceID
    961         guard !foundExisting || !isLocalDeviceRow else { return false }
    962         let previousUpdatedAt = entity.updatedAt ?? .distantPast
    963         let previousCells = (entity.cells.flatMap { try? MovesCodec.decode($0) }) ?? [:]
    964         let mergedCells = mergeIncomingMovesCells(
    965             existing: previousCells,
    966             incoming: value.cells
    967         )
    968         let mergedUpdatedAt = max(
    969             previousUpdatedAt,
    970             value.updatedAt,
    971             mergedCells.values.map(\.updatedAt).max() ?? .distantPast
    972         )
    973 
    974         entity.updatedAt = mergedUpdatedAt
    975         entity.cells = (try? MovesCodec.encode(mergedCells)) ?? ((record["cells"] as? Data) ?? Data())
    976 
    977         if let game = entity.game,
    978            game.updatedAt.map({ $0 < mergedUpdatedAt }) ?? true {
    979             game.updatedAt = mergedUpdatedAt
    980         }
    981         // A newly-seen remote author is the roster's only cue to
    982         // a contributor who hasn't published a `Player` record yet. Signal it
    983         // once here; repeat moves and sibling-device rows by a known author
    984         // don't.
    985         if !foundExisting,
    986            !authorAlreadyKnown,
    987            value.authorID != localAuthorID,
    988            value.authorID != CKCurrentUserDefaultName,
    989            !value.authorID.isEmpty {
    990             onNewAuthor?(value.authorID)
    991         }
    992         return true
    993     }
    994 
    995     private static func mergeIncomingMovesCells(
    996         existing: [GridPosition: TimestampedCell],
    997         incoming: [GridPosition: TimestampedCell]
    998     ) -> [GridPosition: TimestampedCell] {
    999         var cells = existing
   1000         for (position, incomingCell) in incoming {
   1001             if let existingCell = cells[position],
   1002                existingCell.updatedAt > incomingCell.updatedAt {
   1003                 continue
   1004             }
   1005             cells[position] = incomingCell
   1006         }
   1007         return cells
   1008     }
   1009 
   1010     /// Projects an inbound `Decision` record onto local Core Data. For
   1011     /// `kind == "block"` this upserts a `FriendEntity` tombstone keyed by the
   1012     /// blocked author so the block becomes authoritative across the user's own
   1013     /// devices: `applyInvitePings` (authorID-keyed) and the friendship
   1014     /// bootstrap's `friendExists` (pairKey-keyed) both then suppress the
   1015     /// blocked collaborator everywhere. A device that never befriended the
   1016     /// author still gets a minimal blocked row. Returns `true` when a row was
   1017     /// written. `localAuthorID` lets the pairKey be derived for the bootstrap
   1018     /// short-circuit; it's deterministic from the unordered author pair.
   1019     @discardableResult
   1020     static func applyDecisionRecord(
   1021         _ record: CKRecord,
   1022         to ctx: NSManagedObjectContext,
   1023         localAuthorID: String?,
   1024         databaseScope: Int16 = 0
   1025     ) -> Bool {
   1026         guard record.recordType == "Decision" else { return false }
   1027         // Identity comes from the record name (always present, immutable);
   1028         // `kind` is also mirrored as a field, name-parse as the fallback.
   1029         let parsed = parseDecisionRecordName(record.recordID.recordName)
   1030         guard let kind = (record["kind"] as? String) ?? parsed?.kind,
   1031               let key = parsed?.key,
   1032               !kind.isEmpty, !key.isEmpty
   1033         else { return false }
   1034 
   1035         switch kind {
   1036         case "block":
   1037             let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity")
   1038             req.predicate = NSPredicate(format: "authorID == %@", key)
   1039             req.fetchLimit = 1
   1040             let friend = (try? ctx.fetch(req).first) ?? FriendEntity(context: ctx)
   1041             friend.authorID = key
   1042             friend.isBlocked = true
   1043             if friend.pairKey == nil,
   1044                let localAuthorID, !localAuthorID.isEmpty {
   1045                 friend.pairKey = FriendZone.pairKey(localAuthorID, key)
   1046             }
   1047             if friend.createdAt == nil { friend.createdAt = Date() }
   1048             return true
   1049         case "left":
   1050             // The user left this shared game on another of their devices.
   1051             // Hard-delete the local row so it stops hanging around (the
   1052             // shared-zone deletion alone would only flag it access-revoked,
   1053             // see SyncEngine.handleFetchedDatabaseChanges). Idempotent: a
   1054             // re-applied decision after the row is gone is a no-op. Guarded
   1055             // to participant rows (databaseScope == 1) so a same-id owned
   1056             // copy is never collateral.
   1057             guard let gameID = UUID(uuidString: key) else { return false }
   1058             let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
   1059             req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
   1060             req.fetchLimit = 1
   1061             guard let entity = try? ctx.fetch(req).first,
   1062                   entity.databaseScope == 1 else { return false }
   1063             ctx.delete(entity)
   1064             return true
   1065         case nameDecisionKind:
   1066             // A friend's display name, read out of the pairwise friend zone.
   1067             // Our own copy (key == localAuthorID, account zone) carries no
   1068             // Core Data projection — the local name lives in
   1069             // `PlayerPreferences`; only its version is adopted, by the caller.
   1070             guard let localAuthorID, !localAuthorID.isEmpty,
   1071                   key != localAuthorID,
   1072                   let name = record["payload"] as? String,
   1073                   !name.isEmpty
   1074             else { return false }
   1075             // Provenance: both participants can write into a friend zone, so
   1076             // a name Decision is only honored when the zone is *the* pairwise
   1077             // zone for (us, key) — the zone name embeds a hash of the author
   1078             // pair, so the claimed subject is verifiable without trusting the
   1079             // record. This also rejects a name Decision for a third party
   1080             // misdelivered into an unrelated zone.
   1081             let pairKey = FriendZone.pairKey(localAuthorID, key)
   1082             let zoneID = record.recordID.zoneID
   1083             guard zoneID.zoneName == FriendZone.zoneName(pairKey: pairKey) else {
   1084                 return false
   1085             }
   1086             let version = decisionVersion(record)
   1087             let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity")
   1088             req.predicate = NSPredicate(format: "pairKey == %@", pairKey)
   1089             req.fetchLimit = 1
   1090             if let friend = try? ctx.fetch(req).first {
   1091                 guard !friend.isBlocked,
   1092                       version >= friend.displayNameVersion
   1093                 else { return false }
   1094                 friend.displayName = name
   1095                 friend.displayNameVersion = version
   1096                 return true
   1097             }
   1098             // No local row: the bootstrap evidence (game zones, `.friend`
   1099             // Ping) may be long gone on a restored device, but the name
   1100             // Decision arriving from a live friend zone is itself proof of
   1101             // the friendship — resurrect the row from the zone it rode in on.
   1102             let friend = FriendEntity(context: ctx)
   1103             friend.authorID = key
   1104             friend.pairKey = pairKey
   1105             friend.friendZoneName = zoneID.zoneName
   1106             friend.friendZoneOwnerName = zoneID.ownerName
   1107             friend.databaseScope = databaseScope
   1108             friend.createdAt = Date()
   1109             friend.displayName = name
   1110             friend.displayNameVersion = version
   1111             return true
   1112         case nicknameDecisionKind:
   1113             // The user's private nickname for a friend, authoritative across
   1114             // their own devices. Honored only from the account zone in our own
   1115             // private database: friend zones are writable by the other
   1116             // participant, who must not be able to relabel people in this
   1117             // user's friends list. Match on zone *name* + private scope, not a
   1118             // full `zoneID ==`: a record fetched back from CloudKit does not
   1119             // reliably carry the `CKCurrentUserDefaultName` owner placeholder
   1120             // `accountZoneID` is built with — its `ownerName` often comes back
   1121             // as the concrete user-record ID, so an `==` silently rejects every
   1122             // synced nickname. Scoping to the private DB keeps the anti-relabel
   1123             // guarantee (a friend can only reach us through the shared DB).
   1124             guard record.recordID.zoneID.zoneName == accountZoneID.zoneName,
   1125                   databaseScope == 0
   1126             else { return false }
   1127             let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity")
   1128             req.predicate = NSPredicate(format: "authorID == %@", key)
   1129             req.fetchLimit = 1
   1130             // No resurrection: unlike a name Decision, an account-zone row
   1131             // carries no zone provenance to rebuild a usable friendship from,
   1132             // and a zoneless row would surface as an uninvitable friend.
   1133             guard let friend = try? ctx.fetch(req).first else { return false }
   1134             let version = decisionVersion(record)
   1135             guard version >= friend.nicknameVersion else { return false }
   1136             let nickname = (record["payload"] as? String)?
   1137                 .trimmingCharacters(in: .whitespacesAndNewlines)
   1138             // Empty/absent payload is a deliberate clear, not a malformed
   1139             // record — the rename alert's blank entry reverts to their name.
   1140             friend.nickname = (nickname?.isEmpty == false) ? nickname : nil
   1141             friend.nicknameVersion = version
   1142             return true
   1143         default:
   1144             // Unknown kind from a newer build — ignore rather than guess.
   1145             return false
   1146         }
   1147     }
   1148 
   1149     // MARK: - System fields encode/decode
   1150 
   1151     static func encodeSystemFields(of record: CKRecord) -> Data? {
   1152         let coder = NSKeyedArchiver(requiringSecureCoding: true)
   1153         record.encodeSystemFields(with: coder)
   1154         coder.finishEncoding()
   1155         return coder.encodedData
   1156     }
   1157 
   1158     static func decodeRecord(from data: Data) -> CKRecord? {
   1159         guard let coder = try? NSKeyedUnarchiver(forReadingFrom: data) else { return nil }
   1160         coder.requiresSecureCoding = true
   1161         let record = CKRecord(coder: coder)
   1162         coder.finishDecoding()
   1163         return record
   1164     }
   1165 
   1166     /// Returns `true` when `incoming` reflects a server state at least as
   1167     /// recent as the modification date encoded in `existingFields`. Used by
   1168     /// the apply paths to drop fetched snapshots that arrive after our
   1169     /// writeback has already adopted a newer change tag — adopting them
   1170     /// would downgrade the local etag and the next save would OpLock-fail.
   1171     /// Defaults to `true` when either side lacks a modification date so a
   1172     /// first-time fetch can land.
   1173     static func incomingIsAtLeastAsFresh(
   1174         _ incoming: CKRecord,
   1175         existingFields: Data?
   1176     ) -> Bool {
   1177         guard let existingFields,
   1178               let existingRecord = decodeRecord(from: existingFields),
   1179               let existingDate = existingRecord.modificationDate,
   1180               let incomingDate = incoming.modificationDate
   1181         else { return true }
   1182         return incomingDate >= existingDate
   1183     }
   1184 
   1185     // MARK: - Private helpers
   1186 
   1187     /// Restores a `CKRecord` from archived system fields (preserving the
   1188     /// server change tag) or creates a fresh one if no archive is available.
   1189     private static func restoreOrCreate(
   1190         recordType: String,
   1191         recordName: String,
   1192         zone: CKRecordZone.ID,
   1193         systemFields: Data?
   1194     ) -> CKRecord {
   1195         if let data = systemFields, let restored = decodeRecord(from: data) {
   1196             return restored
   1197         }
   1198         let recordID = CKRecord.ID(recordName: recordName, zoneID: zone)
   1199         return CKRecord(recordType: recordType, recordID: recordID)
   1200     }
   1201 
   1202     private static func fetchOrCreate(
   1203         entityName: String,
   1204         recordName: String,
   1205         in context: NSManagedObjectContext
   1206     ) -> NSManagedObject {
   1207         let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
   1208         request.predicate = NSPredicate(format: "ckRecordName == %@", recordName)
   1209         request.fetchLimit = 1
   1210         if let existing = try? context.fetch(request).first {
   1211             return existing
   1212         }
   1213         return NSEntityDescription.insertNewObject(forEntityName: entityName, into: context)
   1214     }
   1215 
   1216 }