crossmate

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

SyncEngine.swift (111338B)


      1 import CloudKit
      2 import CoreData
      3 import Foundation
      4 import SwiftUI
      5 
      6 extension EnvironmentValues {
      7     @Entry var syncEngine: SyncEngine? = nil
      8     @Entry var resetDatabase: (() async -> Void)? = nil
      9     /// `(gameID, friendAuthorID)` — re-invites an existing friend to a game.
     10     @Entry var inviteFriend: ((UUID, String) async throws -> Void)? = nil
     11     /// `(shareURL, pingRecordName)` — accepts a pending game invite.
     12     @Entry var acceptInvite: ((String, String) async throws -> Void)? = nil
     13     /// `(gameID)` — declines a pending game invite (tombstone + badge refresh).
     14     @Entry var declineInvite: ((UUID) async throws -> Void)? = nil
     15     /// `(friendAuthorID)` — blocks a collaborator: suppress future invites,
     16     /// leave their shared games, tear down the friend zone.
     17     @Entry var blockFriend: ((String) async -> Void)? = nil
     18     /// `(friendAuthorID, nickname)` — sets the user's private nickname for a
     19     /// friend; an empty nickname clears it back to the friend's own name.
     20     @Entry var renameFriend: ((String, String) async -> Void)? = nil
     21     /// `(gameID)` — fires the completion APN after a game is resigned from
     22     /// the library (the in-puzzle path uses `onResign` directly).
     23     @Entry var sendResignPings: ((UUID) async -> Void)? = nil
     24 }
     25 
     26 extension Notification.Name {
     27     /// Posted by `SyncEngine` after applying fetched record zone changes that
     28     /// touched a game's roster-relevant state — a Player record, a Game record,
     29     /// a deletion, or a new contributor's first Moves row (see
     30     /// `BatchEffects.rosterRelevant`). `userInfo["gameIDs"]` is a `Set<UUID>`.
     31     /// `PlayerRoster` observes this to refresh in response to remote name /
     32     /// cursor updates and new participants joining. Repeat Moves from a known
     33     /// contributor (peer letters) are excluded — they don't change the roster.
     34     static let playerRosterShouldRefresh = Notification.Name("playerRosterShouldRefresh")
     35 
     36     /// Posted by `SyncEngine` when an inbound `Journal` record lands for one or
     37     /// more games. `userInfo["gameIDs"]` is a `Set<UUID>`. The finish-banner
     38     /// replay observes this to re-check completeness the moment a contributor's
     39     /// journal syncs, instead of polling.
     40     static let replayJournalDidSync = Notification.Name("replayJournalDidSync")
     41 }
     42 
     43 
     44 /// Owns the CloudKit sync lifecycle via two `CKSyncEngine` instances — one for
     45 /// the private database (owned games and shares) and one for the shared
     46 /// database (joined games). Zone creation, subscription setup, change-token
     47 /// management, batching, and retry are all delegated to the framework.
     48 /// This actor's job is to:
     49 ///
     50 /// - Start and persist each engine's state across launches.
     51 /// - Translate outbound edits (from `MovesUpdater`) into pending record zone
     52 ///   changes that CKSyncEngine will batch and send.
     53 /// - Apply incoming `Moves`, `Game`, and `Player` records to Core Data and
     54 ///   replay them onto the `CellEntity` cache.
     55 /// - Notify the main actor so the in-memory `Game` stays current.
     56 actor SyncEngine {
     57     let container: CKContainer
     58     let persistence: PersistenceController
     59 
     60     var privateEngine: CKSyncEngine?
     61     var sharedEngine: CKSyncEngine?
     62 
     63     /// In-memory map for Ping records pending send. Pings have no Core Data
     64     /// backing — they're write-once-and-forget — so we stash the minimal data
     65     /// here keyed by record name and look it up in `buildRecord`.
     66     private var pendingPings: [String: PingPayload] = [:]
     67     /// Payloads for `Decision` records pending send, keyed by
     68     /// `decisionStateKey` (zone + record name — the same decision record can
     69     /// be pending in several zones at once, e.g. a name Decision fanned out
     70     /// to every friend zone, and one zone's save must not strip the others').
     71     /// Unlike a ping body, these must survive an app kill: CKSyncEngine persists
     72     /// the pending `.saveRecord`, so on relaunch `buildRecord` would otherwise
     73     /// emit a payload-less Decision (e.g. an empty `pushSecret`) that uploads as
     74     /// a poison record other devices can't parse and this device never
     75     /// republishes. Mirrored to UserDefaults on every mutation and restored in
     76     /// `start()`.
     77     private var pendingDecisionPayloads: [String: String] = [:]
     78 
     79     private static let pendingDecisionPayloadsDefaultsKey =
     80         "SyncEngine.pendingDecisionPayloads"
     81 
     82     /// Intended generation for a versioned `Decision` pending send, keyed by
     83     /// `decisionStateKey`. Mirrors `pendingDecisionPayloads`' lifecycle and
     84     /// durability: the version must survive an app kill so a rebuilt rotation
     85     /// re-asserts at the right generation rather than a stale one. The push
     86     /// secret and display name are versioned; unversioned decisions
     87     /// (block/left/pushAddress) have no entry and stay write-once on conflict.
     88     private var pendingDecisionVersions: [String: Int64] = [:]
     89 
     90     private static let pendingDecisionVersionsDefaultsKey =
     91         "SyncEngine.pendingDecisionVersions"
     92 
     93     /// Server system fields adopted while recovering a *versioned* Decision that
     94     /// lost the change-tag race but won on version — stashed so the next
     95     /// `buildRecord` carries the server's tag and the overwrite is accepted
     96     /// rather than re-colliding. In-memory only: the version in
     97     /// `pendingDecisionVersions` carries correctness across a relaunch (a
     98     /// tagless re-send simply re-hits the conflict and re-recovers), so this is
     99     /// a round-trip optimization, not durable state. Keyed by
    100     /// `decisionStateKey` — the same record name carries a different change
    101     /// tag in each zone it lives in. Cleared once the record saves or settles.
    102     private var decisionSystemFields: [String: Data] = [:]
    103 
    104     /// Key for the per-zone decision state above. A decision's record name is
    105     /// deterministic, so the same name can be pending in the account zone and
    106     /// several friend zones simultaneously; the zone name disambiguates.
    107     nonisolated static func decisionStateKey(_ recordID: CKRecord.ID) -> String {
    108         "\(recordID.zoneID.zoneName)/\(recordID.recordName)"
    109     }
    110 
    111     struct PingPayload {
    112         let gameID: UUID
    113         let authorID: String
    114         let deviceID: String
    115         let playerName: String
    116         let puzzleTitle: String
    117         let eventTimestampMs: Int64
    118         let kind: PingKind
    119         let payload: String?
    120         let addressee: String?
    121     }
    122 
    123     /// Label for the in-flight fetch — surfaced in traces so the diagnostics
    124     /// log can distinguish push-driven fetches from polls / foreground / etc.
    125     /// `nil` means CKSyncEngine drove the fetch itself (its internal scheduler).
    126     private var currentFetchSource: String?
    127     /// One-shot flag — set the first time we observe shared-DB content
    128     /// arriving via a push-triggered fetch. Confirms the silent-push path is
    129     /// actually wired up end-to-end.
    130     private var loggedFirstSharedPushPayload = false
    131 
    132     var onRemoteMovesUpdated: (@MainActor @Sendable (Set<UUID>) async -> Void)?
    133     /// Fires with the game IDs for which a collaborator's `Player` record was
    134     /// seen for the **first time** (a new `PlayerEntity` was created) — not on
    135     /// their subsequent name / cursor updates. Independent of moves; the
    136     /// friendship bootstrap keys off this so a collaborator becomes a friend
    137     /// once, as soon as their identity syncs, without waiting for a move.
    138     var onRemotePlayersUpdated: (@MainActor @Sendable (Set<UUID>) async -> Void)?
    139     /// Fires when a remote collaborator's `Player` record updates active
    140     /// presence state (selection set/cleared or refreshed). Unlike
    141     /// `onRemotePlayersUpdated`, this fires for existing Player records too,
    142     /// so live engagement can start when a known collaborator opens a puzzle.
    143     var onRemotePlayerPresenceChanged: (@MainActor @Sendable (Set<UUID>) async -> Void)?
    144     /// Fires with the game IDs whose Game-record `engagement` creds just
    145     /// changed (a peer minted or rotated the room). Drives the receiver to
    146     /// reconcile its live connection toward the new creds.
    147     var onRemoteEngagementChanged: (@MainActor @Sendable (Set<UUID>) async -> Void)?
    148     var onPings: (@MainActor @Sendable ([Ping]) async -> Void)?
    149     private var onAccountChange: (@MainActor @Sendable () async -> Void)?
    150     private var onGameAccessRevoked: (@MainActor @Sendable (UUID) async -> Void)?
    151     private var onGameRemoved: (@MainActor @Sendable (UUID) async -> Void)?
    152     /// Fires when an inbound Game record transitions a local row to completed.
    153     /// App-level side effects that are not sync-engine state (for example
    154     /// closing public share tickets) hang off this edge.
    155     var onGameCompleted: (@MainActor @Sendable (UUID) async -> Void)?
    156     /// Fires with the game ID of a shared zone that just appeared locally —
    157     /// the user joined the game here or on a sibling device. Drives cleanup
    158     /// of the now-redundant pending invite row.
    159     private var onGameJoined: (@MainActor @Sendable (UUID) async -> Void)?
    160     /// Fires with Ping records that were just deleted on the server (a sibling
    161     /// device consumed a directed ping). Drives cross-device withdrawal of the
    162     /// notification and any local durable invite row this device may hold.
    163     var onPingDeleted: (@MainActor @Sendable ([(recordName: String, gameID: UUID)]) async -> Void)?
    164     /// Fires with (gameID, readAt) pairs lifted from inbound Player records
    165     /// whose authorID matches the local user. A sibling device has recorded
    166     /// the account's read horizon; active sessions may move it into the near
    167     /// future and later close it with a lower current-time value.
    168     var onIncomingReadCursor: (@MainActor @Sendable ([(UUID, Date, Data?)]) async -> Void)?
    169     var onAccountPushAddress: (@MainActor @Sendable (String) async -> Void)?
    170     var onAccountPushSecret: (@MainActor @Sendable (String, Int64) async -> Void)?
    171     private var localAuthorIDProvider: (@MainActor @Sendable () -> String?)?
    172     private var tracer: (@MainActor @Sendable (String) -> Void)?
    173     /// Fires when a delegate event reports a successful round-trip with the
    174     /// CloudKit server (fetched DB changes, fetched zone changes, or sent
    175     /// zone changes with no failures). Bumps `Last Success` in the
    176     /// diagnostics view so the timestamp reflects actual engine activity
    177     /// rather than only instrumented phases run through `SyncMonitor.run`.
    178     private var successCheckpoint: (@MainActor @Sendable () -> Void)?
    179     var liveQueryCheckpoints: [String: Date] = [:]
    180     let liveQueryCheckpointOverlap: TimeInterval = 5
    181 
    182     /// Per-scope checkpoint for the background ping fast path. Independent of
    183     /// CKSyncEngine's change tokens and of `liveQueryCheckpoints` (which are
    184     /// per-zone and Moves/Player oriented). Keyed by databaseScope value
    185     /// (0 = private, 1 = shared).
    186     var pingPushCheckpoints: [Int16: Date] = [:]
    187     let pingPushCheckpointOverlap: TimeInterval = 30
    188     /// Per-scope record-name → modificationDate of Ping records already
    189     /// surfaced by the fast path. The time-window query deliberately re-fetches
    190     /// anything within `pingPushCheckpointOverlap` of the floor (skew safety),
    191     /// so without this a record — unboundedly, the newest one — would re-emit
    192     /// on every push. Pruned to the overlap window each scan, so it stays
    193     /// small. Mirrors the presentation-layer dedupe in `NotificationState`.
    194     var seenPingRecords: [Int16: [String: Date]] = [:]
    195     let backgroundSessionLookback: TimeInterval = 10 * 60
    196 
    197     func setTracer(_ t: @MainActor @Sendable @escaping (String) -> Void) {
    198         tracer = t
    199     }
    200 
    201     func setSuccessCheckpoint(_ cb: @MainActor @Sendable @escaping () -> Void) {
    202         successCheckpoint = cb
    203     }
    204 
    205     private func noteRoundTripSuccess() async {
    206         guard let successCheckpoint else { return }
    207         await successCheckpoint()
    208     }
    209 
    210     func setOnRemoteMovesUpdated(_ cb: @MainActor @Sendable @escaping (Set<UUID>) async -> Void) {
    211         onRemoteMovesUpdated = cb
    212     }
    213 
    214     func setOnRemotePlayersUpdated(_ cb: @MainActor @Sendable @escaping (Set<UUID>) async -> Void) {
    215         onRemotePlayersUpdated = cb
    216     }
    217 
    218     func setOnRemotePlayerPresenceChanged(_ cb: @MainActor @Sendable @escaping (Set<UUID>) async -> Void) {
    219         onRemotePlayerPresenceChanged = cb
    220     }
    221 
    222     func setOnRemoteEngagementChanged(_ cb: @MainActor @Sendable @escaping (Set<UUID>) async -> Void) {
    223         onRemoteEngagementChanged = cb
    224     }
    225 
    226     func setOnPings(_ cb: @MainActor @Sendable @escaping ([Ping]) async -> Void) {
    227         onPings = cb
    228     }
    229 
    230     func setOnAccountChange(_ cb: @MainActor @Sendable @escaping () async -> Void) {
    231         onAccountChange = cb
    232     }
    233 
    234     func setOnGameAccessRevoked(_ cb: @MainActor @Sendable @escaping (UUID) async -> Void) {
    235         onGameAccessRevoked = cb
    236     }
    237 
    238     func setOnGameRemoved(_ cb: @MainActor @Sendable @escaping (UUID) async -> Void) {
    239         onGameRemoved = cb
    240     }
    241 
    242     func setOnGameCompleted(_ cb: @MainActor @Sendable @escaping (UUID) async -> Void) {
    243         onGameCompleted = cb
    244     }
    245 
    246     func setOnGameJoined(_ cb: @MainActor @Sendable @escaping (UUID) async -> Void) {
    247         onGameJoined = cb
    248     }
    249 
    250     func setOnPingDeleted(
    251         _ cb: @MainActor @Sendable @escaping ([(recordName: String, gameID: UUID)]) async -> Void
    252     ) {
    253         onPingDeleted = cb
    254     }
    255 
    256     func setOnIncomingReadCursor(_ cb: @MainActor @Sendable @escaping ([(UUID, Date, Data?)]) async -> Void) {
    257         onIncomingReadCursor = cb
    258     }
    259 
    260     func setOnAccountPushAddress(_ cb: @MainActor @Sendable @escaping (String) async -> Void) {
    261         onAccountPushAddress = cb
    262     }
    263 
    264     func setOnAccountPushSecret(_ cb: @MainActor @Sendable @escaping (String, Int64) async -> Void) {
    265         onAccountPushSecret = cb
    266     }
    267 
    268     func setLocalAuthorIDProvider(_ cb: @MainActor @Sendable @escaping () -> String?) {
    269         localAuthorIDProvider = cb
    270     }
    271 
    272     init(container: CKContainer, persistence: PersistenceController) {
    273         self.container = container
    274         self.persistence = persistence
    275     }
    276 
    277     // MARK: - Lifecycle
    278 
    279     /// Database subscription IDs. Stable, per-scope, idempotent on re-creation
    280     /// so we can always attempt a save without first listing.
    281     private static let privateSubscriptionID = "crossmate-private-db-subscription"
    282     private static let sharedSubscriptionID = "crossmate-shared-db-subscription"
    283 
    284     /// Creates both `CKSyncEngine` instances, restoring previously-saved state
    285     /// so pending changes and change tokens survive restarts. Call once after
    286     /// wiring callbacks. Idempotent — extra calls are no-ops so a race between
    287     /// `services.start()` and a scene-active foreground sync can't double-
    288     /// initialise the engines or re-fire subscription setup.
    289     func start() async {
    290         guard privateEngine == nil, sharedEngine == nil else { return }
    291 
    292         // CKSyncEngine restores its pending `.saveRecord` changes from the
    293         // serialized state below; restore the matching Decision payloads so a
    294         // pending decision rebuilds with its body instead of as a poison record.
    295         restorePendingDecisionPayloads()
    296         restorePendingDecisionVersions()
    297 
    298         let bgCtx = persistence.container.newBackgroundContext()
    299 
    300         let privateData: Data? = bgCtx.performAndWait {
    301             SyncStateEntity.current(in: bgCtx).ckPrivateEngineState
    302         }
    303         let privateState = await decodeEngineState(privateData, label: "private")
    304         privateEngine = CKSyncEngine(CKSyncEngine.Configuration(
    305             database: container.privateCloudDatabase,
    306             stateSerialization: privateState,
    307             delegate: self
    308         ))
    309 
    310         let sharedData: Data? = bgCtx.performAndWait {
    311             SyncStateEntity.current(in: bgCtx).ckSharedEngineState
    312         }
    313         let sharedState = await decodeEngineState(sharedData, label: "shared")
    314         sharedEngine = CKSyncEngine(CKSyncEngine.Configuration(
    315             database: container.sharedCloudDatabase,
    316             stateSerialization: sharedState,
    317             delegate: self
    318         ))
    319 
    320         // CKSyncEngine's automatic subscription creation is unreliable in
    321         // practice — diagnostics on real devices showed both scopes with zero
    322         // subscriptions even after a healthy initial fetch and push, which
    323         // means CloudKit never fires pushes and the engine silently degrades
    324         // to its periodic poll. Create the database subscriptions ourselves;
    325         // CKDatabase.save is idempotent for an existing subscriptionID.
    326         Task { await ensureDatabaseSubscriptions() }
    327         Task { await purgeLegacyLeasePings_v1() }
    328         Task { await purgeLegacyInvitePings_v1() }
    329         Task { await purgeStaleHailPings_v1() }
    330         Task { await purgeDebugPreviewFriends_v1() }
    331         Task { await purgeLegacyPlayPings_v1() }
    332     }
    333 
    334     private func ensureDatabaseSubscriptions() async {
    335         await ensureDatabaseSubscription(
    336             database: container.privateCloudDatabase,
    337             subscriptionID: Self.privateSubscriptionID,
    338             label: "private"
    339         )
    340         await ensureDatabaseSubscription(
    341             database: container.sharedCloudDatabase,
    342             subscriptionID: Self.sharedSubscriptionID,
    343             label: "shared"
    344         )
    345     }
    346 
    347     private func ensureDatabaseSubscription(
    348         database: CKDatabase,
    349         subscriptionID: String,
    350         label: String
    351     ) async {
    352         do {
    353             let existing = try await database.allSubscriptions()
    354             if existing.contains(where: { $0.subscriptionID == subscriptionID }) {
    355                 await trace("\(label) subscription already present (\(subscriptionID))")
    356                 return
    357             }
    358         } catch {
    359             await trace("\(label) allSubscriptions probe failed: \(describe(error)) — attempting save anyway")
    360         }
    361         let subscription = CKDatabaseSubscription(subscriptionID: subscriptionID)
    362         let info = CKSubscription.NotificationInfo()
    363         info.shouldSendContentAvailable = true
    364         subscription.notificationInfo = info
    365         do {
    366             _ = try await database.save(subscription)
    367             await trace("\(label) subscription created (\(subscriptionID))")
    368         } catch {
    369             await trace("\(label) subscription save FAILED: \(describe(error))")
    370         }
    371     }
    372 
    373     /// Kicks CKSyncEngine's outbound drain after pending state has been queued.
    374     /// This must be detached: several enqueue paths are reachable from
    375     /// CKSyncEngine delegate callbacks, and a plain `Task {}` can inherit the
    376     /// callback's executor and re-enter CKSyncEngine before the callback
    377     /// unwinds, tripping CloudKit's serialization guard.
    378     private func sendChangesDetached(on engine: CKSyncEngine) {
    379         Task.detached { [engine] in try? await engine.sendChanges() }
    380     }
    381 
    382     /// Per-scope burst depth. `enqueuePlayer` consults this — while
    383     /// non-zero for a scope, it records that a drain is owed instead of
    384     /// firing `sendChanges` immediately. The outermost frame issues one
    385     /// `sendChanges` on exit if any enqueue landed during the burst.
    386     /// Replaces an earlier time-window coalesce that had to be tuned to
    387     /// match `PlayerSelectionPublisher`'s debounce: the open path now
    388     /// explicitly fans out read cursor, name, and the initial selection
    389     /// inside a burst, so the batch is bounded by the work that produces
    390     /// it rather than by a wall-clock window.
    391     private var playerSendBurstDepth: [Int16: Int] = [:]
    392     private var playerSendBurstPending: Set<Int16> = []
    393 
    394     /// Opens a Player-record send burst for `gameID`'s scope. Subsequent
    395     /// `enqueuePlayer` calls on that scope skip their immediate
    396     /// drain; the caller must pair this with `endPlayerSendBurst(scope:)`,
    397     /// which fires one `sendChanges` if any enqueue landed in between.
    398     /// Returns the scope so the caller can close the matching burst even
    399     /// if the game's zone routing changes after the call.
    400     func beginPlayerSendBurst(gameID: UUID) -> Int16? {
    401         let ctx = persistence.container.newBackgroundContext()
    402         guard let info = zoneInfo(forGameID: gameID, in: ctx) else { return nil }
    403         playerSendBurstDepth[info.scope, default: 0] += 1
    404         return info.scope
    405     }
    406 
    407     /// Closes a burst opened by `beginPlayerSendBurst`. The outermost
    408     /// frame drains the affected engine if any enqueue landed during the
    409     /// burst; nested frames just decrement the counter.
    410     func endPlayerSendBurst(scope: Int16) {
    411         guard let depth = playerSendBurstDepth[scope], depth > 0 else { return }
    412         if depth > 1 {
    413             playerSendBurstDepth[scope] = depth - 1
    414             return
    415         }
    416         playerSendBurstDepth.removeValue(forKey: scope)
    417         guard playerSendBurstPending.remove(scope) != nil else { return }
    418         let engine = scope == 1 ? sharedEngine : privateEngine
    419         guard let engine else { return }
    420         sendChangesDetached(on: engine)
    421     }
    422 
    423     // MARK: - Outbound
    424 
    425     /// Registers each game's local-device Moves record as a pending save,
    426     /// routed per-game to the correct engine. Called by the `MovesUpdater`
    427     /// sink after the device's `MovesEntity` row has been merged and persisted.
    428     ///
    429     /// `drain` controls whether to force an eager `sendChanges()`:
    430     ///
    431     /// - `false` (live typing debounce): the engagement socket already carries
    432     ///   the letters via `cellEdit`/`cellEditBatch`, so CloudKit is the durable
    433     ///   backstop ("appears on sync"), not the live path. With no peer watching
    434     ///   the durable write in real time, leaving the drain to CKSyncEngine's
    435     ///   automatic scheduler lets it coalesce a typing burst into far fewer
    436     ///   round-trips and avoids the self-induced oplock races two concurrent
    437     ///   drains produced over the same `CKRecord.ID`.
    438     /// - `true` (explicit `flush()` on leave/background, and recovery via
    439     ///   `enqueueUnconfirmedMoves`): the solver's final letters must reach
    440     ///   CloudKit promptly even when no peer is on the socket, so force the
    441     ///   send rather than waiting for the next foreground.
    442     func enqueueMoves(gameIDs: Set<UUID>, drain: Bool = true) async {
    443         guard !gameIDs.isEmpty else { return }
    444         // The local-device row is matched by author as well as device: after
    445         // an iCloud account switch this device's old rows persist under the
    446         // previous authorID (the record name embeds the author, so they are
    447         // distinct rows), and a device-only `fetchLimit = 1` lookup could pick
    448         // the old account's record and push a save the user no longer owns.
    449         // When no author is known yet (provider unset, first launch) fall back
    450         // to the device-only match, which is unambiguous until a switch has
    451         // happened.
    452         let localAuthorID = await currentLocalAuthorID()
    453         let ctx = persistence.container.newBackgroundContext()
    454         let (privateRecordIDs, sharedRecordIDs): ([CKRecord.ID], [CKRecord.ID]) = ctx.performAndWait {
    455             var privateIDs: [CKRecord.ID] = []
    456             var sharedIDs: [CKRecord.ID] = []
    457             for gameID in gameIDs {
    458                 guard let info = zoneInfo(forGameID: gameID, in: ctx) else { continue }
    459                 let isShared = info.scope == 1
    460                 let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity")
    461                 if let localAuthorID, !localAuthorID.isEmpty {
    462                     req.predicate = NSPredicate(
    463                         format: "game.id == %@ AND deviceID == %@ AND authorID == %@",
    464                         gameID as CVarArg,
    465                         RecordSerializer.localDeviceID,
    466                         localAuthorID
    467                     )
    468                 } else {
    469                     req.predicate = NSPredicate(
    470                         format: "game.id == %@ AND deviceID == %@",
    471                         gameID as CVarArg,
    472                         RecordSerializer.localDeviceID
    473                     )
    474                 }
    475                 req.fetchLimit = 1
    476                 guard let entity = try? ctx.fetch(req).first,
    477                       let recordName = entity.ckRecordName
    478                 else { continue }
    479                 let recordID = CKRecord.ID(recordName: recordName, zoneID: info.zoneID)
    480                 if isShared {
    481                     sharedIDs.append(recordID)
    482                 } else {
    483                     privateIDs.append(recordID)
    484                 }
    485             }
    486             return (privateIDs, sharedIDs)
    487         }
    488         if !privateRecordIDs.isEmpty, let engine = privateEngine {
    489             engine.state.add(
    490                 pendingRecordZoneChanges: privateRecordIDs.map { .saveRecord($0) }
    491             )
    492             if drain { sendChangesDetached(on: engine) }
    493         }
    494         if !sharedRecordIDs.isEmpty, let engine = sharedEngine {
    495             engine.state.add(
    496                 pendingRecordZoneChanges: sharedRecordIDs.map { .saveRecord($0) }
    497             )
    498             if drain { sendChangesDetached(on: engine) }
    499         }
    500     }
    501 
    502     /// Re-enqueues locally persisted Moves rows that do not yet have CloudKit
    503     /// system fields. Covers crash/relaunch recovery and any save that reached
    504     /// Core Data before CKSyncEngine recorded the pending change. Scoped to the
    505     /// current author for the same reason as `enqueueMoves`: an account
    506     /// switch leaves the old account's unconfirmed rows behind on this device,
    507     /// and recovery must not push them under the new account.
    508     @discardableResult
    509     func enqueueUnconfirmedMoves() async -> Int {
    510         let localAuthorID = await currentLocalAuthorID()
    511         let ctx = persistence.container.newBackgroundContext()
    512         let gameIDs: Set<UUID> = ctx.performAndWait {
    513             let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity")
    514             if let localAuthorID, !localAuthorID.isEmpty {
    515                 req.predicate = NSPredicate(
    516                     format: "ckSystemFields == nil AND deviceID == %@ AND authorID == %@",
    517                     RecordSerializer.localDeviceID,
    518                     localAuthorID
    519                 )
    520             } else {
    521                 req.predicate = NSPredicate(
    522                     format: "ckSystemFields == nil AND deviceID == %@",
    523                     RecordSerializer.localDeviceID
    524                 )
    525             }
    526             let entities = (try? ctx.fetch(req)) ?? []
    527             return Set(entities.compactMap { $0.game?.id })
    528         }
    529         await enqueueMoves(gameIDs: gameIDs)
    530         return gameIDs.count
    531     }
    532 
    533     /// Registers record deletions as pending sends. Extracts the game UUID
    534     /// from the record name and routes to the correct engine.
    535     /// Registers the game's CloudKit zone for deletion. Each game owns its
    536     /// own zone, so this removes all remote records for the puzzle, including
    537     /// moves, player records, pings, and share metadata.
    538     func enqueueDeleteGame(_ deletion: GameCloudDeletion) {
    539         let zoneID = CKRecordZone.ID(
    540             zoneName: deletion.ckZoneName,
    541             ownerName: deletion.ckZoneOwnerName
    542         )
    543         let engine = deletion.databaseScope == 1 ? sharedEngine : privateEngine
    544         guard let engine else { return }
    545         engine.state.add(pendingDatabaseChanges: [.deleteZone(zoneID)])
    546         sendChangesDetached(on: engine)
    547 
    548         // A finished participant game keeps a self-contained backup in a
    549         // separate archive-<id> zone (see GameArchiver). The live game lives in
    550         // the shared database, so the deletion above never reaches that backup —
    551         // tear it down here. The archive is always in this account's private
    552         // database, so it routes through the private engine regardless of the
    553         // game's own scope.
    554         guard let archiveZoneName = deletion.archiveZoneName,
    555               let privateEngine else { return }
    556         let archiveZoneID = CKRecordZone.ID(
    557             zoneName: archiveZoneName,
    558             ownerName: CKCurrentUserDefaultName
    559         )
    560         privateEngine.state.add(pendingDatabaseChanges: [.deleteZone(archiveZoneID)])
    561         sendChangesDetached(on: privateEngine)
    562     }
    563 
    564     /// Registers a Ping record as a pending send. Pings now only cover
    565     /// bootstrap kinds (`.join` / `.friend` / `.invite` / `.hail`) — the
    566     /// user-facing play events go through the push worker. Sender-only
    567     /// state: the payload is stashed in `pendingPings` and only used to
    568     /// build the outgoing `CKRecord`; nothing is persisted.
    569     func enqueuePing(
    570         kind: PingKind,
    571         gameID: UUID,
    572         authorID: String,
    573         playerName: String,
    574         payload: String? = nil,
    575         addressee: String? = nil
    576     ) {
    577         let ctx = persistence.container.newBackgroundContext()
    578         let zoneAndTitle: (info: ZoneInfo, title: String)? = ctx.performAndWait {
    579             guard let info = self.zoneInfo(forGameID: gameID, in: ctx) else { return nil }
    580             let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    581             req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
    582             req.fetchLimit = 1
    583             let entity = try? ctx.fetch(req).first
    584             let title = PuzzleNotificationText.title(for: entity)
    585             return (info, title)
    586         }
    587         guard let zoneAndTitle else {
    588             Task {
    589                 await trace(
    590                     "ping send: SKIPPED kind=\(kind.rawValue) " +
    591                     "game=\(gameID.uuidString) " +
    592                     "— no zone info (game not yet synced/shared on this device)"
    593                 )
    594             }
    595             return
    596         }
    597         let engine = zoneAndTitle.info.scope == 1 ? sharedEngine : privateEngine
    598         guard let engine else {
    599             Task {
    600                 await trace(
    601                     "ping send: SKIPPED kind=\(kind.rawValue) " +
    602                     "game=\(gameID.uuidString) " +
    603                     "— no CKSyncEngine for " +
    604                     "\(zoneAndTitle.info.scope == 1 ? "shared" : "private") scope"
    605                 )
    606             }
    607             return
    608         }
    609         let deviceID = RecordSerializer.localDeviceID
    610         let eventTimestampMs = Int64(Date().timeIntervalSince1970 * 1000)
    611         let recordName = RecordSerializer.recordName(
    612             forPingInGame: gameID,
    613             authorID: authorID,
    614             deviceID: deviceID,
    615             eventTimestampMs: eventTimestampMs
    616         )
    617         pendingPings[recordName] = PingPayload(
    618             gameID: gameID,
    619             authorID: authorID,
    620             deviceID: deviceID,
    621             playerName: playerName,
    622             puzzleTitle: zoneAndTitle.title,
    623             eventTimestampMs: eventTimestampMs,
    624             kind: kind,
    625             payload: payload,
    626             addressee: addressee
    627         )
    628         let recordID = CKRecord.ID(recordName: recordName, zoneID: zoneAndTitle.info.zoneID)
    629         engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)])
    630         Task {
    631             await trace(
    632                 "ping send: enqueued kind=\(kind.rawValue) " +
    633                 "game=\(gameID.uuidString) " +
    634                 "target=\(zoneAndTitle.info.scope == 1 ? "shared" : "private") " +
    635                 "zone=\(zoneAndTitle.info.zoneID.zoneName) record=\(recordName)"
    636             )
    637         }
    638         sendChangesDetached(on: engine)
    639     }
    640 
    641     /// Registers an `.opened` Ping for cross-device notification dismissal.
    642     /// Written to the account zone in the private database, so only the
    643     /// authoring user's own devices receive it. The receive side filters
    644     /// self-sends by (authorID, deviceID).
    645     /// One-shot cleanup of legacy `.opened`/`.closed` lease pings from this
    646     /// device's slot in the private-DB account zone. The lease subsystem is
    647     /// gone (cross-device read state now rides `Player.readAt`), so any
    648     /// remaining records are dead weight — every device cleans its own slice
    649     /// the first time it launches the new build. Gated by an App-Group flag;
    650     /// failures retry on the next launch. Slated for removal in a future
    651     /// release once every device of every user has run it.
    652     func purgeLegacyLeasePings_v1() async {
    653         guard NotificationState.legacyLeasePurgeNeeded() else { return }
    654         let predicate = NSPredicate(
    655             format: "kind IN %@ AND deviceID == %@",
    656             ["opened", "closed"],
    657             RecordSerializer.localDeviceID
    658         )
    659         do {
    660             let records = try await queryRecords(
    661                 type: "Ping",
    662                 database: container.privateCloudDatabase,
    663                 zoneID: RecordSerializer.accountZoneID,
    664                 predicate: predicate,
    665                 desiredKeys: []
    666             )
    667             try await deleteRecords(
    668                 withIDs: records.map(\.recordID),
    669                 in: container.privateCloudDatabase
    670             )
    671             NotificationState.markLegacyLeasePurged()
    672             if !records.isEmpty {
    673                 await trace("legacy-lease purge: deleted \(records.count) record(s)")
    674             }
    675         } catch {
    676             await trace("legacy-lease purge failed: \(describe(error))")
    677         }
    678     }
    679 
    680     /// One-shot cleanup of legacy broadcast `.invite` pings from pairwise
    681     /// friend zones. Current invites are addressed with `addressee`; older
    682     /// ones were broadcast within the friend zone and can resurrect stale
    683     /// Game List rows after a device restores or replays zone history.
    684     func purgeLegacyInvitePings_v1() async {
    685         guard NotificationState.legacyInvitePurgeNeeded() else { return }
    686         let privateZones = friendZoneIDs(forScope: 0)
    687         let sharedZones = friendZoneIDs(forScope: 1)
    688         do {
    689             let privateDeleted = try await purgeLegacyInvitePings(
    690                 in: privateZones,
    691                 database: container.privateCloudDatabase
    692             )
    693             let sharedDeleted = try await purgeLegacyInvitePings(
    694                 in: sharedZones,
    695                 database: container.sharedCloudDatabase
    696             )
    697             NotificationState.markLegacyInvitePurged()
    698             let total = privateDeleted + sharedDeleted
    699             if total > 0 {
    700                 await trace("legacy-invite purge: deleted \(total) record(s)")
    701             }
    702         } catch {
    703             await trace("legacy-invite purge failed: \(describe(error))")
    704         }
    705     }
    706 
    707     private func purgeLegacyInvitePings(
    708         in zoneIDs: [CKRecordZone.ID],
    709         database: CKDatabase
    710     ) async throws -> Int {
    711         var deleted = 0
    712         let predicate = NSPredicate(format: "kind == %@", PingKind.invite.rawValue)
    713         for zoneID in zoneIDs {
    714             let records = try await queryRecords(
    715                 type: "Ping",
    716                 database: database,
    717                 zoneID: zoneID,
    718                 predicate: predicate,
    719                 desiredKeys: ["addressee"]
    720             )
    721             let legacyIDs = records
    722                 .filter { ($0["addressee"] as? String)?.isEmpty != false }
    723                 .map(\.recordID)
    724             try await deleteRecords(withIDs: legacyIDs, in: database)
    725             deleted += legacyIDs.count
    726         }
    727         return deleted
    728     }
    729 
    730     /// One-shot cleanup of stale legacy `.hail` records from known game
    731     /// zones. Hails are ephemeral bootstrap envelopes; any records written
    732     /// before the current cleanup/deletion path shipped can only replay
    733     /// obsolete handshakes, so remove them once per device.
    734     func purgeStaleHailPings_v1() async {
    735         guard NotificationState.staleHailPurgeNeeded() else { return }
    736         do {
    737             let privateDeleted = try await purgeStaleHailPings(
    738                 in: gameZoneIDs(forScope: 0),
    739                 database: container.privateCloudDatabase
    740             )
    741             let sharedDeleted = try await purgeStaleHailPings(
    742                 in: gameZoneIDs(forScope: 1),
    743                 database: container.sharedCloudDatabase
    744             )
    745             NotificationState.markStaleHailPurged()
    746             let total = privateDeleted + sharedDeleted
    747             await trace("stale-hail purge: deleted \(total) record(s)")
    748         } catch {
    749             await trace("stale-hail purge failed: \(describe(error))")
    750         }
    751     }
    752 
    753     private func purgeStaleHailPings(
    754         in zoneIDs: [CKRecordZone.ID],
    755         database: CKDatabase
    756     ) async throws -> Int {
    757         var deleted = 0
    758         let predicate = NSPredicate(format: "kind == %@", PingKind.hail.rawValue)
    759         for zoneID in zoneIDs {
    760             let records = try await queryRecords(
    761                 type: "Ping",
    762                 database: database,
    763                 zoneID: zoneID,
    764                 predicate: predicate,
    765                 desiredKeys: []
    766             )
    767             try await deleteRecords(withIDs: records.map(\.recordID), in: database)
    768             deleted += records.count
    769         }
    770         return deleted
    771     }
    772 
    773     /// One-shot cleanup of Ping records whose kinds were retired when the
    774     /// push worker took over user-facing event notifications:
    775     /// `.check`, `.reveal`, `.resign`, `.win`, and the old session-start
    776     /// `.join`. Those kinds are gone from `PingKind`, but records written
    777     /// by pre-cutover builds linger in shared game zones and will replay
    778     /// as obsolete announcements after a zone fetch. Every device drains
    779     /// the game zones it can reach exactly once.
    780     func purgeLegacyPlayPings_v1() async {
    781         guard NotificationState.legacyPlayPingPurgeNeeded() else { return }
    782         do {
    783             let privateDeleted = try await purgeLegacyPlayPings(
    784                 in: gameZoneIDs(forScope: 0),
    785                 database: container.privateCloudDatabase
    786             )
    787             let sharedDeleted = try await purgeLegacyPlayPings(
    788                 in: gameZoneIDs(forScope: 1),
    789                 database: container.sharedCloudDatabase
    790             )
    791             NotificationState.markLegacyPlayPingPurged()
    792             let total = privateDeleted + sharedDeleted
    793             if total > 0 {
    794                 await trace("legacy play-ping purge: deleted \(total) record(s)")
    795             }
    796         } catch {
    797             await trace("legacy play-ping purge failed: \(describe(error))")
    798         }
    799     }
    800 
    801     private func purgeLegacyPlayPings(
    802         in zoneIDs: [CKRecordZone.ID],
    803         database: CKDatabase
    804     ) async throws -> Int {
    805         var deleted = 0
    806         let predicate = NSPredicate(
    807             format: "kind IN %@",
    808             ["check", "reveal", "resign", "win", "join"]
    809         )
    810         for zoneID in zoneIDs {
    811             let records = try await queryRecords(
    812                 type: "Ping",
    813                 database: database,
    814                 zoneID: zoneID,
    815                 predicate: predicate,
    816                 desiredKeys: []
    817             )
    818             try await deleteRecords(withIDs: records.map(\.recordID), in: database)
    819             deleted += records.count
    820         }
    821         return deleted
    822     }
    823 
    824     private func gameZoneIDs(forScope scope: Int16) -> [CKRecordZone.ID] {
    825         let ctx = persistence.container.newBackgroundContext()
    826         return ctx.performAndWait {
    827             let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    828             req.predicate = NSPredicate(
    829                 format: "databaseScope == %d AND isAccessRevoked == NO",
    830                 scope
    831             )
    832             var seen = Set<String>()
    833             var result: [CKRecordZone.ID] = []
    834             for entity in (try? ctx.fetch(req)) ?? [] {
    835                 guard let gameID = entity.id else { continue }
    836                 let zoneName = entity.ckZoneName ?? "game-\(gameID.uuidString)"
    837                 let ownerName = entity.ckZoneOwnerName ?? CKCurrentUserDefaultName
    838                 let key = "\(ownerName)|\(zoneName)"
    839                 guard seen.insert(key).inserted else { continue }
    840                 result.append(CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName))
    841             }
    842             return result
    843         }
    844     }
    845 
    846     /// One-shot local cleanup for pre-release debug friend rows whose
    847     /// `friend-debug-preview-*` zones no longer exist or have invalid
    848     /// participants. These rows are local metadata, but while present they
    849     /// make the ping fast-path query dead zones on every poll.
    850     func purgeDebugPreviewFriends_v1() async {
    851         guard NotificationState.debugPreviewFriendPurgeNeeded() else { return }
    852         let ctx = persistence.container.newBackgroundContext()
    853         let deleted = ctx.performAndWait {
    854             let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity")
    855             req.predicate = NSPredicate(format: "friendZoneName BEGINSWITH %@", "friend-debug-preview-")
    856             let rows = (try? ctx.fetch(req)) ?? []
    857             for row in rows {
    858                 ctx.delete(row)
    859             }
    860             do {
    861                 if ctx.hasChanges {
    862                     try ctx.save()
    863                 }
    864                 return rows.count
    865             } catch {
    866                 ctx.rollback()
    867                 return -1
    868             }
    869         }
    870 
    871         guard deleted >= 0 else {
    872             await trace("debug-preview friend purge failed")
    873             return
    874         }
    875         NotificationState.markDebugPreviewFriendPurged()
    876         await trace("debug-preview friend purge: deleted \(deleted) row(s)")
    877     }
    878 
    879     /// Registers a directed Ping (`.invite` or `.decline`) into an existing
    880     /// *friend* zone. Unlike `enqueuePing`, the target zone is the friend zone
    881     /// (not the game zone), so the zone and engine are passed in explicitly:
    882     /// `scope == 1` means we joined the friend zone (it lives in our shared DB
    883     /// → shared engine); `scope == 0` means we own it (private DB → private
    884     /// engine). The zone already exists by the time an invite or decline is
    885     /// possible, so no `saveZone`. `gameID` is the game in question; it rides
    886     /// the record name so the recipient resolves it without reading the game
    887     /// zone. `authorID` is the sender, so an invite carries the inviter and a
    888     /// decline carries the decliner.
    889     func enqueueFriendZonePing(
    890         kind: PingKind,
    891         gameID: UUID,
    892         gameTitle: String,
    893         authorID: String,
    894         playerName: String,
    895         addressee: String,
    896         friendZoneID: CKRecordZone.ID,
    897         friendZoneScope: Int16,
    898         payload: String? = nil
    899     ) {
    900         let engine = friendZoneScope == 1 ? sharedEngine : privateEngine
    901         guard let engine else { return }
    902         let deviceID = RecordSerializer.localDeviceID
    903         let eventTimestampMs = Int64(Date().timeIntervalSince1970 * 1000)
    904         let recordName = RecordSerializer.recordName(
    905             forPingInGame: gameID,
    906             authorID: authorID,
    907             deviceID: deviceID,
    908             eventTimestampMs: eventTimestampMs
    909         )
    910         pendingPings[recordName] = PingPayload(
    911             gameID: gameID,
    912             authorID: authorID,
    913             deviceID: deviceID,
    914             playerName: playerName,
    915             puzzleTitle: gameTitle,
    916             eventTimestampMs: eventTimestampMs,
    917             kind: kind,
    918             payload: payload,
    919             addressee: addressee
    920         )
    921         let recordID = CKRecord.ID(recordName: recordName, zoneID: friendZoneID)
    922         engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)])
    923         sendChangesDetached(on: engine)
    924     }
    925 
    926     /// Registers a durable `Decision` record into the account zone so the fact
    927     /// reaches the user's own other devices. Decisions without payloads are
    928     /// reconstructable from their names; payload-bearing decisions mirror their
    929     /// body to UserDefaults so CKSyncEngine's persisted pending save survives an
    930     /// app kill without rebuilding as a payload-less record. Idempotent — a
    931     /// re-send of an existing decision is a benign conflict the send path drops.
    932     func enqueueDecision(
    933         kind: String,
    934         key: String,
    935         payload: String? = nil,
    936         version: Int64? = nil
    937     ) {
    938         guard let engine = privateEngine else { return }
    939         let zoneID = RecordSerializer.accountZoneID
    940         // CKSyncEngine dedupes redundant saveZone requests, so it's safe to
    941         // repeat — block may be the first thing ever written to this zone.
    942         engine.state.add(pendingDatabaseChanges: [.saveZone(CKRecordZone(zoneID: zoneID))])
    943         registerDecisionSave(
    944             kind: kind, key: key, payload: payload, version: version,
    945             zoneID: zoneID, engine: engine
    946         )
    947     }
    948 
    949     /// Registers a `Decision` into an existing *friend* zone — the channel a
    950     /// display name rides to reach the other participant. Engine selection
    951     /// mirrors `enqueueFriendInvitePing`: `scope == 1` means we joined the
    952     /// zone (shared engine), `scope == 0` means we own it (private engine).
    953     /// The zone already exists by the time a friendship is recorded, so no
    954     /// `saveZone`.
    955     func enqueueFriendDecision(
    956         kind: String,
    957         key: String,
    958         payload: String? = nil,
    959         version: Int64? = nil,
    960         friendZoneID: CKRecordZone.ID,
    961         friendZoneScope: Int16
    962     ) {
    963         guard let engine = friendZoneScope == 1 ? sharedEngine : privateEngine else { return }
    964         registerDecisionSave(
    965             kind: kind, key: key, payload: payload, version: version,
    966             zoneID: friendZoneID, engine: engine
    967         )
    968     }
    969 
    970     /// Routes a display-name Decision to its zone. The account zone may not
    971     /// exist yet, so that path rides `enqueueDecision`'s `saveZone` backstop;
    972     /// a friend zone always exists by the time a friendship is recorded.
    973     func enqueueNameDecision(
    974         authorID: String,
    975         name: String,
    976         version: Int64,
    977         zoneID: CKRecordZone.ID,
    978         scope: Int16
    979     ) {
    980         if zoneID == RecordSerializer.accountZoneID {
    981             enqueueDecision(
    982                 kind: RecordSerializer.nameDecisionKind,
    983                 key: authorID,
    984                 payload: name,
    985                 version: version
    986             )
    987         } else {
    988             enqueueFriendDecision(
    989                 kind: RecordSerializer.nameDecisionKind,
    990                 key: authorID,
    991                 payload: name,
    992                 version: version,
    993                 friendZoneID: zoneID,
    994                 friendZoneScope: scope
    995             )
    996         }
    997     }
    998 
    999     private func registerDecisionSave(
   1000         kind: String,
   1001         key: String,
   1002         payload: String?,
   1003         version: Int64?,
   1004         zoneID: CKRecordZone.ID,
   1005         engine: CKSyncEngine
   1006     ) {
   1007         let name = RecordSerializer.decisionRecordName(kind: kind, key: key)
   1008         let recordID = CKRecord.ID(recordName: name, zoneID: zoneID)
   1009         let stateKey = Self.decisionStateKey(recordID)
   1010         if let payload {
   1011             pendingDecisionPayloads[stateKey] = payload
   1012             persistPendingDecisionPayloads()
   1013         }
   1014         if let version {
   1015             pendingDecisionVersions[stateKey] = version
   1016             persistPendingDecisionVersions()
   1017         }
   1018         engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)])
   1019         sendChangesDetached(on: engine)
   1020     }
   1021 
   1022     /// Deletes a durable `Decision` record (account zone) so a fact that no
   1023     /// longer holds stops propagating — e.g. a `left` decision is voided when
   1024     /// the user re-joins that game. Deleting an absent record is benign
   1025     /// (CloudKit reports it gone; the send path does not retry-loop it).
   1026     func enqueueDecisionDeletion(kind: String, key: String) {
   1027         guard let engine = privateEngine else { return }
   1028         let name = RecordSerializer.decisionRecordName(kind: kind, key: key)
   1029         let recordID = CKRecord.ID(recordName: name, zoneID: RecordSerializer.accountZoneID)
   1030         engine.state.add(pendingRecordZoneChanges: [.deleteRecord(recordID)])
   1031         sendChangesDetached(on: engine)
   1032     }
   1033 
   1034     /// Mirrors `pendingDecisionPayloads` to durable storage. CKSyncEngine
   1035     /// persists the pending `.saveRecord` on its own, so the payload must be
   1036     /// equally durable or a relaunch sends a payload-less Decision.
   1037     private func persistPendingDecisionPayloads() {
   1038         if pendingDecisionPayloads.isEmpty {
   1039             UserDefaults.standard.removeObject(
   1040                 forKey: Self.pendingDecisionPayloadsDefaultsKey
   1041             )
   1042         } else {
   1043             UserDefaults.standard.set(
   1044                 pendingDecisionPayloads,
   1045                 forKey: Self.pendingDecisionPayloadsDefaultsKey
   1046             )
   1047         }
   1048     }
   1049 
   1050     private func restorePendingDecisionPayloads() {
   1051         pendingDecisionPayloads = (UserDefaults.standard.dictionary(
   1052             forKey: Self.pendingDecisionPayloadsDefaultsKey
   1053         ) as? [String: String]) ?? [:]
   1054     }
   1055 
   1056     /// Mirrors `pendingDecisionVersions` to durable storage, alongside
   1057     /// `persistPendingDecisionPayloads`. NSNumber/Int64 is plist-representable.
   1058     private func persistPendingDecisionVersions() {
   1059         if pendingDecisionVersions.isEmpty {
   1060             UserDefaults.standard.removeObject(
   1061                 forKey: Self.pendingDecisionVersionsDefaultsKey
   1062             )
   1063         } else {
   1064             UserDefaults.standard.set(
   1065                 pendingDecisionVersions.mapValues { NSNumber(value: $0) },
   1066                 forKey: Self.pendingDecisionVersionsDefaultsKey
   1067             )
   1068         }
   1069     }
   1070 
   1071     private func restorePendingDecisionVersions() {
   1072         let raw = (UserDefaults.standard.dictionary(
   1073             forKey: Self.pendingDecisionVersionsDefaultsKey
   1074         ) as? [String: NSNumber]) ?? [:]
   1075         pendingDecisionVersions = raw.mapValues { $0.int64Value }
   1076     }
   1077 
   1078     /// Consume-deletes a single Ping the local account has handled (shown,
   1079     /// suppressed, or a duplicate). The deletion syncs through the game zone so
   1080     /// this user's other devices withdraw any notification they showed for it.
   1081     /// Deleting an absent record is benign (CloudKit reports it gone; the send
   1082     /// path does not retry-loop it). The deletion is queued into `engine.state`
   1083     /// synchronously; only the `sendChanges` drain is deferred — and via
   1084     /// `Task.detached`, not a plain `Task {}`. The completion-ack consume path
   1085     /// (`presentPings` → `consumeIfDirected`) reaches this from inside the
   1086     /// `onPings` delegate callback, so an un-detached Task could re-enter
   1087     /// CKSyncEngine before the callback unwinds (same class as the
   1088     /// friend-invite `fetchChanges` trap). Detaching keeps it off the
   1089     /// callback's actor; the drain only needs to land eventually.
   1090     func deletePing(recordName: String, gameID: UUID) {
   1091         let ctx = persistence.container.newBackgroundContext()
   1092         guard let info = zoneInfo(forGameID: gameID, in: ctx) else { return }
   1093         let engine = info.scope == 1 ? sharedEngine : privateEngine
   1094         guard let engine else { return }
   1095         pendingPings.removeValue(forKey: recordName)
   1096         let recordID = CKRecord.ID(recordName: recordName, zoneID: info.zoneID)
   1097         engine.state.add(pendingRecordZoneChanges: [.deleteRecord(recordID)])
   1098         sendChangesDetached(on: engine)
   1099     }
   1100 
   1101     /// Deletes a Ping from a known non-game zone, currently used for accepted
   1102     /// friend invites. Unlike `deletePing(recordName:gameID:)`, the GameEntity
   1103     /// may not exist before acceptance, so the caller supplies the friend-zone
   1104     /// route directly.
   1105     func deletePing(recordName: String, zoneID: CKRecordZone.ID, databaseScope: Int16) {
   1106         let engine = databaseScope == 1 ? sharedEngine : privateEngine
   1107         guard let engine else { return }
   1108         pendingPings.removeValue(forKey: recordName)
   1109         let recordID = CKRecord.ID(recordName: recordName, zoneID: zoneID)
   1110         engine.state.add(pendingRecordZoneChanges: [.deleteRecord(recordID)])
   1111         sendChangesDetached(on: engine)
   1112     }
   1113 
   1114     /// Registers a Player record as a pending send. One record per
   1115     /// (game, authorID), so participants only ever write their own slot.
   1116     /// `reason` is logged so the diagnostics view can attribute each enqueue
   1117     /// to its caller (rename, read cursor, cursor track, …); it does not
   1118     /// affect routing. Drains immediately unless a burst is open for this
   1119     /// scope (see `beginPlayerSendBurst`) — the open path uses a burst to
   1120     /// ship read cursor + name + initial selection in one round-trip.
   1121     ///
   1122     /// `drain: false` enqueues the change durably but does not force an
   1123     /// immediate `sendChanges()`; the record ships on CKSyncEngine's own
   1124     /// schedule instead. Used on the way to the background / on puzzle leave,
   1125     /// where a forced drain would race the scarce suspension budget to deliver
   1126     /// a presence write the engagement socket already carries live (and that
   1127     /// CloudKit delivers durably regardless).
   1128     func enqueuePlayer(gameID: UUID, authorID: String, reason: String, drain: Bool = true) async {
   1129         let ctx = persistence.container.newBackgroundContext()
   1130         guard let info = zoneInfo(forGameID: gameID, in: ctx) else { return }
   1131         // The shared zone has already been confirmed missing server-side
   1132         // (see `applyZoneOrphaning`). Re-saving a Player record would just
   1133         // fail with `.zoneNotFound` and drag the orphan handler through
   1134         // another round of the same teardown work — open-game UI keeps
   1135         // calling here for read-cursor / selection / name-open while the
   1136         // user lingers on the revoked puzzle. Silently dropping the enqueue
   1137         // is the only sensible response.
   1138         guard !info.isAccessRevoked else {
   1139             await trace(
   1140                 "enqueue Player[\(gameID.uuidString.prefix(8))] skipped " +
   1141                 "(access revoked) reason=\(reason)"
   1142             )
   1143             return
   1144         }
   1145         let engine = info.scope == 1 ? sharedEngine : privateEngine
   1146         guard let engine else { return }
   1147         let recordName = RecordSerializer.recordName(forPlayerInGame: gameID, authorID: authorID)
   1148         let recordID = CKRecord.ID(recordName: recordName, zoneID: info.zoneID)
   1149         engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)])
   1150         await trace("enqueue Player[\(gameID.uuidString.prefix(8))] reason=\(reason)")
   1151         // Durably enqueued above. A `drain: false` caller leaves the send to
   1152         // CKSyncEngine's automatic scheduling — and skips the burst-pending
   1153         // mark too, so a concurrent burst won't drain on this record's behalf.
   1154         guard drain else { return }
   1155         if (playerSendBurstDepth[info.scope] ?? 0) > 0 {
   1156             playerSendBurstPending.insert(info.scope)
   1157         } else {
   1158             sendChangesDetached(on: engine)
   1159         }
   1160     }
   1161 
   1162     /// Registers a Game record as a pending send and ensures its zone is
   1163     /// created in CloudKit first. Called when a new game is created locally.
   1164     func enqueueGame(ckRecordName: String) {
   1165         guard let gameID = gameID(fromRecordName: ckRecordName) else { return }
   1166         let ctx = persistence.container.newBackgroundContext()
   1167         guard let info = zoneInfo(forGameID: gameID, in: ctx) else { return }
   1168         let engine = info.scope == 1 ? sharedEngine : privateEngine
   1169         guard let engine else { return }
   1170         // Save the zone before the game record so it exists when records arrive.
   1171         engine.state.add(pendingDatabaseChanges: [.saveZone(CKRecordZone(zoneID: info.zoneID))])
   1172         let recordID = CKRecord.ID(recordName: ckRecordName, zoneID: info.zoneID)
   1173         engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)])
   1174         sendChangesDetached(on: engine)
   1175     }
   1176 
   1177     /// Registers this device's move journal as a pending send, into the game's
   1178     /// existing zone (no `saveZone` — by completion the zone is long since
   1179     /// created). One `Journal` record per (game, author, device); the asset is
   1180     /// rebuilt from the durable local `JournalEntity` log in `buildRecord`, so
   1181     /// no payload is stashed here. Called once at completion; a re-send is a
   1182     /// benign conflict the send path drops. Skipped on an access-revoked game,
   1183     /// where any save would just fail with `.zoneNotFound`.
   1184     func enqueueJournalUpload(gameID: UUID, authorID: String) {
   1185         let ctx = persistence.container.newBackgroundContext()
   1186         guard let info = zoneInfo(forGameID: gameID, in: ctx),
   1187               !info.isAccessRevoked else { return }
   1188         // A device that logged nothing produces no JournalEntity rows, so the
   1189         // build-time reap drops the save before it reaches CloudKit — an empty
   1190         // journal never uploads, so it can't add a phantom device key to
   1191         // replay's present set. No need to guard the enqueue itself.
   1192         let engine = info.scope == 1 ? sharedEngine : privateEngine
   1193         guard let engine else { return }
   1194         let recordName = RecordSerializer.recordName(
   1195             forJournalInGame: gameID,
   1196             authorID: authorID,
   1197             deviceID: RecordSerializer.localDeviceID
   1198         )
   1199         let recordID = CKRecord.ID(recordName: recordName, zoneID: info.zoneID)
   1200         engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)])
   1201         sendChangesDetached(on: engine)
   1202     }
   1203 
   1204     // MARK: - Explicit sync triggers (called by AppServices / diagnostics view)
   1205 
   1206     func fetchChanges(source: String = "manual") async throws {
   1207         currentFetchSource = source
   1208         defer { currentFetchSource = nil }
   1209         async let p: Void = privateEngine?.fetchChanges() ?? ()
   1210         async let s: Void = sharedEngine?.fetchChanges() ?? ()
   1211         _ = try await (p, s)
   1212     }
   1213 
   1214     /// Zone-scoped fetch for a single game. Returns `false` if the game's zone
   1215     /// isn't known locally (e.g. a freshly-invited share before its zone has
   1216     /// landed) so the caller can fall back to a full `fetchChanges`. Records
   1217     /// arrive via the normal `fetchedRecordZoneChanges` delegate path; the
   1218     /// engine's change token is the only checkpoint.
   1219     func fetchChangesForGame(
   1220         scope: CKDatabase.Scope,
   1221         gameID: UUID,
   1222         source: String = "manual"
   1223     ) async throws -> Bool {
   1224         let engine: CKSyncEngine?
   1225         let scopeValue: Int16
   1226         switch scope {
   1227         case .private:
   1228             engine = privateEngine
   1229             scopeValue = 0
   1230         case .shared:
   1231             engine = sharedEngine
   1232             scopeValue = 1
   1233         case .public:
   1234             return false
   1235         @unknown default:
   1236             return false
   1237         }
   1238         guard let engine else { return false }
   1239         let ctx = persistence.container.newBackgroundContext()
   1240         guard let info = zoneInfo(forGameID: gameID, in: ctx),
   1241               info.scope == scopeValue
   1242         else { return false }
   1243         currentFetchSource = source
   1244         defer { currentFetchSource = nil }
   1245         let options = CKSyncEngine.FetchChangesOptions(scope: .zoneIDs([info.zoneID]))
   1246         try await engine.fetchChanges(options)
   1247         return true
   1248     }
   1249 
   1250     func pushChanges() async throws {
   1251         async let p: Void = privateEngine?.sendChanges() ?? ()
   1252         async let s: Void = sharedEngine?.sendChanges() ?? ()
   1253         _ = try await (p, s)
   1254     }
   1255 
   1256     // MARK: - Diagnostics
   1257 
   1258 
   1259     /// Clears the saved state for both engines and replaces the in-memory
   1260     /// engine instances so subsequent fetches walk every zone from scratch.
   1261     /// Clearing the persisted state alone is ineffective: the running engines
   1262     /// hold their tokens in memory and the next `stateUpdate` event saves
   1263     /// those tokens back, so the wipe is undone before the user can act on it.
   1264     /// Pending records already in CloudKit are unaffected. Locally-unconfirmed
   1265     /// moves are re-enqueued so the new engines push them on the next cycle.
   1266     func resetSyncState() async {
   1267         let ctx = persistence.container.newBackgroundContext()
   1268         ctx.performAndWait {
   1269             let entity = SyncStateEntity.current(in: ctx)
   1270             entity.ckPrivateEngineState = nil
   1271             entity.ckSharedEngineState = nil
   1272             do {
   1273                 try ctx.save()
   1274             } catch {
   1275                 trace("resetSyncState ctx.save failed — \(error)")
   1276             }
   1277         }
   1278         privateEngine = CKSyncEngine(CKSyncEngine.Configuration(
   1279             database: container.privateCloudDatabase,
   1280             stateSerialization: nil,
   1281             delegate: self
   1282         ))
   1283         sharedEngine = CKSyncEngine(CKSyncEngine.Configuration(
   1284             database: container.sharedCloudDatabase,
   1285             stateSerialization: nil,
   1286             delegate: self
   1287         ))
   1288         pendingPings = [:]
   1289         pendingDecisionPayloads = [:]
   1290         persistPendingDecisionPayloads()
   1291         pendingDecisionVersions = [:]
   1292         persistPendingDecisionVersions()
   1293         decisionSystemFields = [:]
   1294         pingPushCheckpoints = [:]
   1295         seenPingRecords = [:]
   1296         liveQueryCheckpoints = [:]
   1297         loggedFirstSharedPushPayload = false
   1298         playerSendBurstDepth = [:]
   1299         playerSendBurstPending = []
   1300         _ = await enqueueUnconfirmedMoves()
   1301     }
   1302 
   1303     // MARK: - Private helpers
   1304 
   1305     func currentLocalAuthorID() async -> String? {
   1306         guard let localAuthorIDProvider else { return nil }
   1307         return await MainActor.run {
   1308             localAuthorIDProvider()
   1309         }
   1310     }
   1311 
   1312 
   1313     func trace(_ message: String) async {
   1314         guard let tracer else { return }
   1315         await tracer(message)
   1316     }
   1317 
   1318     /// Decodes a persisted `CKSyncEngine.State.Serialization` payload.
   1319     /// On failure, traces the cold-start cause so it isn't silent in the
   1320     /// diagnostics log — the engine will rebuild change tokens and resend
   1321     /// pending changes from scratch, which is visible-to-the-user behavior.
   1322     private func decodeEngineState(_ data: Data?, label: String) async -> CKSyncEngine.State.Serialization? {
   1323         guard let data else { return nil }
   1324         do {
   1325             return try JSONDecoder().decode(CKSyncEngine.State.Serialization.self, from: data)
   1326         } catch {
   1327             await trace("\(label) engine state decode failed (\(data.count) bytes) — cold-starting sync: \(describe(error))")
   1328             return nil
   1329         }
   1330     }
   1331 
   1332     private func saveEngineState(
   1333         _ serialization: CKSyncEngine.State.Serialization,
   1334         isPrivate: Bool
   1335     ) {
   1336         let ctx = persistence.container.newBackgroundContext()
   1337         ctx.performAndWait {
   1338             guard let data = try? JSONEncoder().encode(serialization) else { return }
   1339             let entity = SyncStateEntity.current(in: ctx)
   1340             if isPrivate {
   1341                 entity.ckPrivateEngineState = data
   1342             } else {
   1343                 entity.ckSharedEngineState = data
   1344             }
   1345             do {
   1346                 try ctx.save()
   1347             } catch {
   1348                 trace("saveEngineState ctx.save failed — \(error)")
   1349             }
   1350         }
   1351     }
   1352 
   1353     private func handleFetchedDatabaseChanges(
   1354         _ event: CKSyncEngine.Event.FetchedDatabaseChanges,
   1355         isPrivate: Bool
   1356     ) async {
   1357         let src = currentFetchSource ?? "framework"
   1358         await trace(
   1359             "\(isPrivate ? "private" : "shared") db changes [src=\(src)]: " +
   1360             "\(event.modifications.count) zone mods, \(event.deletions.count) zone deletions"
   1361         )
   1362         await noteRoundTripSuccess()
   1363 
   1364         // Private-DB zone deletions reflect the user removing one of their own
   1365         // games on another device — hard-delete locally so the row stops
   1366         // hanging around forever. Shared-DB zone deletions reflect the owner
   1367         // removing this account from the share — mark access-revoked instead
   1368         // so the UI can surface "no longer have access" rather than silently
   1369         // vanishing the row mid-game. Modifications on the shared DB also
   1370         // create placeholder GameEntities for newly-joined shares.
   1371         let ctx = persistence.container.newBackgroundContext()
   1372         ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
   1373         let (removedIDs, revokedIDs, rejoinedIDs, failureMessages):
   1374             ([UUID], [UUID], [UUID], [String]) = ctx.performAndWait {
   1375             var removed: [UUID] = []
   1376             var revoked: [UUID] = []
   1377             var rejoined: [UUID] = []
   1378             var messages: [String] = []
   1379             if !isPrivate {
   1380                 for mod in event.modifications {
   1381                     let zoneID = mod.zoneID
   1382                     let zoneName = zoneID.zoneName
   1383                     guard zoneName.hasPrefix("game-") else { continue }
   1384                     let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
   1385                     req.predicate = NSPredicate(format: "ckZoneName == %@", zoneName)
   1386                     req.fetchLimit = 1
   1387                     if (try? ctx.fetch(req).first) == nil {
   1388                         // Placeholder until the Game record arrives.
   1389                         let entity = GameEntity(context: ctx)
   1390                         let uuidString = String(zoneName.dropFirst("game-".count))
   1391                         let gid = UUID(uuidString: uuidString)
   1392                         entity.id = gid
   1393                         entity.ckRecordName = zoneName
   1394                         entity.ckZoneName = zoneName
   1395                         entity.ckZoneOwnerName = zoneID.ownerName
   1396                         entity.databaseScope = 1
   1397                         entity.title = "Joining\u{2026}"
   1398                         entity.puzzleSource = ""
   1399                         entity.createdAt = Date()
   1400                         entity.updatedAt = Date()
   1401                         // Gaining access to this shared zone means the user
   1402                         // (re)joined. Any prior `left` decision for this game
   1403                         // is now void — clear it so a re-invited game isn't
   1404                         // re-deleted on this or a sibling device by the stale
   1405                         // durable fact — and any pending invite row for it is
   1406                         // now redundant.
   1407                         if let gid { rejoined.append(gid) }
   1408                     }
   1409                 }
   1410             }
   1411             for deletion in event.deletions {
   1412                 let zoneName = deletion.zoneID.zoneName
   1413                 guard zoneName.hasPrefix("game-") else { continue }
   1414                 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
   1415                 req.predicate = NSPredicate(format: "ckZoneName == %@", zoneName)
   1416                 req.fetchLimit = 1
   1417                 guard let entity = try? ctx.fetch(req).first else { continue }
   1418                 if isPrivate {
   1419                     if let id = entity.id { removed.append(id) }
   1420                     ctx.delete(entity)
   1421                 } else {
   1422                     entity.isAccessRevoked = true
   1423                     if let id = entity.id { revoked.append(id) }
   1424                 }
   1425             }
   1426             if ctx.hasChanges {
   1427                 do {
   1428                     try ctx.save()
   1429                 } catch {
   1430                     // A dropped save here loses placeholder rows / revocation
   1431                     // flags with no redelivery (the change token advances on
   1432                     // return) — trace it so the diagnostics log shows the drop.
   1433                     let nsError = error as NSError
   1434                     messages.append(
   1435                         "db-changes ctx.save FAILED — domain=\(nsError.domain) " +
   1436                         "code=\(nsError.code) \(nsError.localizedDescription)"
   1437                     )
   1438                 }
   1439             }
   1440             return (removed, revoked, rejoined, messages)
   1441         }
   1442 
   1443         for message in failureMessages {
   1444             await trace(message)
   1445         }
   1446         for id in removedIDs {
   1447             if let cb = onGameRemoved { await cb(id) }
   1448         }
   1449         for id in revokedIDs {
   1450             if let cb = onGameAccessRevoked { await cb(id) }
   1451         }
   1452         // enqueueDecisionDeletion defers its CKSyncEngine work via Task — it
   1453         // never awaits sync from this delegate callback's context. onGameJoined
   1454         // only touches Core Data, so awaiting it directly is safe.
   1455         for id in rejoinedIDs {
   1456             enqueueDecisionDeletion(kind: "left", key: id.uuidString)
   1457             if let cb = onGameJoined { await cb(id) }
   1458         }
   1459     }
   1460 
   1461     private func handleFetchedRecordZoneChanges(
   1462         _ event: CKSyncEngine.Event.FetchedRecordZoneChanges,
   1463         isPrivate: Bool
   1464     ) async {
   1465         let scope: Int16 = isPrivate ? 0 : 1
   1466         let src = currentFetchSource ?? "framework"
   1467         await trace(
   1468             "\(isPrivate ? "private" : "shared") fetch [src=\(src)]: " +
   1469             "\(event.modifications.count) modifications, \(event.deletions.count) deletions"
   1470         )
   1471         await noteRoundTripSuccess()
   1472         if !isPrivate, !loggedFirstSharedPushPayload, src == "push",
   1473            event.modifications.count + event.deletions.count > 0 {
   1474             loggedFirstSharedPushPayload = true
   1475             await trace("✅ first shared-DB push payload received — silent-push path is live")
   1476         }
   1477 
   1478         let ctx = persistence.container.newBackgroundContext()
   1479         ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
   1480         let localAuthorID = await currentLocalAuthorID()
   1481         let effects: BatchEffects = ctx.performAndWait {
   1482             var effects = BatchEffects()
   1483             for mod in event.modifications {
   1484                 let record = mod.record
   1485                 switch record.recordType {
   1486                 case "Game":
   1487                     let entity = RecordSerializer.applyGameRecord(
   1488                         record,
   1489                         to: ctx,
   1490                         databaseScope: scope,
   1491                         onEngagementChange: { effects.engagementChanged.insert($0) },
   1492                         onCompletedTransition: { effects.completedTransitions.insert($0) },
   1493                         onContentKeyChange: { effects.contentKeysChanged.insert($0) }
   1494                     )
   1495                     if let id = entity.id { effects.rosterRelevant.insert(id) }
   1496                 case "Moves":
   1497                     if let value = RecordSerializer.parseMovesRecord(record) {
   1498                         let cellsChanged = RecordSerializer.applyMovesRecord(
   1499                             record,
   1500                             value: value,
   1501                             to: ctx,
   1502                             localAuthorID: localAuthorID,
   1503                             onNewAuthor: { _ in effects.rosterRelevant.insert(value.gameID) }
   1504                         )
   1505                         if cellsChanged { effects.movesUpdated.insert(value.gameID) }
   1506                     }
   1507                 case "Player":
   1508                     if let (gameID, _) = RecordSerializer.parsePlayerRecordName(record.recordID.recordName) {
   1509                         self.applyPlayerRecord(
   1510                             record,
   1511                             in: ctx,
   1512                             localAuthorID: localAuthorID,
   1513                             onFirstTime: { effects.playersUpdated.insert($0) },
   1514                             onPresenceChange: { effects.playerPresenceChanged.insert($0) },
   1515                             onReadCursor: { effects.readCursors.append(($0, $1, $2)) }
   1516                         )
   1517                         effects.rosterRelevant.insert(gameID)
   1518                     }
   1519                 case "Ping":
   1520                     if let ping = Ping.parseRecord(record) {
   1521                         effects.pings.append(ping)
   1522                     }
   1523                 case "Decision":
   1524                     if let address = RecordSerializer.parseAccountPushAddressDecision(record) {
   1525                         effects.accountPushAddresses.append(address)
   1526                     }
   1527                     if let parsed = RecordSerializer.parseAccountPushSecretDecision(record) {
   1528                         effects.accountPushSecrets.append(parsed)
   1529                     }
   1530                     let wrote = RecordSerializer.applyDecisionRecord(
   1531                         record,
   1532                         to: ctx,
   1533                         localAuthorID: localAuthorID,
   1534                         databaseScope: scope
   1535                     )
   1536                     // The decision-apply path is otherwise silent, which makes
   1537                     // a "synced fact that never landed" (e.g. a nickname that
   1538                     // applied on the sender but not here) impossible to diagnose
   1539                     // from the on-device log. Record the fetched decision name,
   1540                     // its zone, and whether it applied so the receive side is
   1541                     // observable.
   1542                     effects.traces.append(
   1543                         "decision applied=\(wrote) " +
   1544                         "\(record.recordID.recordName) " +
   1545                         "zone=\(record.recordID.zoneID.zoneName)/" +
   1546                         "\(record.recordID.zoneID.ownerName) scope=\(scope)"
   1547                     )
   1548                     // Our own name Decision echoed back from the account zone
   1549                     // (or a friend zone a sibling seeded): adopt its version so
   1550                     // this device's next rename supersedes it rather than
   1551                     // colliding at an equal generation.
   1552                     if let (subject, _, version) = RecordSerializer.parseNameDecision(record),
   1553                        let localAuthorID, subject == localAuthorID {
   1554                         effects.selfNameVersions.append(version)
   1555                     }
   1556                     // A `left` decision hard-deletes a game row; surface it so
   1557                     // an open PuzzleView / the game list reacts, the same as
   1558                     // the private zone-deletion path does via onGameRemoved.
   1559                     if wrote,
   1560                        let (dKind, dKey) = RecordSerializer.parseDecisionRecordName(
   1561                            record.recordID.recordName
   1562                        ) {
   1563                         if dKind == "left", let gid = UUID(uuidString: dKey) {
   1564                             effects.removed.insert(gid)
   1565                         }
   1566                         // A friend's own rename or this user's nickname landed
   1567                         // — either side of an App Group nickname-directory
   1568                         // entry, so it's rebuilt after the batch saves.
   1569                         if dKind == RecordSerializer.nameDecisionKind
   1570                             || dKind == RecordSerializer.nicknameDecisionKind {
   1571                             effects.friendNamesChanged = true
   1572                         }
   1573                     }
   1574                 case "Journal":
   1575                     // Journals are never applied to Core Data from the sync
   1576                     // delegate — the replay loader (`fetchReplay`) pulls them on
   1577                     // demand with a plain CKQuery. But the record landing is the
   1578                     // signal a contributor finished and uploaded, so note the
   1579                     // game to wake a waiting replay banner (posted below).
   1580                     if let (gid, _, _) = RecordSerializer.parseJournalRecordName(
   1581                         record.recordID.recordName
   1582                     ) {
   1583                         effects.journalsSynced.insert(gid)
   1584                     }
   1585                 case Archive.recordType:
   1586                     // A finished shared game this user archived to their own
   1587                     // private DB. Inert where the live original still exists;
   1588                     // hydrated into a standalone completed game on a device that
   1589                     // lacks it (fresh install / after the original was revoked).
   1590                     if let id = self.applyArchiveRecord(record, in: ctx) {
   1591                         effects.rosterRelevant.insert(id)
   1592                     }
   1593                 default:
   1594                     break
   1595                 }
   1596             }
   1597             for deletion in event.deletions {
   1598                 self.applyDeletion(
   1599                     recordID: deletion.recordID,
   1600                     recordType: deletion.recordType,
   1601                     in: ctx
   1602                 )
   1603                 if let id = self.gameID(fromRecordName: deletion.recordID.recordName) {
   1604                     effects.rosterRelevant.insert(id)
   1605                 }
   1606             }
   1607             for gameID in effects.movesUpdated {
   1608                 effects.traces += self.replayCellCache(for: gameID, in: ctx)
   1609             }
   1610             // CKSyncEngine advances its change token whenever the delegate
   1611             // returns from fetchedRecordZoneChanges, regardless of whether we
   1612             // persisted anything. A silent failure here means the records are
   1613             // gone from the engine's "to deliver" set — they won't come back
   1614             // without a `resetSyncState`. Surface failures so we can act —
   1615             // through the tracer, which is the only channel that reaches the
   1616             // on-device diagnostics log this project debugs Production from.
   1617             if ctx.hasChanges {
   1618                 do {
   1619                     try ctx.save()
   1620                 } catch {
   1621                     let nsError = error as NSError
   1622                     effects.traces.append(
   1623                         "fetchedRecordZoneChanges ctx.save FAILED " +
   1624                         "— domain=\(nsError.domain) code=\(nsError.code) " +
   1625                         "\(nsError.localizedDescription)"
   1626                     )
   1627                 }
   1628             }
   1629             // Re-mirror the App Group key directory once the batch is saved, so
   1630             // a just-adopted content key is available to the NSE immediately.
   1631             if !effects.contentKeysChanged.isEmpty {
   1632                 GameEntity.rebuildContentKeyDirectory(in: ctx)
   1633             }
   1634             return effects
   1635         }
   1636 
   1637         for message in effects.traces {
   1638             await trace(message)
   1639         }
   1640         if let onRemoteMovesUpdated, !effects.movesUpdated.isEmpty {
   1641             await onRemoteMovesUpdated(effects.movesUpdated)
   1642         }
   1643         if let onRemotePlayersUpdated, !effects.playersUpdated.isEmpty {
   1644             await onRemotePlayersUpdated(effects.playersUpdated)
   1645         }
   1646         if let onRemotePlayerPresenceChanged, !effects.playerPresenceChanged.isEmpty {
   1647             await onRemotePlayerPresenceChanged(effects.playerPresenceChanged)
   1648         }
   1649         if let onRemoteEngagementChanged, !effects.engagementChanged.isEmpty {
   1650             await onRemoteEngagementChanged(effects.engagementChanged)
   1651         }
   1652         if let onIncomingReadCursor, !effects.readCursors.isEmpty {
   1653             await onIncomingReadCursor(effects.readCursors)
   1654         }
   1655         if let onPings, !effects.pings.isEmpty {
   1656             await onPings(effects.pings)
   1657         }
   1658         if let onAccountPushAddress {
   1659             for address in effects.accountPushAddresses {
   1660                 await onAccountPushAddress(address)
   1661             }
   1662         }
   1663         // The push secret converges across the account's own devices purely
   1664         // through this inbound path (the account zone lives in the private DB,
   1665         // which reliably syncs to every one of the account's devices). On a
   1666         // simultaneous-mint race the loser adopts the winner's secret here on
   1667         // the next fetch — no send-failure-recovery shortcut needed.
   1668         if let onAccountPushSecret {
   1669             for entry in effects.accountPushSecrets {
   1670                 await onAccountPushSecret(entry.secret, entry.version)
   1671             }
   1672         }
   1673         if let localAuthorID, !localAuthorID.isEmpty,
   1674            let maxVersion = effects.selfNameVersions.max() {
   1675             NameVersionStore.adopt(maxVersion, authorID: localAuthorID)
   1676         }
   1677         if effects.friendNamesChanged {
   1678             let mirrorCtx = persistence.container.newBackgroundContext()
   1679             mirrorCtx.performAndWait {
   1680                 FriendEntity.rebuildNicknameDirectory(in: mirrorCtx)
   1681             }
   1682         }
   1683         for id in effects.removed {
   1684             if let cb = onGameRemoved { await cb(id) }
   1685         }
   1686         // A game just learned it's complete via sync: upload this device's
   1687         // journal (no-op if it logged nothing) so replay can converge. The
   1688         // enqueue defers its CKSyncEngine drain via `sendChangesDetached`, so
   1689         // it never awaits sync from inside this delegate callback.
   1690         if let localAuthorID, !localAuthorID.isEmpty {
   1691             for id in effects.completedTransitions {
   1692                 enqueueJournalUpload(gameID: id, authorID: localAuthorID)
   1693             }
   1694         }
   1695         if let onGameCompleted {
   1696             for id in effects.completedTransitions {
   1697                 await onGameCompleted(id)
   1698             }
   1699         }
   1700         let deletedPings = event.deletions.compactMap { deletion -> (recordName: String, gameID: UUID)? in
   1701             let recordName = deletion.recordID.recordName
   1702             guard recordName.hasPrefix("ping-"),
   1703                   let gameID = gameID(fromRecordName: recordName)
   1704             else { return nil }
   1705             return (recordName, gameID)
   1706         }
   1707         if let onPingDeleted, !deletedPings.isEmpty {
   1708             await onPingDeleted(deletedPings)
   1709         }
   1710         if !effects.rosterRelevant.isEmpty {
   1711             NotificationCenter.default.post(
   1712                 name: .playerRosterShouldRefresh,
   1713                 object: nil,
   1714                 userInfo: ["gameIDs": effects.rosterRelevant]
   1715             )
   1716         }
   1717         if !effects.journalsSynced.isEmpty {
   1718             NotificationCenter.default.post(
   1719                 name: .replayJournalDidSync,
   1720                 object: nil,
   1721                 userInfo: ["gameIDs": effects.journalsSynced]
   1722             )
   1723         }
   1724     }
   1725 
   1726 
   1727     private nonisolated static func recordTypeSummary(_ counts: [String: Int]) -> String {
   1728         counts
   1729             .filter { $0.value > 0 }
   1730             .sorted { lhs, rhs in
   1731                 if lhs.key == rhs.key { return lhs.value > rhs.value }
   1732                 return lhs.key < rhs.key
   1733             }
   1734             .map { "\($0.key)=\($0.value)" }
   1735             .joined(separator: ", ")
   1736     }
   1737 
   1738     private nonisolated static func inferredRecordType(for recordID: CKRecord.ID) -> String {
   1739         let name = recordID.recordName
   1740         if name.hasPrefix("moves-") { return "Moves" }
   1741         if name.hasPrefix("journal-") { return "Journal" }
   1742         if name.hasPrefix("player-") { return "Player" }
   1743         if name.hasPrefix("game-") { return "Game" }
   1744         if name.hasPrefix("ping-") { return "Ping" }
   1745         if name.hasPrefix("decision-") { return "Decision" }
   1746         return "Unknown"
   1747     }
   1748 
   1749     private func handleSentRecordZoneChanges(
   1750         _ event: CKSyncEngine.Event.SentRecordZoneChanges,
   1751         isPrivate: Bool
   1752     ) async {
   1753         let savedTypes = Dictionary(
   1754             grouping: event.savedRecords,
   1755             by: \.recordType
   1756         ).mapValues(\.count)
   1757         let failedTypes = Dictionary(
   1758             grouping: event.failedRecordSaves,
   1759             by: { $0.record.recordType }
   1760         ).mapValues(\.count)
   1761         let deletedTypes = Dictionary(
   1762             grouping: event.deletedRecordIDs,
   1763             by: Self.inferredRecordType(for:)
   1764         ).mapValues(\.count)
   1765         let savedSummary = Self.recordTypeSummary(savedTypes)
   1766         let failedSummary = Self.recordTypeSummary(failedTypes)
   1767         let deletedSummary = Self.recordTypeSummary(deletedTypes)
   1768 
   1769         await trace(
   1770             "\(isPrivate ? "private" : "shared") sent: " +
   1771             "\(event.savedRecords.count) saved" +
   1772             "\(savedSummary.isEmpty ? "" : " (\(savedSummary))"), " +
   1773             "\(event.failedRecordSaves.count) failed" +
   1774             "\(failedSummary.isEmpty ? "" : " (\(failedSummary))"), " +
   1775             "\(event.deletedRecordIDs.count) deleted" +
   1776             "\(deletedSummary.isEmpty ? "" : " (\(deletedSummary))")"
   1777         )
   1778         // Only bump on a clean batch: a round trip with per-record failures
   1779         // is not "success" from the user's point of view, and the existing
   1780         // SyncMonitor.recordError path owns reporting whatever did break.
   1781         if event.failedRecordSaves.isEmpty {
   1782             await noteRoundTripSuccess()
   1783         }
   1784         for record in event.savedRecords {
   1785             let name = record.recordID.recordName
   1786             if name.hasPrefix("ping-") {
   1787                 pendingPings.removeValue(forKey: name)
   1788             } else if name.hasPrefix("decision-") {
   1789                 let stateKey = Self.decisionStateKey(record.recordID)
   1790                 pendingDecisionPayloads.removeValue(forKey: stateKey)
   1791                 persistPendingDecisionPayloads()
   1792                 pendingDecisionVersions.removeValue(forKey: stateKey)
   1793                 persistPendingDecisionVersions()
   1794                 decisionSystemFields.removeValue(forKey: stateKey)
   1795             }
   1796         }
   1797         // Snapshot the intended versions on-actor so the off-actor conflict
   1798         // resolution can tell a deliberate, newer write from a stale one.
   1799         let pendingVersionsSnapshot = pendingDecisionVersions
   1800         let ctx = persistence.container.newBackgroundContext()
   1801         ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
   1802         let (failureMessages, orphanedZones, resolvedDecisions, settledJournals,
   1803              resolvedAccountAddresses, resolvedAccountSecrets, decisionWins, recoveredSaves):
   1804             ([String], Set<CKRecordZone.ID>, Set<CKRecord.ID>, Set<CKRecord.ID>,
   1805              [String], [(secret: String, version: Int64)],
   1806              [(recordID: CKRecord.ID, stateKey: String, systemFields: Data)],
   1807              [CKRecord.ID]) = ctx.performAndWait {
   1808             var messages: [String] = []
   1809             var orphaned = Set<CKRecordZone.ID>()
   1810             var settledDecisions = Set<CKRecord.ID>()
   1811             var settledJournals = Set<CKRecord.ID>()
   1812             var accountAddresses: [String] = []
   1813             var accountSecrets: [(secret: String, version: Int64)] = []
   1814             // Versioned decisions that lost the change-tag race but win on
   1815             // version: (decision state key, server system fields to adopt for
   1816             // the overwrite retry).
   1817             var decisionWins: [(recordID: CKRecord.ID, stateKey: String, systemFields: Data)] = []
   1818             // Game/Moves/Player saves that lost a change-tag race and had the
   1819             // server's system fields written back: re-enqueued below. Like
   1820             // decisions, a `serverRecordChanged` failure leaves nothing pending,
   1821             // so without the re-add these heal only when unrelated activity
   1822             // happens to re-enqueue the record — reliable enough for the
   1823             // keystroke-cadence Moves/Player writes, but a Game record (share
   1824             // metadata, completion) can strand on an idle game until its next
   1825             // change.
   1826             var recoveredSaves: [CKRecord.ID] = []
   1827             for record in event.savedRecords {
   1828                 self.writeBackSystemFields(record: record, in: ctx)
   1829                 let savedName = record.recordID.recordName
   1830                 if savedName.hasPrefix("game-") {
   1831                     self.clearPendingSaveFlag(for: savedName, in: ctx)
   1832                 } else if savedName.hasPrefix("journal-"),
   1833                           let (gid, _, _) = RecordSerializer.parseJournalRecordName(savedName) {
   1834                     // Confirmed durable upload of this device's journal — record
   1835                     // it so the level-triggered backstop
   1836                     // (`reconcilePendingJournalUploads`) stops re-enqueuing it.
   1837                     self.markJournalUploaded(gameID: gid, in: ctx)
   1838                 }
   1839             }
   1840             for failure in event.failedRecordSaves {
   1841                 let name = failure.record.recordID.recordName
   1842                 let err = failure.error as NSError
   1843                 // A settled failure is one CloudKit rejected but we treat as
   1844                 // success (the durable record already exists), so it skips the
   1845                 // verbose "failed to save" log below — that line reads as an
   1846                 // error and is pure noise next to the "settled" message.
   1847                 var settled = false
   1848                 if err.domain == CKErrorDomain,
   1849                    err.code == CKError.zoneNotFound.rawValue {
   1850                     orphaned.insert(failure.record.recordID.zoneID)
   1851                 } else if !isPrivate,
   1852                           self.isInvalidSharedZoneOwnerError(err) {
   1853                     orphaned.insert(failure.record.recordID.zoneID)
   1854                 } else if name.hasPrefix("decision-"),
   1855                           err.domain == CKErrorDomain,
   1856                           err.code == CKError.serverRecordChanged.rawValue {
   1857                     let serverRecord = err.userInfo[CKRecordChangedErrorServerRecordKey] as? CKRecord
   1858                     let stateKey = Self.decisionStateKey(failure.record.recordID)
   1859                     let intended = pendingVersionsSnapshot[stateKey]
   1860                     let serverVersion = serverRecord.map(RecordSerializer.decisionVersion)
   1861                     if let intended, let serverRecord, let serverVersion,
   1862                        intended > serverVersion,
   1863                        let serverFields = RecordSerializer.encodeSystemFields(of: serverRecord) {
   1864                         // A deliberate, newer write (e.g. a rotated push secret
   1865                         // or a rename) that lost only the change-tag race. Adopt
   1866                         // the server's tag so the next build overwrites instead
   1867                         // of re-colliding, then re-enqueue the save below: a
   1868                         // `serverRecordChanged` failure does NOT leave the change
   1869                         // in CKSyncEngine's pending state (the original code
   1870                         // assumed it did), so the retry must re-add it explicitly
   1871                         // or the overwrite is silently dropped.
   1872                         decisionWins.append((failure.record.recordID, stateKey, serverFields))
   1873                         settled = true
   1874                         messages.append(
   1875                             "send: decision \(name) lost tag race but wins on version " +
   1876                             "(\(intended) > \(serverVersion)) — overwriting"
   1877                         )
   1878                     } else {
   1879                         // Write-once (block/left/pushAddress) or an equal/older
   1880                         // version: the durable fact already on the server wins.
   1881                         // Drop the pending change so the record doesn't
   1882                         // retry-loop, and adopt the winner's payload straight off
   1883                         // the conflict instead of waiting for a later fetch — for
   1884                         // the push secret that closes the window in which this
   1885                         // device would keep deriving divergent per-game addresses.
   1886                         settledDecisions.insert(failure.record.recordID)
   1887                         if let serverRecord {
   1888                             if let address = RecordSerializer.parseAccountPushAddressDecision(serverRecord) {
   1889                                 accountAddresses.append(address)
   1890                             }
   1891                             if let parsed = RecordSerializer.parseAccountPushSecretDecision(serverRecord) {
   1892                                 accountSecrets.append(parsed)
   1893                             }
   1894                         }
   1895                         settled = true
   1896                         messages.append("send: decision \(name) already present — settled")
   1897                     }
   1898                 } else if name.hasPrefix("journal-"),
   1899                           err.domain == CKErrorDomain,
   1900                           err.code == CKError.serverRecordChanged.rawValue,
   1901                           let (gid, _, _) = RecordSerializer.parseJournalRecordName(name) {
   1902                     // Journals are write-once at completion: "record to insert
   1903                     // already exists" means this device's journal is already
   1904                     // durable server-side (a backstop re-enqueue, or a first
   1905                     // upload on a build predating the `journalUploaded` flag).
   1906                     // There is no system-fields archive to adopt for an update
   1907                     // and the content is frozen, so the re-send is a no-op —
   1908                     // settle it like a Decision: drop the pending change and
   1909                     // mark the game uploaded so `reconcilePendingJournalUploads`
   1910                     // stops re-enqueuing it. Without this the save fails every
   1911                     // sweep and `Pending Changes` never drains.
   1912                     settledJournals.insert(failure.record.recordID)
   1913                     self.markJournalUploaded(gameID: gid, in: ctx)
   1914                     settled = true
   1915                     messages.append("send: journal \(name) already present — settled")
   1916                 } else if self.recoverServerChangedSave(failure.error, failedRecordName: name, in: ctx) {
   1917                     recoveredSaves.append(failure.record.recordID)
   1918                     messages.append(
   1919                         "send: recovered stale system fields for \(name) from CloudKit server record"
   1920                     )
   1921                 }
   1922                 guard !settled else { continue }
   1923                 let userInfo = err.userInfo
   1924                     .map { "\($0.key)=\($0.value)" }
   1925                     .joined(separator: " | ")
   1926                 messages.append(
   1927                     "send: failed to save \(name) — domain=\(err.domain) code=\(err.code) \(err.localizedDescription) | userInfo: \(userInfo)"
   1928                 )
   1929             }
   1930             if ctx.hasChanges {
   1931                 do {
   1932                     try ctx.save()
   1933                 } catch {
   1934                     let nsError = error as NSError
   1935                     messages.append(
   1936                         "send: writeBack ctx.save failed — domain=\(nsError.domain) code=\(nsError.code) \(nsError.localizedDescription)"
   1937                     )
   1938                 }
   1939             }
   1940             return (messages, orphaned, settledDecisions, settledJournals,
   1941                     accountAddresses, accountSecrets, decisionWins, recoveredSaves)
   1942         }
   1943         if !orphanedZones.isEmpty {
   1944             await applyZoneOrphaning(orphanedZones, isPrivate: isPrivate)
   1945         }
   1946         // Settle/retry against the engine this sent-event belongs to: account-
   1947         // zone decisions ride the private engine, but a name Decision in a
   1948         // joined friend zone rides the shared one.
   1949         let decisionEngine = isPrivate ? privateEngine : sharedEngine
   1950         if !resolvedDecisions.isEmpty, let decisionEngine {
   1951             decisionEngine.state.remove(
   1952                 pendingRecordZoneChanges: resolvedDecisions.map { .saveRecord($0) }
   1953             )
   1954             for recordID in resolvedDecisions {
   1955                 let stateKey = Self.decisionStateKey(recordID)
   1956                 pendingDecisionPayloads.removeValue(forKey: stateKey)
   1957                 pendingDecisionVersions.removeValue(forKey: stateKey)
   1958                 decisionSystemFields.removeValue(forKey: stateKey)
   1959             }
   1960             persistPendingDecisionPayloads()
   1961             persistPendingDecisionVersions()
   1962         }
   1963         // Adopt the server's tag for each versioned decision we're overwriting,
   1964         // re-enqueue the save (the failed change is no longer pending), then
   1965         // nudge the send loop so the retry (now carrying the tag) goes out
   1966         // promptly rather than on CKSyncEngine's own cadence.
   1967         if !decisionWins.isEmpty, let decisionEngine {
   1968             for win in decisionWins {
   1969                 decisionSystemFields[win.stateKey] = win.systemFields
   1970                 decisionEngine.state.add(
   1971                     pendingRecordZoneChanges: [.saveRecord(win.recordID)]
   1972                 )
   1973             }
   1974             sendChangesDetached(on: decisionEngine)
   1975         }
   1976         // Re-enqueue Game/Moves/Player saves whose stale system fields we just
   1977         // healed: a `serverRecordChanged` failure leaves nothing pending (see
   1978         // `decisionWins`), so the now-current record must be re-added or the
   1979         // local change waits for the next unrelated enqueue. The engine for
   1980         // this sent-event is the same one decisions ride.
   1981         if !recoveredSaves.isEmpty, let engine = isPrivate ? privateEngine : sharedEngine {
   1982             engine.state.add(
   1983                 pendingRecordZoneChanges: recoveredSaves.map { .saveRecord($0) }
   1984             )
   1985             sendChangesDetached(on: engine)
   1986         }
   1987         if let onAccountPushAddress {
   1988             for address in resolvedAccountAddresses {
   1989                 await onAccountPushAddress(address)
   1990             }
   1991         }
   1992         if let onAccountPushSecret {
   1993             for entry in resolvedAccountSecrets {
   1994                 await onAccountPushSecret(entry.secret, entry.version)
   1995             }
   1996         }
   1997         if !settledJournals.isEmpty {
   1998             // Drop from whichever engine owns the zone (private for solo games,
   1999             // shared for collaborations) — unlike decisions, journals ride both.
   2000             let engine = isPrivate ? privateEngine : sharedEngine
   2001             engine?.state.remove(
   2002                 pendingRecordZoneChanges: settledJournals.map { .saveRecord($0) }
   2003             )
   2004         }
   2005         for message in failureMessages {
   2006             await trace(message)
   2007         }
   2008     }
   2009 
   2010     nonisolated func isInvalidSharedZoneOwnerError(_ error: NSError) -> Bool {
   2011         let values = [error.localizedDescription] + error.userInfo.map { "\($0.value)" }
   2012         return values.contains {
   2013             $0.localizedCaseInsensitiveContains("Cannot convert userId to dsId") ||
   2014             $0.localizedCaseInsensitiveContains("invalid userId")
   2015         }
   2016     }
   2017 
   2018     /// Reacts to per-record `.zoneNotFound` failures discovered during a push
   2019     /// by reflecting the missing-zone reality locally. The framework reports
   2020     /// the same failure on every retry without ever clearing the queued change,
   2021     /// so we drop pending sends that target the zone, mirror the fetch-side
   2022     /// deletion handling on the local game (delete on private, mark
   2023     /// access-revoked on shared), and notify upstream observers. Without this,
   2024     /// `Last Error` stays stuck on `Failed to send changes` indefinitely.
   2025     /// Internal-rather-than-private so the test suite can drive it directly;
   2026     /// `CKSyncEngine.Event` payloads have no public initializer so we cannot
   2027     /// exercise `handleSentRecordZoneChanges` end-to-end.
   2028     func applyZoneOrphaning(
   2029         _ zones: Set<CKRecordZone.ID>,
   2030         isPrivate: Bool
   2031     ) async {
   2032         let engine = isPrivate ? privateEngine : sharedEngine
   2033         if let engine {
   2034             let toRemove = engine.state.pendingRecordZoneChanges.filter { change in
   2035                 switch change {
   2036                 case .saveRecord(let id):
   2037                     return zones.contains(id.zoneID)
   2038                 case .deleteRecord(let id):
   2039                     return zones.contains(id.zoneID)
   2040                 @unknown default:
   2041                     return false
   2042                 }
   2043             }
   2044             if !toRemove.isEmpty {
   2045                 engine.state.remove(pendingRecordZoneChanges: toRemove)
   2046             }
   2047         }
   2048 
   2049         for (name, _) in pendingPings {
   2050             guard let gameID = gameID(fromRecordName: name) else { continue }
   2051             if zones.contains(where: { $0.zoneName == "game-\(gameID.uuidString)" }) {
   2052                 pendingPings.removeValue(forKey: name)
   2053             }
   2054         }
   2055 
   2056         let ctx = persistence.container.newBackgroundContext()
   2057         ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
   2058         let (removedIDs, revokedIDs, failureMessages): ([UUID], [UUID], [String]) = ctx.performAndWait {
   2059             var removed: [UUID] = []
   2060             var revoked: [UUID] = []
   2061             var messages: [String] = []
   2062             for zone in zones {
   2063                 let zoneName = zone.zoneName
   2064                 guard zoneName.hasPrefix("game-") else { continue }
   2065                 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
   2066                 req.predicate = NSPredicate(format: "ckZoneName == %@", zoneName)
   2067                 req.fetchLimit = 1
   2068                 guard let entity = try? ctx.fetch(req).first else { continue }
   2069                 if isPrivate {
   2070                     if let id = entity.id { removed.append(id) }
   2071                     ctx.delete(entity)
   2072                 } else {
   2073                     if !entity.isAccessRevoked, let id = entity.id {
   2074                         revoked.append(id)
   2075                     }
   2076                     entity.isAccessRevoked = true
   2077                 }
   2078             }
   2079             if ctx.hasChanges {
   2080                 do {
   2081                     try ctx.save()
   2082                 } catch {
   2083                     let nsError = error as NSError
   2084                     messages.append(
   2085                         "orphan-zone ctx.save FAILED — domain=\(nsError.domain) " +
   2086                         "code=\(nsError.code) \(nsError.localizedDescription)"
   2087                     )
   2088                 }
   2089             }
   2090             return (removed, revoked, messages)
   2091         }
   2092 
   2093         for message in failureMessages {
   2094             await trace(message)
   2095         }
   2096         await trace(
   2097             "\(isPrivate ? "private" : "shared") orphaned \(zones.count) zone(s) on send: " +
   2098             zones.map(\.zoneName).sorted().joined(separator: ", ")
   2099         )
   2100 
   2101         for id in removedIDs {
   2102             if let cb = onGameRemoved { await cb(id) }
   2103         }
   2104         for id in revokedIDs {
   2105             if let cb = onGameAccessRevoked { await cb(id) }
   2106         }
   2107     }
   2108 
   2109     /// CKSyncEngine reports optimistic-lock conflicts as failed saves, but the
   2110     /// error payload often includes the current server record. Adopt only that
   2111     /// record's system fields so a retry can use the fresh change tag while
   2112     /// preserving the local values that caused the pending save.
   2113     private nonisolated func recoverServerChangedSave(
   2114         _ error: Error,
   2115         failedRecordName: String,
   2116         in ctx: NSManagedObjectContext
   2117     ) -> Bool {
   2118         let nsError = error as NSError
   2119         guard nsError.domain == CKErrorDomain,
   2120               nsError.code == CKError.serverRecordChanged.rawValue,
   2121               let serverRecord = nsError.userInfo[CKRecordChangedErrorServerRecordKey] as? CKRecord,
   2122               serverRecord.recordID.recordName == failedRecordName
   2123         else { return false }
   2124 
   2125         writeBackSystemFields(record: serverRecord, in: ctx)
   2126         return true
   2127     }
   2128 
   2129     private nonisolated func writeBackSystemFields(
   2130         record: CKRecord,
   2131         in ctx: NSManagedObjectContext
   2132     ) {
   2133         let name = record.recordID.recordName
   2134         let entityName: String
   2135         if name.hasPrefix("moves-") { entityName = "MovesEntity" }
   2136         else if name.hasPrefix("player-") { entityName = "PlayerEntity" }
   2137         else if name.hasPrefix("game-") { entityName = "GameEntity" }
   2138         else { return }
   2139 
   2140         let req = NSFetchRequest<NSManagedObject>(entityName: entityName)
   2141         req.predicate = NSPredicate(format: "ckRecordName == %@", name)
   2142         req.fetchLimit = 1
   2143         guard let obj = try? ctx.fetch(req).first else { return }
   2144         obj.setValue(RecordSerializer.encodeSystemFields(of: record), forKey: "ckSystemFields")
   2145         if entityName == "GameEntity" {
   2146             obj.setValue(Date(), forKey: "lastSyncedAt")
   2147         }
   2148     }
   2149 
   2150     /// Clears the `hasPendingSave` guard on `GameEntity` after a successful
   2151     /// push so future inbound fetches resume adopting server fields. Called
   2152     /// from `handleSentRecordZoneChanges` only on confirmed saves — *not* from
   2153     /// the oplock-recovery path, which only adopts a fresh etag for retry.
   2154     /// Also clears `hasPushPending`, the one-shot flag that forces the next
   2155     /// push to re-include the `puzzleSource` asset (used by the NYT re-upgrade
   2156     /// path to replace an already-uploaded puzzle).
   2157     private nonisolated func clearPendingSaveFlag(
   2158         for recordName: String,
   2159         in ctx: NSManagedObjectContext
   2160     ) {
   2161         let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
   2162         req.predicate = NSPredicate(format: "ckRecordName == %@", recordName)
   2163         req.fetchLimit = 1
   2164         guard let entity = try? ctx.fetch(req).first else { return }
   2165         entity.hasPendingSave = false
   2166         entity.hasPushPending = false
   2167     }
   2168 
   2169     private nonisolated func markJournalUploaded(
   2170         gameID: UUID,
   2171         in ctx: NSManagedObjectContext
   2172     ) {
   2173         let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
   2174         req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
   2175         req.fetchLimit = 1
   2176         guard let entity = try? ctx.fetch(req).first else { return }
   2177         entity.journalUploaded = true
   2178     }
   2179 
   2180     // MARK: - Logging helpers
   2181 
   2182     private nonisolated func trace(_ message: String) {
   2183         print("SyncEngine: \(message)")
   2184     }
   2185 
   2186 }
   2187 
   2188 // MARK: - CKSyncEngineDelegate
   2189 
   2190 extension SyncEngine: CKSyncEngineDelegate {
   2191     func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) async {
   2192         let isPrivate = syncEngine === privateEngine
   2193         switch event {
   2194         case .stateUpdate(let e):
   2195             saveEngineState(e.stateSerialization, isPrivate: isPrivate)
   2196 
   2197         case .accountChange(let e):
   2198             await trace("account change: \(e.changeType)")
   2199             if let onAccountChange { await onAccountChange() }
   2200 
   2201         case .fetchedDatabaseChanges(let e):
   2202             await handleFetchedDatabaseChanges(e, isPrivate: isPrivate)
   2203 
   2204         case .fetchedRecordZoneChanges(let e):
   2205             await handleFetchedRecordZoneChanges(e, isPrivate: isPrivate)
   2206 
   2207         case .sentDatabaseChanges:
   2208             break
   2209 
   2210         case .sentRecordZoneChanges(let e):
   2211             await handleSentRecordZoneChanges(e, isPrivate: isPrivate)
   2212 
   2213         case .willFetchChanges, .didFetchChanges,
   2214              .willFetchRecordZoneChanges, .didFetchRecordZoneChanges,
   2215              .willSendChanges, .didSendChanges:
   2216             break
   2217 
   2218         @unknown default:
   2219             break
   2220         }
   2221     }
   2222 
   2223     func nextRecordZoneChangeBatch(
   2224         _ context: CKSyncEngine.SendChangesContext,
   2225         syncEngine: CKSyncEngine
   2226     ) async -> CKSyncEngine.RecordZoneChangeBatch? {
   2227         await makeRecordZoneChangeBatch(for: syncEngine)
   2228     }
   2229 
   2230     /// Builds the next outbound batch for `engine`, reaping any pending
   2231     /// `.saveRecord` whose record can no longer be reconstructed. For a ping
   2232     /// that means its ephemeral `pendingPings` payload was lost across a
   2233     /// relaunch (CKSyncEngine persists pending changes; the ping body is
   2234     /// in-memory only); for game/moves/player it means the Core Data entity
   2235     /// was deleted. Either way the save can never succeed, so the change is
   2236     /// dropped instead of returning a nil batch that leaves it queued forever
   2237     /// — `Pending Changes` would never drain and no error is ever surfaced.
   2238     /// Mirrors Apple's CKSyncEngine reference implementation, which reaps
   2239     /// un-buildable changes inside the record provider. Internal-rather-than-
   2240     /// private so the test suite can drive it directly; the delegate entry
   2241     /// point can't be exercised because `CKSyncEngine.SendChangesContext` has
   2242     /// no public initializer.
   2243     func makeRecordZoneChangeBatch(
   2244         for engine: CKSyncEngine
   2245     ) async -> CKSyncEngine.RecordZoneChangeBatch? {
   2246         let pending = engine.state.pendingRecordZoneChanges
   2247         guard !pending.isEmpty else { return nil }
   2248         await traceForeignPlayerWrites(in: pending)
   2249         let pingSnapshot = pendingPings
   2250         let decisionSnapshot = pendingDecisionPayloads
   2251         let decisionVersionSnapshot = pendingDecisionVersions
   2252         let decisionSystemFieldsSnapshot = decisionSystemFields
   2253         return await CKSyncEngine.RecordZoneChangeBatch(pendingChanges: pending) { [weak self] recordID in
   2254             guard let self else { return nil }
   2255             if let record = self.buildRecord(
   2256                 for: recordID,
   2257                 pings: pingSnapshot,
   2258                 decisions: decisionSnapshot,
   2259                 decisionVersions: decisionVersionSnapshot,
   2260                 decisionSystemFields: decisionSystemFieldsSnapshot
   2261             ) {
   2262                 return record
   2263             }
   2264             engine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)])
   2265             return nil
   2266         }
   2267     }
   2268 
   2269     /// Diagnostic for the recurring "ghost peer": flags any *peer's* Player
   2270     /// slot in our outbound batch. A participant must only ever write its own
   2271     /// `(game, authorID)` record — `enqueuePlayer` is keyed to the local
   2272     /// author. A foreign authorID here means this device is about to upload
   2273     /// someone else's presence, which `RecordBuilder` stamps with our own
   2274     /// game-wide `lastReadOtherMoveAt` (`RecordBuilder.swift:131`). If that
   2275     /// horizon is a live read lease, the peer is resurrected as present with
   2276     /// our future `readAt`. We log the value the build will send so a future
   2277     /// lease (the ghost) is distinguishable from a current-time close, and so
   2278     /// the next recurrence names the culprit in one line rather than leaving it
   2279     /// to inference. Silent in the normal case (own slot only), so it adds no
   2280     /// noise until the bug actually fires.
   2281     private func traceForeignPlayerWrites(
   2282         in pending: [CKSyncEngine.PendingRecordZoneChange]
   2283     ) async {
   2284         guard let localAuthorID = await currentLocalAuthorID() else { return }
   2285         var foreign: [(UUID, String)] = []
   2286         for change in pending {
   2287             guard case .saveRecord(let recordID) = change,
   2288                   let (gameID, authorID) =
   2289                     RecordSerializer.parsePlayerRecordName(recordID.recordName),
   2290                   authorID != localAuthorID
   2291             else { continue }
   2292             foreign.append((gameID, authorID))
   2293         }
   2294         guard !foreign.isEmpty else { return }
   2295         let ctx = persistence.container.newBackgroundContext()
   2296         for (gameID, authorID) in foreign {
   2297             let readAt: Date? = ctx.performAndWait {
   2298                 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
   2299                 req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
   2300                 req.fetchLimit = 1
   2301                 return (try? ctx.fetch(req).first)?.lastReadOtherMoveAt
   2302             }
   2303             let leaseDesc: String
   2304             if let readAt {
   2305                 let delta = Int(readAt.timeIntervalSinceNow)
   2306                 leaseDesc = "readAt=\(readAt.ISO8601Format()) " +
   2307                     (delta > 0 ? "(future +\(delta)s)" : "(past \(-delta)s)")
   2308             } else {
   2309                 leaseDesc = "readAt=nil"
   2310             }
   2311             await trace(
   2312                 "‼️ OUTBOUND peer Player[\(gameID.uuidString.prefix(8))] " +
   2313                 "author=\(authorID.prefix(8)) local=\(localAuthorID.prefix(8)) " +
   2314                 "\(leaseDesc) — uploading a peer's slot"
   2315             )
   2316         }
   2317     }
   2318 
   2319     /// Test seam: drives `makeRecordZoneChangeBatch` for the given scope's
   2320     /// engine. Mirrors `pendingSaveRecordNames(scope:)`'s scope routing.
   2321     func makeRecordZoneChangeBatch(
   2322         forTestingScope scope: CKDatabase.Scope
   2323     ) async -> CKSyncEngine.RecordZoneChangeBatch? {
   2324         let engine = scope == .shared ? sharedEngine : privateEngine
   2325         guard let engine else { return nil }
   2326         return await makeRecordZoneChangeBatch(for: engine)
   2327     }
   2328 }