crossmate

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

RecordSerializer.swift (78687B)


      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         "roomCredential",
     18         "pushCredential",
     19         "puzzleSource",
     20         "syncVersion",
     21     ]
     22 
     23     // authorID/deviceID are not stored as fields — they're recovered from the
     24     // record name (`moves-<gameID>-<authorID>-<deviceID>`).
     25     static let movesDesiredKeys: [CKRecord.FieldKey] = [
     26         "cells",
     27         "updatedAt",
     28     ]
     29 
     30     // authorID is recovered from the record name (`player-<gameID>-<authorID>`).
     31     static let playerDesiredKeys: [CKRecord.FieldKey] = [
     32         "name",
     33         "updatedAt",
     34         "selRow",
     35         "selCol",
     36         "selDir",
     37         "presenceUntil",
     38         "readThrough",
     39         "viewedAt",
     40         "timeLog",
     41         "pushAddress",
     42     ]
     43 
     44     // authorID/deviceID are recovered from the record name.
     45     static let pingDesiredKeys: [CKRecord.FieldKey] = [
     46         "playerName",
     47         "puzzleTitle",
     48         "kind",
     49         "payload",
     50         "addressee",
     51     ]
     52 
     53     static let pingDeletionDesiredKeys: [CKRecord.FieldKey] = [
     54         "kind",
     55     ]
     56 
     57     // MARK: - Device identity
     58 
     59     /// A stable per-device identifier appended to move and snapshot record
     60     /// names to prevent two devices owned by the same iCloud user from
     61     /// producing identical record names when both assign the same Lamport
     62     /// clock value while offline.
     63     ///
     64     /// Stored in UserDefaults so it survives app restarts but resets on
     65     /// reinstall (which is fine — a reinstalled app has no local moves to
     66     /// conflict with).
     67     static let localDeviceID: String = {
     68         let key = "crossmate.localDeviceID"
     69         if let stored = UserDefaults.standard.string(forKey: key) {
     70             return stored
     71         }
     72         let new = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased()
     73         UserDefaults.standard.set(new, forKey: key)
     74         return new
     75     }()
     76 
     77     // MARK: - Record names
     78 
     79     static func recordName(forGameID gameID: UUID) -> String {
     80         "game-\(gameID.uuidString)"
     81     }
     82 
     83     /// Recovers the game UUID from a `"game-<UUID>"` record or zone name — the
     84     /// inverse of `recordName(forGameID:)`. Returns nil when the name isn't a
     85     /// game name or the UUID doesn't parse. A game's zone name and its root
     86     /// record name are identical, so this also resolves a share's zone.
     87     static func gameID(fromGameRecordName name: String) -> UUID? {
     88         guard name.hasPrefix("game-") else { return nil }
     89         return UUID(uuidString: String(name.dropFirst("game-".count)))
     90     }
     91 
     92     /// One Moves record per `(game, authorID, deviceID)`. Each device only
     93     /// writes to its own slot, so there are no write-write conflicts on the
     94     /// `cells` field.
     95     static func recordName(
     96         forMovesInGame gameID: UUID,
     97         authorID: String,
     98         deviceID: String
     99     ) -> String {
    100         "moves-\(gameID.uuidString)-\(authorID)-\(deviceID)"
    101     }
    102 
    103     /// One Journal record per `(game, authorID, deviceID)` — this device's
    104     /// whole local move log, uploaded once at completion (Phase 2). Same
    105     /// `(game, author, device)` shape as the Moves record so collaborators'
    106     /// uploads stay distinct and mergeable by timestamp for replay.
    107     static func recordName(
    108         forJournalInGame gameID: UUID,
    109         authorID: String,
    110         deviceID: String
    111     ) -> String {
    112         "journal-\(gameID.uuidString)-\(authorID)-\(deviceID)"
    113     }
    114 
    115     /// One player record per (game, author). Each participant only ever
    116     /// writes to their own slot, so there are no write-write conflicts on
    117     /// the field.
    118     static func recordName(forPlayerInGame gameID: UUID, authorID: String) -> String {
    119         "player-\(gameID.uuidString)-\(authorID)"
    120     }
    121 
    122     /// One Ping record per event. `deviceID` keeps cross-device writes from
    123     /// the same iCloud user unique (authorID is identical across that user's
    124     /// devices), and the event timestamp covers repeated pings from the same
    125     /// device.
    126     static func recordName(
    127         forPingInGame gameID: UUID,
    128         authorID: String,
    129         deviceID: String,
    130         eventTimestampMs: Int64
    131     ) -> String {
    132         "ping-\(gameID.uuidString)-\(authorID)-\(deviceID)-\(eventTimestampMs)"
    133     }
    134 
    135     /// One `Decision` record per `(kind, key)`. A durable, per-user fact that
    136     /// must agree across a single iCloud user's own devices — the durable
    137     /// counterpart to the transient `Ping`. Lives in the account zone; the
    138     /// deterministic name makes every write an idempotent upsert. `kind`
    139     /// carries no dashes (so the first dash after the prefix splits cleanly);
    140     /// `key` may contain dashes.
    141     static func decisionRecordName(kind: String, key: String) -> String {
    142         "decision-\(kind)-\(key)"
    143     }
    144 
    145     /// Parses `decision-<kind>-<key>`. `kind` is the segment up to the first
    146     /// dash after the prefix; `key` is the remainder.
    147     static func parseDecisionRecordName(_ name: String) -> (kind: String, key: String)? {
    148         let prefix = "decision-"
    149         guard name.hasPrefix(prefix) else { return nil }
    150         let rest = name.dropFirst(prefix.count)
    151         guard let dash = rest.firstIndex(of: "-") else { return nil }
    152         let kind = String(rest[rest.startIndex..<dash])
    153         let key = String(rest[rest.index(after: dash)...])
    154         guard !kind.isEmpty, !key.isEmpty else { return nil }
    155         return (kind, key)
    156     }
    157 
    158     /// Kind for the display-name Decision: `decision-name-<authorID>`, payload
    159     /// = the display name, `version` = the author's monotonic rename
    160     /// generation. The author writes their own copy into their account zone
    161     /// (own-device convergence and restore durability) and into every friend
    162     /// zone they participate in (the friend's devices read it from there) —
    163     /// names never sync through any other channel.
    164     static let nameDecisionKind = "name"
    165 
    166     static func nameDecisionName(authorID: String) -> String {
    167         decisionRecordName(kind: nameDecisionKind, key: authorID)
    168     }
    169 
    170     /// Parses a display-name Decision into its subject author, name, and
    171     /// version. Returns `nil` for any other decision or an empty payload.
    172     static func parseNameDecision(
    173         _ record: CKRecord
    174     ) -> (authorID: String, name: String, version: Int64)? {
    175         guard record.recordType == "Decision",
    176               let (kind, key) = parseDecisionRecordName(record.recordID.recordName),
    177               kind == nameDecisionKind,
    178               ((record["kind"] as? String) ?? nameDecisionKind) == nameDecisionKind,
    179               let name = record["payload"] as? String,
    180               !name.isEmpty
    181         else { return nil }
    182         return (key, name, decisionVersion(record))
    183     }
    184 
    185     /// Kind for the friend-nickname Decision: `decision-nickname-<authorID>`,
    186     /// payload = the nickname this user privately calls that friend (absent or
    187     /// empty = cleared, fall back to the friend's own name), `version` = this
    188     /// user's monotonic rename generation for that friend. Lives only in the
    189     /// account zone — it's the user's own label, never shared with the friend.
    190     static let nicknameDecisionKind = "nickname"
    191     static let encryptionKeyDecisionKind = "encryptionKey"
    192 
    193     /// Kind for the friend-block Decision: `decision-block-<authorID>`, payload
    194     /// `"1"` blocked / `"0"` unblocked, `version` = this user's monotonic block
    195     /// generation for that friend. Lives only in the account zone and converges
    196     /// the *UI* flag across the user's own devices — the actual enforcement is
    197     /// the server-side `.readOnly` downgrade on the inbox share, which is
    198     /// inherently account-wide.
    199     static let blockDecisionKind = "block"
    200 
    201     static let accountDecisionKind = "account"
    202     static let accountPushAddressDecisionKey = "pushAddress"
    203     /// Key for the account-wide push *secret* decision. The secret is the HMAC
    204     /// key from which every per-game push address is derived (see
    205     /// `deriveGameAddress`); it converges across the account's own devices the
    206     /// same way the account address does, and is never sent to peers or the
    207     /// push worker — only the derived per-game addresses are.
    208     static let accountPushSecretDecisionKey = "pushSecret"
    209 
    210     static var accountPushAddressDecisionName: String {
    211         decisionRecordName(kind: accountDecisionKind, key: accountPushAddressDecisionKey)
    212     }
    213 
    214     static var accountPushSecretDecisionName: String {
    215         decisionRecordName(kind: accountDecisionKind, key: accountPushSecretDecisionKey)
    216     }
    217 
    218     /// A push-address/secret Decision is authoritative only from *this
    219     /// account's* zone in the private database. Match on zone name + private
    220     /// scope, mirroring the `nickname` case in `applyDecisionRecord`: a friend
    221     /// holds `.readWrite` on our private `friend-<pairKey>` inbox (also scope
    222     /// 0), so without the zone-name check a forged `decision-account-pushSecret`
    223     /// dropped there would be adopted as our own HMAC secret and re-derive every
    224     /// per-game push address. An `==` on the full `zoneID` is unreliable — a
    225     /// record fetched back from CloudKit often loses the
    226     /// `CKCurrentUserDefaultName` owner placeholder — so match the name.
    227     static func isAccountZonePrivateDecision(
    228         _ record: CKRecord,
    229         databaseScope: DatabaseScope
    230     ) -> Bool {
    231         record.recordID.zoneID.zoneName == accountZoneID.zoneName
    232             && databaseScope == .private
    233     }
    234 
    235     static func parseAccountPushAddressDecision(
    236         _ record: CKRecord,
    237         databaseScope: DatabaseScope
    238     ) -> String? {
    239         guard record.recordType == "Decision",
    240               record.recordID.recordName == accountPushAddressDecisionName,
    241               (record["kind"] as? String) == accountDecisionKind,
    242               isAccountZonePrivateDecision(record, databaseScope: databaseScope),
    243               let address = record["payload"] as? String,
    244               !address.isEmpty
    245         else { return nil }
    246         return address
    247     }
    248 
    249     /// Default generation for a `version`-less Decision — any record written by
    250     /// the pre-rotation code. Matched to the value a fresh mint uses so legacy
    251     /// and freshly-minted secrets share a generation and converge via the
    252     /// equal-version "server wins" rule, while a deliberate rotation (2+)
    253     /// supersedes them. Mapping to 0 instead would let the first post-update
    254     /// mint clobber an already-converged legacy secret.
    255     static let decisionBaseVersion: Int64 = 1
    256 
    257     /// The monotonic generation of a Decision. Higher wins: an inbound or
    258     /// conflicting record at a higher version supersedes the local value; equal
    259     /// versions converge on whoever reached the server first. Absent (legacy)
    260     /// records report `decisionBaseVersion`.
    261     static func decisionVersion(_ record: CKRecord) -> Int64 {
    262         (record["version"] as? Int64) ?? decisionBaseVersion
    263     }
    264 
    265     static func parseAccountPushSecretDecision(
    266         _ record: CKRecord,
    267         databaseScope: DatabaseScope
    268     ) -> (secret: String, version: Int64)? {
    269         guard record.recordType == "Decision",
    270               record.recordID.recordName == accountPushSecretDecisionName,
    271               (record["kind"] as? String) == accountDecisionKind,
    272               isAccountZonePrivateDecision(record, databaseScope: databaseScope),
    273               let secret = record["payload"] as? String,
    274               !secret.isEmpty
    275         else { return nil }
    276         return (secret, decisionVersion(record))
    277     }
    278 
    279     /// Derives this account's push address for one game as
    280     /// `HMAC-SHA256(secret, gameID)`, base64url-encoded. Deterministic, so every
    281     /// one of the account's devices computes the identical address for a game
    282     /// without any negotiation, and per-game scoped: a peer holding one game's
    283     /// address can't compute another's without the secret, which never leaves
    284     /// the account's devices. Rotation is by changing the secret.
    285     static func deriveGameAddress(secret: String, gameID: UUID) -> String {
    286         let key = SymmetricKey(data: Data(secret.utf8))
    287         let mac = HMAC<SHA256>.authenticationCode(
    288             for: Data(gameID.uuidString.utf8),
    289             using: key
    290         )
    291         return Data(mac).base64EncodedString()
    292             .replacingOccurrences(of: "+", with: "-")
    293             .replacingOccurrences(of: "/", with: "_")
    294             .replacingOccurrences(of: "=", with: "")
    295     }
    296 
    297     // MARK: - Zone
    298 
    299     /// Zone ID for a per-game zone. `ownerName` defaults to the current user
    300     /// placeholder; pass an explicit value for shared games where the zone is
    301     /// owned by another iCloud account.
    302     static func zoneID(
    303         for gameID: UUID,
    304         ownerName: String = CKCurrentUserDefaultName
    305     ) -> CKRecordZone.ID {
    306         CKRecordZone.ID(zoneName: "game-\(gameID.uuidString)", ownerName: ownerName)
    307     }
    308 
    309     // MARK: - Inbound record identity
    310 
    311     /// The record types that live in per-game zones (plus the archive zone)
    312     /// and therefore must pass `isTrustedGameScopedRecord` /
    313     /// `isTrustedGameScopedDeletion` before touching a game-scoped apply
    314     /// path. Everything else (Decision, share metadata, …) has its own
    315     /// zone/scope gates.
    316     static func isGameScopedRecordType(_ type: CKRecord.RecordType) -> Bool {
    317         switch type {
    318         case "Game", "Moves", "Player", "Ping", "Journal",
    319              Archive.recordType, Archive.legacyRecordType:
    320             return true
    321         default:
    322             return false
    323         }
    324     }
    325 
    326     /// CloudKit reports the creator of a record that the *fetching* user
    327     /// created themselves as the `CKCurrentUserDefaultName` placeholder, not
    328     /// their concrete user-record name — so the placeholder must be accepted
    329     /// alongside the claimed author, or every self-authored record is
    330     /// rejected when it round-trips (fresh install, second device, replay).
    331     /// This does not weaken the impersonation defense: a record created by a
    332     /// *remote* participant always arrives with their concrete creator ID,
    333     /// never the placeholder.
    334     private static func creatorMatches(
    335         _ creatorUserRecordName: String?,
    336         claimedAuthorID: String
    337     ) -> Bool {
    338         creatorUserRecordName == claimedAuthorID
    339             || creatorUserRecordName == CKCurrentUserDefaultName
    340     }
    341 
    342     /// Returns whether a fetched record is safe to route into a game-scoped
    343     /// apply path. Record names are writable by share participants, so they
    344     /// are only an assertion until they agree with CloudKit's zone identity.
    345     ///
    346     /// Moves, Player, Journal, and Ping records additionally claim an author
    347     /// in their name. CloudKit supplies the immutable creator identity for a
    348     /// record; require it to match so a collaborator cannot create a new row
    349     /// in their own name space that impersonates another participant. `Game`
    350     /// has no claimed author — its root record is authenticated by requiring
    351     /// its record name to be exactly the per-game zone name.
    352     static func isTrustedGameScopedRecord(_ record: CKRecord) -> Bool {
    353         isTrustedGameScopedRecord(
    354             record,
    355             creatorUserRecordName: record.creatorUserRecordID?.recordName
    356         )
    357     }
    358 
    359     /// Variant used by tests to exercise CloudKit provenance without needing
    360     /// a server-created CKRecord.
    361     static func isTrustedGameScopedRecord(
    362         _ record: CKRecord,
    363         creatorUserRecordName: String?
    364     ) -> Bool {
    365         let recordID = record.recordID
    366         switch record.recordType {
    367         case "Game":
    368             guard let gameID = gameID(fromGameRecordName: recordID.recordName) else {
    369                 return false
    370             }
    371             return recordID.zoneID.zoneName == recordName(forGameID: gameID)
    372 
    373         case "Moves":
    374             guard let (gameID, authorID, _) = parseMovesRecordName(recordID.recordName) else {
    375                 return false
    376             }
    377             return recordID.zoneID.zoneName == recordName(forGameID: gameID)
    378                 && creatorMatches(creatorUserRecordName, claimedAuthorID: authorID)
    379 
    380         case "Player":
    381             guard let (gameID, authorID) = parsePlayerRecordName(recordID.recordName) else {
    382                 return false
    383             }
    384             return recordID.zoneID.zoneName == recordName(forGameID: gameID)
    385                 && creatorMatches(creatorUserRecordName, claimedAuthorID: authorID)
    386 
    387         case "Journal":
    388             guard let (gameID, authorID, _) = parseJournalRecordName(recordID.recordName) else {
    389                 return false
    390             }
    391             return recordID.zoneID.zoneName == recordName(forGameID: gameID)
    392                 && creatorMatches(creatorUserRecordName, claimedAuthorID: authorID)
    393 
    394         case "Ping":
    395             guard let (gameID, authorID, _) = parsePingRecordName(recordID.recordName),
    396                   creatorMatches(creatorUserRecordName, claimedAuthorID: authorID)
    397             else { return false }
    398             // Friend-zone pings deliberately carry the target game's ID while
    399             // living in a deterministic friend mailbox. Game-zone pings must
    400             // still be confined to the game named by their record.
    401             return !recordID.zoneID.zoneName.hasPrefix("game-")
    402                 || recordID.zoneID.zoneName == recordName(forGameID: gameID)
    403 
    404         case Archive.recordType:
    405             guard let gameID = Archive.originalGameID(fromName: recordID.recordName),
    406                   recordID.recordName == Archive.recordName(forOriginalGameID: gameID),
    407                   recordID.zoneID.zoneName == Archive.zoneName
    408             else { return false }
    409             return true
    410 
    411         case Archive.legacyRecordType:
    412             guard let gameID = Archive.originalGameID(fromName: recordID.recordName),
    413                   recordID.recordName == Archive.legacyRecordName(
    414                     forOriginalGameID: gameID
    415                   ),
    416                   recordID.zoneID.zoneName == Archive.legacyZoneID(
    417                     forOriginalGameID: gameID
    418                   ).zoneName
    419             else { return false }
    420             return true
    421 
    422         default:
    423             return false
    424         }
    425     }
    426 
    427     /// Deletions do not carry creator metadata, but CloudKit does retain their
    428     /// full record identity. Reject a deletion whose name is not confined to
    429     /// the game zone before it can match a local row. Only consulted for
    430     /// `isGameScopedRecordType` types; anything else is out of scope here.
    431     static func isTrustedGameScopedDeletion(
    432         recordID: CKRecord.ID,
    433         recordType: CKRecord.RecordType
    434     ) -> Bool {
    435         let gameID: UUID?
    436         switch recordType {
    437         case "Game":
    438             gameID = Self.gameID(fromGameRecordName: recordID.recordName)
    439         case "Moves":
    440             gameID = parseMovesRecordName(recordID.recordName)?.0
    441         case "Player":
    442             gameID = parsePlayerRecordName(recordID.recordName)?.0
    443         case "Journal":
    444             gameID = parseJournalRecordName(recordID.recordName)?.0
    445         case "Ping":
    446             // Same confinement rule as the record case: a friend-mailbox ping
    447             // names a game outside its zone by design; a game-zone ping must
    448             // name its own zone's game.
    449             guard let (pingGameID, _, _) = parsePingRecordName(recordID.recordName) else {
    450                 return false
    451             }
    452             return !recordID.zoneID.zoneName.hasPrefix("game-")
    453                 || recordID.zoneID.zoneName == recordName(forGameID: pingGameID)
    454         case Archive.recordType:
    455             guard let originalID = Archive.originalGameID(fromName: recordID.recordName) else {
    456                 return false
    457             }
    458             return recordID.recordName == Archive.recordName(forOriginalGameID: originalID)
    459                 && recordID.zoneID.zoneName == Archive.zoneName
    460         case Archive.legacyRecordType:
    461             guard let originalID = Archive.originalGameID(fromName: recordID.recordName) else {
    462                 return false
    463             }
    464             return recordID.recordName == Archive.legacyRecordName(
    465                 forOriginalGameID: originalID
    466             ) && recordID.zoneID.zoneName == Archive.legacyZoneID(
    467                 forOriginalGameID: originalID
    468             ).zoneName
    469         default:
    470             return false
    471         }
    472         guard let gameID else { return false }
    473         return recordID.zoneID.zoneName == recordName(forGameID: gameID)
    474     }
    475 
    476     /// Matches the local row for a record by its full zone identity, so a
    477     /// private-zone record can never match a same-named shared-zone row (or
    478     /// vice versa, or two shares of the same game by different owners).
    479     ///
    480     /// The owner name participates only for shared-database zones. CloudKit
    481     /// does not reliably round-trip the `CKCurrentUserDefaultName` placeholder
    482     /// for the current user's own zones — a fetched zone ID often carries the
    483     /// concrete user-record ID instead (see `isAccountZonePrivateDecision`) —
    484     /// so private rows always store `nil` and match on zone name alone, which
    485     /// is unambiguous there: every private-database zone belongs to the
    486     /// current user.
    487     static func gameIdentityPredicate(
    488         recordName: String,
    489         zoneID: CKRecordZone.ID,
    490         databaseScope: DatabaseScope,
    491         entityPrefix: String = ""
    492     ) -> NSPredicate {
    493         let zonePrefix = entityPrefix.isEmpty ? "" : "\(entityPrefix)."
    494         if databaseScope == .private {
    495             return NSPredicate(
    496                 format: "ckRecordName == %@ AND \(zonePrefix)ckZoneName == %@ AND \(zonePrefix)ckZoneOwnerName == NIL",
    497                 recordName,
    498                 zoneID.zoneName
    499             )
    500         }
    501         return NSPredicate(
    502             format: "ckRecordName == %@ AND \(zonePrefix)ckZoneName == %@ AND \(zonePrefix)ckZoneOwnerName == %@",
    503             recordName,
    504             zoneID.zoneName,
    505             zoneID.ownerName
    506         )
    507     }
    508 
    509     /// Zone ID for the user's account-wide zone in the private database. Holds
    510     /// records that coordinate state between a single iCloud user's own
    511     /// devices — never shared with collaborators, since the private database
    512     /// itself isn't reachable to anyone else.
    513     static let accountZoneID = CKRecordZone.ID(
    514         zoneName: "account",
    515         ownerName: CKCurrentUserDefaultName
    516     )
    517 
    518     // MARK: - Moves record building
    519 
    520     static func movesRecord(
    521         from view: MovesValue,
    522         zone: CKRecordZone.ID,
    523         systemFields: Data?
    524     ) throws -> CKRecord {
    525         let movesName = recordName(
    526             forMovesInGame: view.gameID,
    527             authorID: view.authorID,
    528             deviceID: view.deviceID
    529         )
    530         let record = restoreOrCreate(
    531             recordType: "Moves",
    532             recordName: movesName,
    533             zone: zone,
    534             systemFields: systemFields
    535         )
    536 
    537         // authorID/deviceID live in the record name, not as fields.
    538         record["updatedAt"] = view.updatedAt as CKRecordValue
    539         record["cells"] = try MovesCodec.encode(view.cells) as CKRecordValue
    540 
    541         return record
    542     }
    543 
    544     // MARK: - Journal record building
    545 
    546     /// Builds the `Journal` record carrying this device's full move log as a
    547     /// `CKAsset`. Write-once at completion, so there is no system-fields
    548     /// archive (mirrors `Ping`/`Decision`): a fresh record each build, and a
    549     /// re-send of an already-uploaded journal is a benign conflict the send
    550     /// path drops. The encoded entries are written to a temp file the same way
    551     /// `populateGameRecord` stages `puzzleSource` — CloudKit copies the asset
    552     /// on upload and the OS reaps the temporary directory.
    553     static func journalRecord(
    554         gameID: UUID,
    555         authorID: String,
    556         deviceID: String,
    557         updatedAt: Date,
    558         entries: [JournalValue],
    559         zone: CKRecordZone.ID
    560     ) throws -> CKRecord {
    561         let name = recordName(forJournalInGame: gameID, authorID: authorID, deviceID: deviceID)
    562         let recordID = CKRecord.ID(recordName: name, zoneID: zone)
    563         let record = CKRecord(recordType: "Journal", recordID: recordID)
    564         // authorID/deviceID live in the record name, not as fields.
    565         record["updatedAt"] = updatedAt as CKRecordValue
    566 
    567         let data = try JournalCodec.encode(entries)
    568         let url = FileManager.default.temporaryDirectory
    569             .appendingPathComponent(UUID().uuidString)
    570             .appendingPathExtension("json")
    571         try data.write(to: url, options: .atomic)
    572         record["entries"] = CKAsset(fileURL: url)
    573 
    574         return record
    575     }
    576 
    577     static func gameRecord(
    578         from entity: GameEntity,
    579         recordID: CKRecord.ID,
    580         includePuzzleSource: Bool
    581     ) -> CKRecord? {
    582         guard entity.ckRecordName != nil else { return nil }
    583         let record: CKRecord
    584         if let fields = entity.ckSystemFields,
    585            let restored = decodeRecord(from: fields) {
    586             record = restored
    587         } else {
    588             record = CKRecord(recordType: "Game", recordID: recordID)
    589         }
    590         populateGameRecord(record, from: entity, includePuzzleSource: includePuzzleSource)
    591         return record
    592     }
    593 
    594     static func populateGameRecord(
    595         _ record: CKRecord,
    596         from entity: GameEntity,
    597         includePuzzleSource: Bool
    598     ) {
    599         // `title`, `shareRecordName`, and `syncVersion` are owner-authoritative:
    600         // the title and protocol come from the game the owner created, and only
    601         // owner devices track the share record. A participant only re-saves this record to
    602         // mint the engagement/notification creds below, and at join time its
    603         // local `title` is still the transient "Joining…" placeholder
    604         // (`SyncEngine.handleFetchedDatabaseChanges`) until the owner's Game
    605         // record lands. Writing it from a participant would LWW-clobber the real
    606         // title on the shared record for everyone — so a non-owner leaves these
    607         // fields untouched and the server keeps the owner's value.
    608         let isOwner = entity.databaseScope == 0
    609         if isOwner {
    610             record["title"] = entity.title as CKRecordValue?
    611             // Owner-side share marker. Propagated so other owner-devices can flip
    612             // their `isShared` flag without reading the zone's CKShare directly.
    613             record["shareRecordName"] = entity.ckShareRecordName as CKRecordValue?
    614             record["syncVersion"] = GameSyncVersion.normalized(entity.syncVersion) as CKRecordValue
    615         }
    616         // Completion is terminal. A metadata-only save from a participant with
    617         // a stale local row must not clear a completion already present in the
    618         // restored server record.
    619         if let completedAt = entity.completedAt {
    620             record["completedAt"] = completedAt as CKRecordValue
    621             // Solver's authorID on a win; nil for a resignation.
    622             record["completedBy"] = entity.completedBy as CKRecordValue?
    623         }
    624         // The shared live-engagement room credentials (encoded
    625         // EngagementRoomCredentials). Any present participant may mint these
    626         // when the field is empty; convergence is plain record-level LWW, and
    627         // peers connect to whatever creds the field currently holds.
    628         // Stored as UTF-8 BYTES (matching cells/timeLog), not STRING — these
    629         // are opaque JSON blobs, not queryable text.
    630         record["roomCredential"] = entity.engagement?.data(using: .utf8) as CKRecordValue?
    631         // The shared per-game notification credentials (encoded
    632         // GamePushCredentials: the push auth secret + credID, plus the
    633         // worker-blind content key the payload is encrypted under). Synced to
    634         // participants like `engagement`; any participant may mint it when
    635         // empty, and record-level LWW converges concurrent mints.
    636         record["pushCredential"] = entity.notification?.data(using: .utf8) as CKRecordValue?
    637         guard includePuzzleSource, let source = entity.puzzleSource else { return }
    638         let url = FileManager.default.temporaryDirectory
    639             .appendingPathComponent(UUID().uuidString)
    640             .appendingPathExtension("xd")
    641         try? source.write(to: url, atomically: true, encoding: .utf8)
    642         record["puzzleSource"] = CKAsset(fileURL: url)
    643     }
    644 
    645     /// Builds a freshly-minted Ping record. Pings are write-once — they have
    646     /// no Core Data equivalent and no system-fields archive.
    647     /// - `authorID` + `deviceID` together let receivers filter out self-sends.
    648     ///   authorID alone is insufficient for kinds (e.g. `.opened`) that fire
    649     ///   between a single user's own devices, where authorID is identical.
    650     /// - `playerName` and `puzzleTitle` let receivers render the alert body.
    651     /// - `kind` distinguishes the bootstrap kinds current clients write
    652     ///   (.friend / .invite / .decline); legacy `.join`/`.hail` records
    653     ///   remain parseable but are no longer produced.
    654     static func pingRecord(
    655         gameID: UUID,
    656         authorID: String,
    657         deviceID: String,
    658         playerName: String,
    659         puzzleTitle: String,
    660         eventTimestampMs: Int64,
    661         kind: PingKind,
    662         payload: String? = nil,
    663         addressee: String? = nil,
    664         zone: CKRecordZone.ID
    665     ) -> CKRecord {
    666         let name = recordName(
    667             forPingInGame: gameID,
    668             authorID: authorID,
    669             deviceID: deviceID,
    670             eventTimestampMs: eventTimestampMs
    671         )
    672         let recordID = CKRecord.ID(recordName: name, zoneID: zone)
    673         let record = CKRecord(recordType: "Ping", recordID: recordID)
    674         // authorID/deviceID live in the record name, not as fields.
    675         record["playerName"] = playerName as CKRecordValue
    676         record["puzzleTitle"] = puzzleTitle as CKRecordValue
    677         record["kind"] = kind.rawValue as CKRecordValue
    678         // Directed pings target one player by authorID; nil ⇒ broadcast (every
    679         // recipient acts on it).
    680         if let addressee {
    681             record["addressee"] = addressee as CKRecordValue
    682         }
    683         if let payload {
    684             record["payload"] = payload as CKRecordValue
    685         }
    686         return record
    687     }
    688 
    689     /// Builds a `Decision` record. The identity (`kind` + `key`) lives in the
    690     /// record name, which keeps every write an idempotent upsert; `key` is not
    691     /// duplicated as a field. `payload` is the generic, kind-specific extra
    692     /// slot — empty for `block`, where presence alone is the fact — mirroring
    693     /// `Ping.payload`. `systemFields` is the archived server record (with its
    694     /// change tag): pass it so a re-send carries the current tag and CloudKit
    695     /// accepts the update (e.g. rotating the push secret) instead of rejecting
    696     /// it as a colliding create. Decisions are therefore upsertable, not
    697     /// write-once; a payload-less write clears any value a restored record held.
    698     static func decisionRecord(
    699         kind: String,
    700         key: String,
    701         payload: String? = nil,
    702         zone: CKRecordZone.ID,
    703         systemFields: Data? = nil,
    704         version: Int64? = nil
    705     ) -> CKRecord {
    706         let name = decisionRecordName(kind: kind, key: key)
    707         let record = restoreOrCreate(
    708             recordType: "Decision",
    709             recordName: name,
    710             zone: zone,
    711             systemFields: systemFields
    712         )
    713         record["kind"] = kind as CKRecordValue
    714         record["payload"] = payload.map { $0 as CKRecordValue }
    715         record["version"] = version.map { $0 as CKRecordValue }
    716         return record
    717     }
    718 
    719     static func playerRecord(
    720         gameID: UUID,
    721         authorID: String,
    722         name: String,
    723         updatedAt: Date,
    724         selection: PlayerSelection?,
    725         presenceUntil: Date? = nil,
    726         readThrough: Date? = nil,
    727         viewedAt: Date? = nil,
    728         timeLog: Data? = nil,
    729         pushAddress: String? = nil,
    730         zone: CKRecordZone.ID,
    731         systemFields: Data?
    732     ) -> CKRecord {
    733         let recordName = recordName(forPlayerInGame: gameID, authorID: authorID)
    734         let record = restoreOrCreate(
    735             recordType: "Player",
    736             recordName: recordName,
    737             zone: zone,
    738             systemFields: systemFields
    739         )
    740 
    741         // authorID lives in the record name, not as a field.
    742         record["name"] = name as CKRecordValue
    743         record["updatedAt"] = updatedAt as CKRecordValue
    744         if let selection {
    745             record["selRow"] = Int64(selection.row) as CKRecordValue
    746             record["selCol"] = Int64(selection.col) as CKRecordValue
    747             record["selDir"] = Int64(selection.direction.rawValue) as CKRecordValue
    748         } else {
    749             record["selRow"] = nil
    750             record["selCol"] = nil
    751             record["selDir"] = nil
    752         }
    753         // The forward-dated presence lease, shipped as the `presenceUntil`
    754         // CKRecord field.
    755         if let presenceUntil {
    756             record["presenceUntil"] = presenceUntil as CKRecordValue
    757         } else {
    758             record["presenceUntil"] = nil
    759         }
    760         // `readThrough` is the true read watermark — the latest other-author
    761         // move time this account has actually seen. Never forward-dated, so a
    762         // peer's session-end summary windows only on moves we genuinely missed.
    763         if let readThrough {
    764             record["readThrough"] = readThrough as CKRecordValue
    765         } else {
    766             record["readThrough"] = nil
    767         }
    768         if let viewedAt {
    769             record["viewedAt"] = viewedAt as CKRecordValue
    770         } else {
    771             record["viewedAt"] = nil
    772         }
    773         // `timeLog` is the device-keyed solve-time log (encoded `TimeLog`):
    774         // each device's active-play intervals plus its open session. Unlike the
    775         // other LWW fields here, a device only ever mutates its own slot, so
    776         // concurrent sibling writes converge by device once merged on apply.
    777         if let timeLog, !timeLog.isEmpty {
    778             record["timeLog"] = timeLog as CKRecordValue
    779         } else {
    780             record["timeLog"] = nil
    781         }
    782         if let pushAddress, !pushAddress.isEmpty {
    783             record["pushAddress"] = pushAddress as CKRecordValue
    784         } else {
    785             record["pushAddress"] = nil
    786         }
    787 
    788         return record
    789     }
    790 
    791     /// Reads `presenceUntil` off an inbound Player record — the per-account horizon
    792     /// for collaborator moves this user has read or is actively watching.
    793     /// Active puzzle sessions may lease the horizon into the near future and
    794     /// close it with a lower current-time write, so callers must apply this
    795     /// only after accepting the Player record under last-writer-wins
    796     /// freshness checks.
    797     /// Returns `nil` if the field is missing — older records, or a slot that
    798     /// has not yet recorded a view.
    799     static func parsePlayerPresenceUntil(from record: CKRecord) -> Date? {
    800         record["presenceUntil"] as? Date
    801     }
    802 
    803     /// Reads `readThrough` off an inbound Player record — the per-account read
    804     /// watermark: the latest other-author move time this user has actually
    805     /// seen. Unlike `presenceUntil` it is never leased into the future, so the
    806     /// session-end push uses it to decide what a recipient still hasn't seen.
    807     /// Returns `nil` for records that predate the field or a slot that has not
    808     /// recorded a read yet.
    809     static func parsePlayerReadThrough(from record: CKRecord) -> Date? {
    810         record["readThrough"] as? Date
    811     }
    812 
    813     /// Reads `selRow`/`selCol`/`selDir` off an inbound Player record. These
    814     /// fields carry the peer's cursor track start, not their exact local
    815     /// reticle. Returns `nil` if any field is missing — the peer either hasn't
    816     /// published a track yet or has cleared theirs (e.g. left the puzzle view).
    817     static func parsePlayerSelection(from record: CKRecord) -> PlayerSelection? {
    818         guard let row = record["selRow"] as? Int64,
    819               let col = record["selCol"] as? Int64,
    820               let dirRaw = record["selDir"] as? Int64,
    821               let direction = PlayerSelection.Direction(rawValue: Int(dirRaw))
    822         else { return nil }
    823         return PlayerSelection(row: Int(row), col: Int(col), direction: direction)
    824     }
    825 
    826     /// Reads `viewedAt` off an inbound Player record — this account's "last
    827     /// viewed" cutoff, written on leave and shared across the author's own
    828     /// devices so a sibling converges on the latest view time rather than
    829     /// recomputing the catch-up baseline from its own (possibly stale) view.
    830     /// Returns `nil` when the account has not yet left a game with peers.
    831     static func parsePlayerViewedAt(from record: CKRecord) -> Date? {
    832         record["viewedAt"] as? Date
    833     }
    834 
    835     /// Reads `timeLog` off an inbound Player record — the encoded `TimeLog`
    836     /// of device-keyed solve-time intervals. Returns `nil` for records that
    837     /// predate the field (or before the schema deploy), which the clock treats
    838     /// as a zero contribution.
    839     static func parsePlayerTimeLog(from record: CKRecord) -> Data? {
    840         record["timeLog"] as? Data
    841     }
    842 
    843     /// Reads `pushAddress` off an inbound Player record — the per-(account,
    844     /// game) capability token a co-participant uses to address a push to this
    845     /// player for this game. Possession is gated by the share ACL (only
    846     /// participants can read the record), so the token, not an identity, is
    847     /// the authorisation. Returns `nil` for older records or a slot that has
    848     /// not yet minted one.
    849     static func parsePlayerPushAddress(from record: CKRecord) -> String? {
    850         record["pushAddress"] as? String
    851     }
    852 
    853     /// Parses an incoming `Player` record name back into its `(gameID,
    854     /// authorID)` components. Returns `nil` if the name doesn't match the
    855     /// `player-<UUID>-<authorID>` shape.
    856     static func parsePlayerRecordName(_ name: String) -> (UUID, String)? {
    857         guard name.hasPrefix("player-") else { return nil }
    858         let rest = name.dropFirst("player-".count)
    859         let uuidLength = 36
    860         guard rest.count > uuidLength,
    861               rest[rest.index(rest.startIndex, offsetBy: uuidLength)] == "-"
    862         else { return nil }
    863         let uuidPart = String(rest[rest.startIndex..<rest.index(rest.startIndex, offsetBy: uuidLength)])
    864         guard let gameID = UUID(uuidString: uuidPart) else { return nil }
    865         let authorPart = String(rest.suffix(from: rest.index(rest.startIndex, offsetBy: uuidLength + 1)))
    866         guard !authorPart.isEmpty else { return nil }
    867         return (gameID, authorPart)
    868     }
    869 
    870     /// Parses an incoming `Moves` CKRecord into a `MovesValue`. Returns `nil`
    871     /// if the record name doesn't match the `moves-<gameUUID>-<authorID>-<deviceID>`
    872     /// shape or the cells payload fails to decode.
    873     static func parseMovesRecord(_ record: CKRecord) -> MovesValue? {
    874         guard record.recordType == "Moves" else { return nil }
    875         guard let (gameID, authorID, deviceID) = parseMovesRecordName(
    876             record.recordID.recordName
    877         ) else { return nil }
    878         guard let data = record["cells"] as? Data,
    879               let cells = try? MovesCodec.decode(data)
    880         else { return nil }
    881         let updatedAt = record["updatedAt"] as? Date
    882             ?? record.modificationDate
    883             ?? Date()
    884         return MovesValue(
    885             gameID: gameID,
    886             authorID: authorID,
    887             deviceID: deviceID,
    888             cells: cells,
    889             updatedAt: updatedAt
    890         )
    891     }
    892 
    893     /// Parses `moves-<gameUUID>-<authorID>-<deviceID>` into its three parts.
    894     /// `deviceID` is the suffix after the final `-`; `authorID` may itself
    895     /// contain dashes (e.g. CloudKit user record names with no dashes today,
    896     /// but we don't want to assume).
    897     static func parseMovesRecordName(_ name: String) -> (UUID, String, String)? {
    898         let prefix = "moves-"
    899         guard name.hasPrefix(prefix) else { return nil }
    900         let rest = name.dropFirst(prefix.count)
    901         let uuidLength = 36
    902         guard rest.count > uuidLength,
    903               rest[rest.index(rest.startIndex, offsetBy: uuidLength)] == "-"
    904         else { return nil }
    905         let uuidPart = String(rest[rest.startIndex..<rest.index(rest.startIndex, offsetBy: uuidLength)])
    906         guard let gameID = UUID(uuidString: uuidPart) else { return nil }
    907         let afterUUID = rest.index(rest.startIndex, offsetBy: uuidLength + 1)
    908         let tail = rest[afterUUID...]
    909         guard let lastDash = tail.lastIndex(of: "-") else { return nil }
    910         let authorID = String(tail[tail.startIndex..<lastDash])
    911         let deviceID = String(tail[tail.index(after: lastDash)...])
    912         guard !authorID.isEmpty, !deviceID.isEmpty else { return nil }
    913         return (gameID, authorID, deviceID)
    914     }
    915 
    916     /// Parses `journal-<gameUUID>-<authorID>-<deviceID>` into its three parts.
    917     /// Same decomposition as `parseMovesRecordName` (deviceID is the suffix
    918     /// after the final `-`; authorID may itself contain dashes).
    919     static func parseJournalRecordName(_ name: String) -> (UUID, String, String)? {
    920         let prefix = "journal-"
    921         guard name.hasPrefix(prefix) else { return nil }
    922         let rest = name.dropFirst(prefix.count)
    923         let uuidLength = 36
    924         guard rest.count > uuidLength,
    925               rest[rest.index(rest.startIndex, offsetBy: uuidLength)] == "-"
    926         else { return nil }
    927         let uuidPart = String(rest[rest.startIndex..<rest.index(rest.startIndex, offsetBy: uuidLength)])
    928         guard let gameID = UUID(uuidString: uuidPart) else { return nil }
    929         let afterUUID = rest.index(rest.startIndex, offsetBy: uuidLength + 1)
    930         let tail = rest[afterUUID...]
    931         guard let lastDash = tail.lastIndex(of: "-") else { return nil }
    932         let authorID = String(tail[tail.startIndex..<lastDash])
    933         let deviceID = String(tail[tail.index(after: lastDash)...])
    934         guard !authorID.isEmpty, !deviceID.isEmpty else { return nil }
    935         return (gameID, authorID, deviceID)
    936     }
    937 
    938     /// Parses `ping-<gameUUID>-<authorID>-<deviceID>-<eventTimestampMs>` into
    939     /// its identity components. Splits from the right: the trailing
    940     /// `eventTimestampMs` is dash-free digits and `deviceID` is a dash-free hex
    941     /// id, so both peel off cleanly; `authorID` is the remainder and may itself
    942     /// contain dashes (same tolerance as `parseMovesRecordName`). Returns nil if
    943     /// the name isn't a ping name or the UUID doesn't parse.
    944     static func parsePingRecordName(_ name: String) -> (UUID, String, String)? {
    945         let prefix = "ping-"
    946         guard name.hasPrefix(prefix) else { return nil }
    947         let rest = name.dropFirst(prefix.count)
    948         let uuidLength = 36
    949         guard rest.count > uuidLength,
    950               rest[rest.index(rest.startIndex, offsetBy: uuidLength)] == "-"
    951         else { return nil }
    952         let uuidPart = String(rest[rest.startIndex..<rest.index(rest.startIndex, offsetBy: uuidLength)])
    953         guard let gameID = UUID(uuidString: uuidPart) else { return nil }
    954         let afterUUID = rest.index(rest.startIndex, offsetBy: uuidLength + 1)
    955         // `<authorID>-<deviceID>-<eventTimestampMs>`
    956         let tail = rest[afterUUID...]
    957         guard let tsDash = tail.lastIndex(of: "-") else { return nil }
    958         let authorAndDevice = tail[tail.startIndex..<tsDash]
    959         guard let deviceDash = authorAndDevice.lastIndex(of: "-") else { return nil }
    960         let authorID = String(authorAndDevice[authorAndDevice.startIndex..<deviceDash])
    961         let deviceID = String(authorAndDevice[authorAndDevice.index(after: deviceDash)...])
    962         guard !authorID.isEmpty, !deviceID.isEmpty else { return nil }
    963         return (gameID, authorID, deviceID)
    964     }
    965 
    966     // MARK: - Bounded asset reads
    967 
    968     /// A `CKAsset` staging file rejected before it was read into memory.
    969     enum AssetReadError: Error, CustomStringConvertible {
    970         case oversized(bytes: Int, limit: Int)
    971         case unknownSize
    972 
    973         var description: String {
    974             switch self {
    975             case .oversized(let bytes, let limit):
    976                 return "asset exceeds \(limit) bytes (\(bytes))"
    977             case .unknownSize:
    978                 return "asset file size unavailable"
    979             }
    980         }
    981     }
    982 
    983     /// Reads an untrusted `CKAsset` staging file only after its on-disk size
    984     /// passes `limit`. A co-player controls the asset's content, and an asset
    985     /// is an external file that can dwarf the ~1 MB record limit — so never
    986     /// `Data(contentsOf:)` one unchecked. Fails closed when the size can't be
    987     /// determined.
    988     static func boundedAssetData(at url: URL, limit: Int) throws -> Data {
    989         guard let size = try url.resourceValues(forKeys: [.fileSizeKey]).fileSize else {
    990             throw AssetReadError.unknownSize
    991         }
    992         guard size <= limit else {
    993             throw AssetReadError.oversized(bytes: size, limit: limit)
    994         }
    995         return try Data(contentsOf: url)
    996     }
    997 
    998     // MARK: - Applying incoming CKRecords to Core Data
    999 
   1000     /// Returns the `GameEntity` for `gameID`, creating an unpopulated stub if
   1001     /// none exists yet. Moves and Player records can arrive in a different
   1002     /// fetch batch than the Game record that created the zone — on
   1003     /// a fresh device CKSyncEngine paginates the initial pull and there is no
   1004     /// guarantee that Game comes first. Without this stub the parent lookup
   1005     /// fails, the inbound record is dropped, but CKSyncEngine still advances
   1006     /// its change token, so the gap is invisible until the next state reset.
   1007     /// The stub uses empty `title` / `puzzleSource` so `GameSummary.init?`
   1008     /// filters it out of the library until `applyGameRecord` arrives with
   1009     /// the real metadata and updates the same row (matched by `ckRecordName`).
   1010     static func ensureGameEntity(
   1011         forGameID gameID: UUID,
   1012         zoneID: CKRecordZone.ID,
   1013         databaseScope: DatabaseScope = .private,
   1014         in ctx: NSManagedObjectContext
   1015     ) -> GameEntity {
   1016         let name = recordName(forGameID: gameID)
   1017         let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
   1018         req.predicate = gameIdentityPredicate(
   1019             recordName: name,
   1020             zoneID: zoneID,
   1021             databaseScope: databaseScope
   1022         )
   1023         req.fetchLimit = 1
   1024         if let existing = try? ctx.fetch(req).first { return existing }
   1025         let entity = GameEntity(context: ctx)
   1026         entity.id = gameID
   1027         entity.ckRecordName = name
   1028         entity.ckZoneName = zoneID.zoneName
   1029         // Scope, not the owner-name spelling, decides ownership: a private-DB
   1030         // zone always belongs to the current user even when its fetched zone
   1031         // ID carries the concrete user-record ID instead of the placeholder.
   1032         entity.ckZoneOwnerName = databaseScope == .private ? nil : zoneID.ownerName
   1033         entity.databaseScope = databaseScope.rawValue
   1034         entity.syncVersion = GameSyncVersion.legacy
   1035         entity.title = ""
   1036         entity.puzzleSource = ""
   1037         entity.createdAt = Date()
   1038         entity.updatedAt = Date()
   1039         return entity
   1040     }
   1041 
   1042     static func applyGameRecord(
   1043         _ record: CKRecord,
   1044         to context: NSManagedObjectContext,
   1045         databaseScope: DatabaseScope = .private,
   1046         onEngagementChange: ((UUID) -> Void)? = nil,
   1047         onCompletedTransition: ((UUID) -> Void)? = nil,
   1048         onContentKeyChange: ((UUID) -> Void)? = nil,
   1049         onStaleCredentials: ((String) -> Void)? = nil,
   1050         onDiagnostic: ((String) -> Void)? = nil
   1051     ) -> GameEntity {
   1052         let recordName = record.recordID.recordName
   1053         let entity = fetchOrCreate(
   1054             entityName: "GameEntity",
   1055             recordName: recordName,
   1056             zoneID: record.recordID.zoneID,
   1057             databaseScope: databaseScope,
   1058             in: context
   1059         ) as! GameEntity
   1060 
   1061         // Recover the UUID from the record name ("game-<UUID>") so the
   1062         // library query, which filters on `entity.id`, doesn't silently drop
   1063         // newly-synced games.
   1064         if entity.id == nil {
   1065             let uuidString = String(recordName.dropFirst("game-".count))
   1066             entity.id = UUID(uuidString: uuidString)
   1067         }
   1068 
   1069         // Drop fetched snapshots older than what we already have; adopting
   1070         // them downgrades the local etag and OpLock-fails the next save
   1071         // (same rationale as `applyMovesRecord` / `applyPlayerRecord`).
   1072         if entity.ckSystemFields != nil,
   1073            !incomingIsAtLeastAsFresh(record, existingFields: entity.ckSystemFields) {
   1074             return entity
   1075         }
   1076 
   1077         // Always adopt the fresher etag and zone identity so the next outbound
   1078         // push uses a current change tag and routes to the right zone.
   1079         entity.ckRecordName = recordName
   1080         entity.ckSystemFields = encodeSystemFields(of: record)
   1081         entity.ckZoneName = record.recordID.zoneID.zoneName
   1082         // Scope, not the owner-name spelling, decides ownership — CloudKit
   1083         // round-trips a private zone's owner as either the placeholder or the
   1084         // concrete user-record ID, and `gameIdentityPredicate` relies on
   1085         // private rows always storing nil.
   1086         entity.ckZoneOwnerName =
   1087             databaseScope == .private ? nil : record.recordID.zoneID.ownerName
   1088         entity.databaseScope = databaseScope.rawValue
   1089 
   1090         // The owner chooses and may advance the game's protocol. Participants
   1091         // never write this field, so they can adopt it even while an unrelated
   1092         // metadata save is pending. An owner with a pending local save keeps
   1093         // its chosen value until that save round-trips.
   1094         if databaseScope == .shared || !entity.hasPendingSave {
   1095             entity.syncVersion = GameSyncVersion.normalized(
   1096                 (record["syncVersion"] as? Int64) ?? GameSyncVersion.legacy
   1097             )
   1098         }
   1099 
   1100         // Local mutable fields take precedence while a push is in flight.
   1101         // The flag is set atomically with the local write (in `markCompleted`,
   1102         // `resignGame`, `persistShareName`) and cleared once `SyncEngine`
   1103         // confirms the push landed. Writing server values here would clobber
   1104         // the pending change and the next outbound push would then serialise
   1105         // the clobbered value, permanently losing it server-side.
   1106         guard !entity.hasPendingSave else { return entity }
   1107 
   1108         // Seed createdAt/updatedAt from the server record only on first sight,
   1109         // so a newly-arrived game has something for the library to order by.
   1110         // After that, the library timestamp tracks *gameplay* (Moves) alone.
   1111         // The Game record's modificationDate advances on non-gameplay writes
   1112         // too — engagement/push credentials, share metadata, the notification
   1113         // field — so adopting it on every fetch made a game look freshly
   1114         // "updated" when nothing was played (e.g. a peer, or this device,
   1115         // merely moved the cursor or re-minted a credential). Gameplay flows
   1116         // through the Moves path, which sets updatedAt independently; the
   1117         // winning move is itself a move, so completion still advances it.
   1118         if entity.createdAt == nil {
   1119             entity.createdAt = record.creationDate ?? Date()
   1120         }
   1121         if entity.updatedAt == nil {
   1122             entity.updatedAt = record.modificationDate ?? Date()
   1123         }
   1124 
   1125         entity.title = record["title"] as? String ?? entity.title
   1126         // Capture the prior completion state before overwriting it: a
   1127         // not-completed → completed transition learned purely via sync (this
   1128         // device wasn't present when the puzzle was finished) does NOT run the
   1129         // local completion path, so it never uploads this device's journal.
   1130         // Replay's strict completeness would then wait on it forever. Signal
   1131         // the transition so the caller can enqueue the upload.
   1132         let wasCompleted = entity.completedAt != nil
   1133         let incomingCompletedAt = record["completedAt"] as? Date
   1134         if let incomingCompletedAt {
   1135             entity.completedAt = incomingCompletedAt
   1136             entity.completedBy = record["completedBy"] as? String
   1137         } else if entity.completedAt == nil {
   1138             entity.completedAt = nil
   1139             entity.completedBy = nil
   1140         }
   1141         if !wasCompleted, entity.completedAt != nil, let id = entity.id {
   1142             onCompletedTransition?(id)
   1143         }
   1144         // Owner-side share marker — set on the device that created the share
   1145         // and round-tripped via the Game record so other owner-devices learn
   1146         // the game is shared. On participant devices `databaseScope == 1`
   1147         // already implies shared, but keeping the field in sync is harmless.
   1148         if let shareRecordName = record["shareRecordName"] as? String {
   1149             entity.ckShareRecordName = shareRecordName
   1150         }
   1151 
   1152         // Adopt the engagement creds (skipped above while a local mint is
   1153         // still pushing, via the hasPendingSave guard). A change here is the
   1154         // signal a peer minted/rotated the room, so the receiver reconciles
   1155         // its live connection toward the new creds.
   1156         let incomingEngagement = (record["roomCredential"] as? Data)
   1157             .flatMap { String(data: $0, encoding: .utf8) }
   1158         if entity.engagement != incomingEngagement {
   1159             entity.engagement = incomingEngagement
   1160             if let id = entity.id { onEngagementChange?(id) }
   1161         }
   1162 
   1163         // Adopt the shared notification credentials. The credential is read
   1164         // lazily at registration/publish time, so converging the field is
   1165         // enough — but a change may carry a new embedded content key, so fire
   1166         // `onContentKeyChange` to re-mirror the App Group key directory the NSE
   1167         // reads. This is what lets a freshly-joined participant decrypt the
   1168         // first push it receives, rather than waiting for the next launch heal.
   1169         //
   1170         // Adoption is generation-gated: rotation replaces the credential at a
   1171         // higher `gen`, and the Game record is pushed whole, so a stale device
   1172         // re-pushing an unrelated change (its inbound applies were blocked by
   1173         // its own `hasPendingSave`) can land the *old* credential back on the
   1174         // server. Adopting that here would silently re-admit a departed
   1175         // participant. Instead keep the newer local credential, flag our copy
   1176         // as pending, and hand the record name to `onStaleCredentials` so the
   1177         // caller re-pushes it — healing the server to the rotated value.
   1178         let incomingNotification = (record["pushCredential"] as? Data)
   1179             .flatMap { String(data: $0, encoding: .utf8) }
   1180         if entity.notification != incomingNotification {
   1181             let localGen = GamePushCredentials.decode(entity.notification)?.generation
   1182             let incomingGen = GamePushCredentials.decode(incomingNotification)?.generation
   1183             // A local credential is superseded only by an inbound one of equal
   1184             // or higher generation (equal = concurrent rotations; the server
   1185             // copy wins the tie). An inbound *absence* never clears a local
   1186             // credential — creds are minted once and only ever replaced, so a
   1187             // missing field is always a stale record.
   1188             let incomingSupersedes: Bool
   1189             if localGen == nil {
   1190                 incomingSupersedes = true
   1191             } else if let incomingGen, let localGen {
   1192                 incomingSupersedes = incomingGen >= localGen
   1193             } else {
   1194                 incomingSupersedes = false
   1195             }
   1196             if incomingSupersedes {
   1197                 entity.notification = incomingNotification
   1198                 if let id = entity.id { onContentKeyChange?(id) }
   1199             } else {
   1200                 onDiagnostic?(
   1201                     "stale pushCredential for \(recordName) " +
   1202                     "(inbound gen \(incomingGen.map(String.init) ?? "none") < " +
   1203                     "local gen \(localGen.map(String.init) ?? "none")) — keeping local, re-pushing"
   1204                 )
   1205                 entity.hasPendingSave = true
   1206                 onStaleCredentials?(recordName)
   1207             }
   1208         }
   1209 
   1210         if let asset = record["puzzleSource"] as? CKAsset,
   1211            let fileURL = asset.fileURL {
   1212             do {
   1213                 // A co-player writes this asset into the shared zone, so treat
   1214                 // its size as untrusted: reject an oversized blob before reading
   1215                 // it into memory. The parser enforces the same bound, but the
   1216                 // asset is an external file that could dwarf the ~1 MB record
   1217                 // limit, so gate on the on-disk size first.
   1218                 if let size = try? fileURL.resourceValues(forKeys: [.fileSizeKey]).fileSize,
   1219                    size > XD.maxSourceBytes {
   1220                     onDiagnostic?(
   1221                         "puzzleSource asset for \(recordName) " +
   1222                         "exceeds \(XD.maxSourceBytes) bytes (\(size)) — skipping"
   1223                     )
   1224                     return entity
   1225                 }
   1226                 let source = try String(contentsOf: fileURL, encoding: .utf8)
   1227                 entity.puzzleSource = source
   1228                 if let xd = try? XD.parse(source) {
   1229                     let puzzle = Puzzle(xd: xd)
   1230                     entity.puzzleParserVersion = Int64(XD.currentParserVersion)
   1231                     // The title is always derived from the puzzle content (there
   1232                     // is no custom game title), so trust the asset over the
   1233                     // record's `title` field — which was already applied above.
   1234                     // This re-derives the real title even when `record["title"]`
   1235                     // carried a stale value, e.g. a participant's transient
   1236                     // "Joining…" placeholder that a prior build wrote to the
   1237                     // shared record, so the title self-heals on the next sync
   1238                     // that carries the asset.
   1239                     entity.title = puzzle.title
   1240                     entity.populateCachedSummaryFields(from: puzzle)
   1241                 }
   1242             } catch {
   1243                 // CKSyncEngine has already committed this batch by the time
   1244                 // the delegate returns, so re-throwing wouldn't redeliver.
   1245                 // Surface the dropped puzzle source instead of silently
   1246                 // leaving the entity without playable content.
   1247                 let nsError = error as NSError
   1248                 onDiagnostic?(
   1249                     "puzzleSource asset read failed for \(recordName) " +
   1250                     "— domain=\(nsError.domain) code=\(nsError.code) " +
   1251                     "\(nsError.localizedDescription)"
   1252                 )
   1253             }
   1254         }
   1255 
   1256         return entity
   1257     }
   1258 
   1259     /// Upserts the `MovesEntity` for `value`. The cells blob is taken straight
   1260     /// off the record so any forward-compat fields the encoder added are
   1261     /// preserved verbatim. Bumps the parent `GameEntity.updatedAt` if the
   1262     /// record is fresher. Returns `true` when cells/updatedAt were adopted,
   1263     /// `false` when the local-device-row guard short-circuited the body so
   1264     /// callers can skip the downstream grid refresh.
   1265     ///
   1266     /// `onNewAuthor` fires with the authorID when this record creates the first
   1267     /// `MovesEntity` row by a *remote* contributor — i.e. a participant the
   1268     /// roster can only discover from their moves (no `Player` record yet, see
   1269     /// `PlayerRoster.refresh`). Callers use it to trigger a one-off roster
   1270     /// refresh on a new collaborator's first move without refreshing on every
   1271     /// subsequent keystroke or sibling-device row.
   1272     @discardableResult
   1273     static func applyMovesRecord(
   1274         _ record: CKRecord,
   1275         value: MovesValue,
   1276         to ctx: NSManagedObjectContext,
   1277         databaseScope: DatabaseScope = .private,
   1278         localAuthorID: String? = nil,
   1279         onNewAuthor: ((String) -> Void)? = nil
   1280     ) -> Bool {
   1281         let ckName = record.recordID.recordName
   1282         let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity")
   1283         req.predicate = gameIdentityPredicate(
   1284             recordName: ckName,
   1285             zoneID: record.recordID.zoneID,
   1286             databaseScope: databaseScope,
   1287             entityPrefix: "game"
   1288         )
   1289         req.fetchLimit = 1
   1290 
   1291         let entity: MovesEntity
   1292         let foundExisting: Bool
   1293         let authorAlreadyKnown: Bool
   1294         if let existing = try? ctx.fetch(req).first {
   1295             entity = existing
   1296             foundExisting = true
   1297             authorAlreadyKnown = true
   1298         } else {
   1299             let authorReq = NSFetchRequest<MovesEntity>(entityName: "MovesEntity")
   1300             authorReq.predicate = NSPredicate(
   1301                 format: "game.id == %@ AND authorID == %@",
   1302                 value.gameID as CVarArg,
   1303                 value.authorID
   1304             )
   1305             authorReq.fetchLimit = 1
   1306             authorAlreadyKnown = ((try? ctx.fetch(authorReq).first) != nil)
   1307 
   1308             let game = ensureGameEntity(
   1309                 forGameID: value.gameID,
   1310                 zoneID: record.recordID.zoneID,
   1311                 databaseScope: databaseScope,
   1312                 in: ctx
   1313             )
   1314             entity = MovesEntity(context: ctx)
   1315             entity.game = game
   1316             foundExisting = false
   1317         }
   1318 
   1319         // Drop fetched snapshots that are older than what we already have.
   1320         // The writeback after a successful push advances `ckSystemFields` to
   1321         // the latest server etag; a query that started before that push
   1322         // landed can return the prior server state, and adopting it here
   1323         // would downgrade the etag and OpLock-fail the next save.
   1324         if foundExisting,
   1325            !incomingIsAtLeastAsFresh(record, existingFields: entity.ckSystemFields) {
   1326             return false
   1327         }
   1328 
   1329         // Adopt system fields so future saves target the server's current
   1330         // change tag. If this is our own per-device row and it already
   1331         // exists locally, the local value state is authoritative; tokenless
   1332         // push-driven direct fetches can re-deliver an older server copy while
   1333         // newer edits are still queued for upload.
   1334         entity.ckRecordName = ckName
   1335         entity.ckSystemFields = encodeSystemFields(of: record)
   1336         entity.authorID = value.authorID
   1337         entity.deviceID = value.deviceID
   1338         let isLocalDeviceRow = value.authorID == localAuthorID
   1339             && value.deviceID == localDeviceID
   1340         guard !foundExisting || !isLocalDeviceRow else { return false }
   1341         let previousUpdatedAt = entity.updatedAt ?? .distantPast
   1342         let previousCells = (entity.cells.flatMap { try? MovesCodec.decode($0) }) ?? [:]
   1343         let mergedCells = mergeIncomingMovesCells(
   1344             existing: previousCells,
   1345             incoming: value.cells
   1346         )
   1347         let mergedUpdatedAt = max(
   1348             previousUpdatedAt,
   1349             value.updatedAt,
   1350             mergedCells.values.map(\.updatedAt).max() ?? .distantPast
   1351         )
   1352 
   1353         entity.updatedAt = mergedUpdatedAt
   1354         entity.cells = (try? MovesCodec.encode(mergedCells)) ?? ((record["cells"] as? Data) ?? Data())
   1355 
   1356         if let game = entity.game,
   1357            game.updatedAt.map({ $0 < mergedUpdatedAt }) ?? true {
   1358             game.updatedAt = mergedUpdatedAt
   1359         }
   1360         // A newly-seen remote author is the roster's only cue to
   1361         // a contributor who hasn't published a `Player` record yet. Signal it
   1362         // once here; repeat moves and sibling-device rows by a known author
   1363         // don't.
   1364         if !foundExisting,
   1365            !authorAlreadyKnown,
   1366            value.authorID != localAuthorID,
   1367            value.authorID != CKCurrentUserDefaultName,
   1368            !value.authorID.isEmpty {
   1369             onNewAuthor?(value.authorID)
   1370         }
   1371         return true
   1372     }
   1373 
   1374     private static func mergeIncomingMovesCells(
   1375         existing: [GridPosition: TimestampedCell],
   1376         incoming: [GridPosition: TimestampedCell]
   1377     ) -> [GridPosition: TimestampedCell] {
   1378         var cells = existing
   1379         for (position, incomingCell) in incoming {
   1380             if let existingCell = cells[position],
   1381                existingCell.compareRevision(to: incomingCell) != .orderedAscending {
   1382                 continue
   1383             }
   1384             cells[position] = incomingCell
   1385         }
   1386         return cells
   1387     }
   1388 
   1389     /// Projects an inbound `Decision` record onto local Core Data. For
   1390     /// `kind == "block"` this upserts a `FriendEntity` tombstone keyed by the
   1391     /// blocked author so the block becomes authoritative across the user's own
   1392     /// devices: `applyInvitePings` (authorID-keyed) and the friendship
   1393     /// bootstrap's `friendExists` (pairKey-keyed) both then suppress the
   1394     /// blocked collaborator everywhere. A device that never befriended the
   1395     /// author still gets a minimal blocked row. Returns `true` when a row was
   1396     /// written. `localAuthorID` lets the pairKey be derived for the bootstrap
   1397     /// short-circuit; it's deterministic from the unordered author pair.
   1398     @discardableResult
   1399     static func applyDecisionRecord(
   1400         _ record: CKRecord,
   1401         to ctx: NSManagedObjectContext,
   1402         localAuthorID: String?,
   1403         databaseScope: DatabaseScope = .private
   1404     ) -> Bool {
   1405         guard record.recordType == "Decision" else { return false }
   1406         // Identity comes from the record name (always present, immutable);
   1407         // `kind` is also mirrored as a field, name-parse as the fallback.
   1408         let parsed = parseDecisionRecordName(record.recordID.recordName)
   1409         guard let kind = (record["kind"] as? String) ?? parsed?.kind,
   1410               let key = parsed?.key,
   1411               !kind.isEmpty, !key.isEmpty
   1412         else { return false }
   1413 
   1414         switch kind {
   1415         case blockDecisionKind:
   1416             // A block is the user's own choice, authoritative across their
   1417             // devices — honored only from the account zone in the private
   1418             // database. Friend zones are writable by the other participant, who
   1419             // must not be able to block (or unblock) a third party on this
   1420             // user's behalf. Same gate as `nickname`/`left`.
   1421             guard isAccountZonePrivateDecision(record, databaseScope: databaseScope)
   1422             else { return false }
   1423             // Versioned so a stale copy can't resurrect a cleared block (or
   1424             // re-clear a fresh one). Payload `"0"` means unblocked; anything
   1425             // else (including a legacy payload-less record) means blocked.
   1426             let version = decisionVersion(record)
   1427             let blocked = (record["payload"] as? String) != "0"
   1428             let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity")
   1429             req.predicate = NSPredicate(format: "authorID == %@", key)
   1430             req.fetchLimit = 1
   1431             let friend = (try? ctx.fetch(req).first) ?? FriendEntity(context: ctx)
   1432             guard version >= friend.blockVersion else { return false }
   1433             friend.authorID = key
   1434             friend.isBlocked = blocked
   1435             friend.blockVersion = version
   1436             if friend.pairKey == nil,
   1437                let localAuthorID, !localAuthorID.isEmpty {
   1438                 friend.pairKey = FriendZone.pairKey(localAuthorID, key)
   1439             }
   1440             if friend.createdAt == nil { friend.createdAt = Date() }
   1441             return true
   1442         case "left":
   1443             // The user left this shared game on another of their devices.
   1444             // Hard-delete the local row so it stops hanging around (the
   1445             // shared-zone deletion alone would only flag it access-revoked,
   1446             // see SyncEngine.handleFetchedDatabaseChanges). Idempotent: a
   1447             // re-applied decision after the row is gone is a no-op. Guarded
   1448             // to participant rows (databaseScope == 1) so a same-id owned
   1449             // copy is never collateral.
   1450             //
   1451             // The decision itself is a self-authored cross-device signal, so
   1452             // honor it only from the account zone in the private database — a
   1453             // friend must not be able to name a `(gameID)` and evict its
   1454             // participant row out of a zone he can write. Same gate as
   1455             // `nickname`/`block`.
   1456             guard isAccountZonePrivateDecision(record, databaseScope: databaseScope)
   1457             else { return false }
   1458             guard let gameID = UUID(uuidString: key) else { return false }
   1459             let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
   1460             req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
   1461             req.fetchLimit = 1
   1462             guard let entity = try? ctx.fetch(req).first,
   1463                   entity.databaseScope == 1 else { return false }
   1464             ctx.delete(entity)
   1465             return true
   1466         case nameDecisionKind:
   1467             // A friend's display name, read out of the pairwise friend zone.
   1468             // Our own copy (key == localAuthorID, account zone) carries no
   1469             // Core Data projection — the local name lives in
   1470             // `PlayerPreferences`; only its version is adopted, by the caller.
   1471             guard let localAuthorID, !localAuthorID.isEmpty,
   1472                   key != localAuthorID,
   1473                   let name = record["payload"] as? String,
   1474                   !name.isEmpty
   1475             else { return false }
   1476             // Provenance: a name Decision is only honored when the zone is *the*
   1477             // pairwise mailbox for (us, key) — the zone name embeds a hash of
   1478             // the author pair, so the claimed subject is verifiable without
   1479             // trusting the record. This also rejects a name Decision for a third
   1480             // party misdelivered into an unrelated zone.
   1481             let pairKey = FriendZone.pairKey(localAuthorID, key)
   1482             let zoneID = record.recordID.zoneID
   1483             guard zoneID.zoneName == FriendZone.zoneName(pairKey: pairKey) else {
   1484                 return false
   1485             }
   1486             // The friend writes their name into *our inbox* (their outbox), so
   1487             // a friend's name only ever arrives via the zone we own — the
   1488             // private engine. Reject one seen at shared scope (that would be
   1489             // the friend's own inbox, not the channel they tell us their name
   1490             // through).
   1491             guard databaseScope == .private else { return false }
   1492             let version = decisionVersion(record)
   1493             let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity")
   1494             req.predicate = NSPredicate(format: "pairKey == %@", pairKey)
   1495             req.fetchLimit = 1
   1496             if let friend = try? ctx.fetch(req).first {
   1497                 guard !friend.isBlocked,
   1498                       version >= friend.displayNameVersion
   1499                 else { return false }
   1500                 friend.displayName = name
   1501                 friend.displayNameVersion = version
   1502                 return true
   1503             }
   1504             // No local row: the bootstrap evidence (game zones, `.friend`
   1505             // Ping) may be long gone on a restored device, but the name
   1506             // Decision arriving from our inbox is itself proof of the
   1507             // friendship — resurrect the row. Both mailbox zones are derivable
   1508             // from `pairKey` + `authorID`, so no zone fields are stored.
   1509             let friend = FriendEntity(context: ctx)
   1510             friend.authorID = key
   1511             friend.pairKey = pairKey
   1512             friend.createdAt = Date()
   1513             friend.displayName = name
   1514             friend.displayNameVersion = version
   1515             return true
   1516         case nicknameDecisionKind:
   1517             // The user's private nickname for a friend, authoritative across
   1518             // their own devices. Honored only from the account zone in our own
   1519             // private database: friend zones are writable by the other
   1520             // participant, who must not be able to relabel people in this
   1521             // user's friends list. Match on zone *name* + private scope, not a
   1522             // full `zoneID ==`: a record fetched back from CloudKit does not
   1523             // reliably carry the `CKCurrentUserDefaultName` owner placeholder
   1524             // `accountZoneID` is built with — its `ownerName` often comes back
   1525             // as the concrete user-record ID, so an `==` silently rejects every
   1526             // synced nickname. Scoping to the private DB keeps the anti-relabel
   1527             // guarantee (a friend can only reach us through the shared DB).
   1528             // Same gate as `block`/`left`.
   1529             guard isAccountZonePrivateDecision(record, databaseScope: databaseScope)
   1530             else { return false }
   1531             let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity")
   1532             req.predicate = NSPredicate(format: "authorID == %@", key)
   1533             req.fetchLimit = 1
   1534             // No resurrection: unlike a name Decision, an account-zone row
   1535             // carries no zone provenance to rebuild a usable friendship from,
   1536             // and a zoneless row would surface as an uninvitable friend.
   1537             guard let friend = try? ctx.fetch(req).first else { return false }
   1538             let version = decisionVersion(record)
   1539             guard version >= friend.nicknameVersion else { return false }
   1540             let nickname = (record["payload"] as? String)?
   1541                 .trimmingCharacters(in: .whitespacesAndNewlines)
   1542             // Empty/absent payload is a deliberate clear, not a malformed
   1543             // record — the rename alert's blank entry reverts to their name.
   1544             friend.nickname = (nickname?.isEmpty == false) ? nickname : nil
   1545             friend.nicknameVersion = version
   1546             return true
   1547         case encryptionKeyDecisionKind:
   1548             // Friend-zone message encryption material. Each participant writes
   1549             // only their own key record, named by authorID; the peer mirrors it
   1550             // to the App Group so the Notification Service Extension can
   1551             // decrypt invite pushes before the game zone exists locally.
   1552             guard let localAuthorID, !localAuthorID.isEmpty,
   1553                   key != localAuthorID,
   1554                   let payload = FriendEncryptionKeyPayload.decode(record["payload"] as? String)
   1555             else { return false }
   1556             let pairKey = FriendZone.pairKey(localAuthorID, key)
   1557             let zoneID = record.recordID.zoneID
   1558             guard zoneID.zoneName == FriendZone.zoneName(pairKey: pairKey) else {
   1559                 return false
   1560             }
   1561             // Like the `name` case: the friend writes their key into *our inbox*
   1562             // (their outbox), so it only ever arrives via the zone we own — the
   1563             // private engine. Reject one seen at shared scope (that would be
   1564             // the friend's own inbox, not the channel they publish to us). The
   1565             // zone-name gate alone is insufficient: a peer owns a zone that
   1566             // *names* itself for this pair and shares it to us at shared scope.
   1567             guard databaseScope == .private else { return false }
   1568             FriendEncryptionKeyDirectory.upsert(payload, for: key)
   1569             return false
   1570         default:
   1571             // Unknown kind from a newer build — ignore rather than guess.
   1572             return false
   1573         }
   1574     }
   1575 
   1576     // MARK: - System fields encode/decode
   1577 
   1578     static func encodeSystemFields(of record: CKRecord) -> Data? {
   1579         let coder = NSKeyedArchiver(requiringSecureCoding: true)
   1580         record.encodeSystemFields(with: coder)
   1581         coder.finishEncoding()
   1582         return coder.encodedData
   1583     }
   1584 
   1585     static func decodeRecord(from data: Data) -> CKRecord? {
   1586         guard let coder = try? NSKeyedUnarchiver(forReadingFrom: data) else { return nil }
   1587         coder.requiresSecureCoding = true
   1588         let record = CKRecord(coder: coder)
   1589         coder.finishDecoding()
   1590         return record
   1591     }
   1592 
   1593     /// Returns `true` when `incoming` reflects a server state at least as
   1594     /// recent as the modification date encoded in `existingFields`. Used by
   1595     /// the apply paths to drop fetched snapshots that arrive after our
   1596     /// writeback has already adopted a newer change tag — adopting them
   1597     /// would downgrade the local etag and the next save would OpLock-fail.
   1598     /// Defaults to `true` when either side lacks a modification date so a
   1599     /// first-time fetch can land.
   1600     static func incomingIsAtLeastAsFresh(
   1601         _ incoming: CKRecord,
   1602         existingFields: Data?
   1603     ) -> Bool {
   1604         guard let existingFields,
   1605               let existingRecord = decodeRecord(from: existingFields),
   1606               let existingDate = existingRecord.modificationDate,
   1607               let incomingDate = incoming.modificationDate
   1608         else { return true }
   1609         return incomingDate >= existingDate
   1610     }
   1611 
   1612     // MARK: - Private helpers
   1613 
   1614     /// Restores a `CKRecord` from archived system fields (preserving the
   1615     /// server change tag) or creates a fresh one if no archive is available.
   1616     private static func restoreOrCreate(
   1617         recordType: String,
   1618         recordName: String,
   1619         zone: CKRecordZone.ID,
   1620         systemFields: Data?
   1621     ) -> CKRecord {
   1622         if let data = systemFields, let restored = decodeRecord(from: data) {
   1623             return restored
   1624         }
   1625         let recordID = CKRecord.ID(recordName: recordName, zoneID: zone)
   1626         return CKRecord(recordType: recordType, recordID: recordID)
   1627     }
   1628 
   1629     private static func fetchOrCreate(
   1630         entityName: String,
   1631         recordName: String,
   1632         zoneID: CKRecordZone.ID,
   1633         databaseScope: DatabaseScope,
   1634         in context: NSManagedObjectContext
   1635     ) -> NSManagedObject {
   1636         let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
   1637         request.predicate = gameIdentityPredicate(
   1638             recordName: recordName,
   1639             zoneID: zoneID,
   1640             databaseScope: databaseScope
   1641         )
   1642         request.fetchLimit = 1
   1643         if let existing = try? context.fetch(request).first {
   1644             return existing
   1645         }
   1646         return NSEntityDescription.insertNewObject(forEntityName: entityName, into: context)
   1647     }
   1648 
   1649 }