crossmate

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

SyncEngine.swift (137980B)


      1 import CloudKit
      2 import CoreData
      3 import Foundation
      4 import SwiftUI
      5 
      6 enum CompletionDurableRecordKind: String, Codable, Hashable, Sendable {
      7     case game
      8     case moves
      9     case journal
     10 }
     11 
     12 enum PingDeliveryState: Sendable {
     13     case queued
     14     case sent
     15     case failed
     16 }
     17 
     18 enum InviteDeliveryFailure: Equatable, Sendable {
     19     case quotaExceeded
     20     case other
     21 
     22     var title: LocalizedStringResource {
     23         "Invitation Not Sent"
     24     }
     25 
     26     var body: LocalizedStringResource {
     27         switch self {
     28         case .quotaExceeded:
     29             "The invitation was not received. This could be because your friend's iCloud storage is full."
     30         case .other:
     31             "The invitation could not be delivered. Try again."
     32         }
     33     }
     34 
     35     init(error: Error) {
     36         if let pingError = error as? SyncEngine.PingOutboxError,
     37            pingError.isQuotaExceeded {
     38             self = .quotaExceeded
     39         } else {
     40             self = .other
     41         }
     42     }
     43 }
     44 
     45 struct PingDeliveryUpdate: Sendable {
     46     let recordName: String
     47     let gameID: UUID
     48     let addressee: String
     49     let state: PingDeliveryState
     50     let rollbackParticipantOnFailure: Bool
     51     let failure: InviteDeliveryFailure?
     52 }
     53 
     54 extension EnvironmentValues {
     55     @Entry var syncEngine: SyncEngine? = nil
     56 }
     57 
     58 extension Notification.Name {
     59     /// Posted by `SyncEngine` after applying fetched record zone changes that
     60     /// touched a game's roster-relevant state — a Player record, a Game record,
     61     /// a deletion, or a new contributor's first Moves row (see
     62     /// `BatchEffects.rosterRelevant`). `userInfo["gameIDs"]` is a `Set<UUID>`.
     63     /// `PlayerRoster` observes this to refresh in response to remote name /
     64     /// cursor updates and new participants joining. Repeat Moves from a known
     65     /// contributor (peer letters) are excluded — they don't change the roster.
     66     static let playerRosterShouldRefresh = Notification.Name("playerRosterShouldRefresh")
     67 
     68     /// Posted by `SyncEngine` when an inbound `Journal` record lands for one or
     69     /// more games. `userInfo["gameIDs"]` is a `Set<UUID>`. The finish-banner
     70     /// replay observes this to re-check completeness the moment a contributor's
     71     /// journal syncs, instead of polling.
     72     static let replayJournalDidSync = Notification.Name("replayJournalDidSync")
     73 
     74     /// The private Chronicle zone changed. Its assets are deliberately excluded
     75     /// from CKSyncEngine and the Game List performs a bounded metadata query.
     76     static let chronicleZoneDidChange = Notification.Name("chronicleZoneDidChange")
     77 }
     78 
     79 
     80 /// Owns the CloudKit sync lifecycle via two `CKSyncEngine` instances — one for
     81 /// the private database (owned games and shares) and one for the shared
     82 /// database (joined games). Zone creation, subscription setup, change-token
     83 /// management, batching, and retry are all delegated to the framework.
     84 /// This actor's job is to:
     85 ///
     86 /// - Start and persist each engine's state across launches.
     87 /// - Translate outbound edits (from `MovesUpdater`) into pending record zone
     88 ///   changes that CKSyncEngine will batch and send.
     89 /// - Apply incoming `Moves`, `Game`, and `Player` records to Core Data and
     90 ///   replay them onto the `CellEntity` cache.
     91 /// - Notify the main actor so the in-memory `Game` stays current.
     92 actor SyncEngine {
     93     enum PingOutboxError: Error, Equatable, LocalizedError {
     94         case syncEngineUnavailable
     95         case deliveryFailed(code: Int?)
     96         /// CloudKit did not confirm or reject the send within the caller's
     97         /// patience window. The Ping stays durably queued and its CKShare seat
     98         /// intact; this only unblocks the caller so the UI stops waiting.
     99         case deliveryPending
    100 
    101         var errorDescription: String? {
    102             switch self {
    103             case .syncEngineUnavailable:
    104                 return String(localized: "The invitation could not be queued for syncing.")
    105             case .deliveryFailed(let code)
    106                 where code == CKError.quotaExceeded.rawValue:
    107                 return String(localized: InviteDeliveryFailure.quotaExceeded.body)
    108             case .deliveryFailed:
    109                 return String(localized: InviteDeliveryFailure.other.body)
    110             case .deliveryPending:
    111                 return String(localized: "The invitation is queued and will keep trying.")
    112             }
    113         }
    114 
    115         var isQuotaExceeded: Bool {
    116             if case .deliveryFailed(let code) = self {
    117                 return code == CKError.quotaExceeded.rawValue
    118             }
    119             return false
    120         }
    121     }
    122 
    123     let container: CKContainer
    124     let persistence: PersistenceController
    125 
    126     var privateEngine: CKSyncEngine?
    127     var sharedEngine: CKSyncEngine?
    128 
    129     /// Dedicated serial context for the singleton `SyncStateEntity` row. Both
    130     /// scopes' engine state persists through this one context: `await
    131     /// ctx.perform` does not hold the actor the way `performAndWait` did, so
    132     /// per-call fresh contexts could otherwise race each other — two
    133     /// concurrent saves could each fetch-or-create the row (duplicating it on
    134     /// first launch) or fail on a row-level conflict. Automatic merging keeps
    135     /// the long-lived context coherent if the row is deleted externally (the
    136     /// database reset path).
    137     private lazy var syncStateContext: NSManagedObjectContext = {
    138         let ctx = persistence.container.newBackgroundContext()
    139         ctx.automaticallyMergesChangesFromParent = true
    140         ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
    141         return ctx
    142     }()
    143 
    144     /// True while `start()` is between its idempotence guard and the engine
    145     /// assignments. The body suspends (engine-state read, state decode), so
    146     /// the `privateEngine == nil` guard alone no longer makes a concurrent
    147     /// second call a no-op.
    148     private var isStarting = false
    149 
    150     /// In-memory mirror of the durable PendingPingEntity outbox, keyed by
    151     /// record name. CKSyncEngine persists the pending record ID but not the
    152     /// body supplied by its record provider, so the outbox is what makes a
    153     /// queued invite reconstructable after process termination.
    154     private var pendingPings: [String: PingPayload] = [:]
    155     private var pingDeliveryWaiters: [
    156         String: CheckedContinuation<Void, Error>
    157     ] = [:]
    158     private var pingDeliveryTimeouts: [String: Task<Void, Never>] = [:]
    159     private var scheduledPingRetries: Set<String> = []
    160 
    161     /// How long a `waitForServerConfirmation` send blocks before it stops
    162     /// waiting and reports `.deliveryPending`. This is a UI-patience limit, not
    163     /// a delivery deadline: the Ping remains queued and the durable outbox keeps
    164     /// retrying, so a slow network no longer strands the caller on a spinner.
    165     private var pingConfirmationTimeout: Duration = .seconds(30)
    166 
    167     private lazy var pendingPingContext: NSManagedObjectContext = {
    168         let ctx = persistence.container.newBackgroundContext()
    169         ctx.automaticallyMergesChangesFromParent = true
    170         ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
    171         return ctx
    172     }()
    173     /// Payloads for `Decision` records pending send, keyed by
    174     /// `decisionStateKey` (zone + record name — the same decision record can
    175     /// be pending in several zones at once, e.g. a name Decision fanned out
    176     /// to every friend zone, and one zone's save must not strip the others').
    177     /// Unlike a ping body, these must survive an app kill: CKSyncEngine persists
    178     /// the pending `.saveRecord`, so on relaunch `buildRecord` would otherwise
    179     /// emit a payload-less Decision (e.g. an empty `pushSecret`) that uploads as
    180     /// a poison record other devices can't parse and this device never
    181     /// republishes. Mirrored to UserDefaults on every mutation and restored in
    182     /// `start()`.
    183     private var pendingDecisionPayloads: [String: String] = [:]
    184 
    185     private static let pendingDecisionPayloadsDefaultsKey =
    186         "SyncEngine.pendingDecisionPayloads"
    187 
    188     /// Intended generation for a versioned `Decision` pending send, keyed by
    189     /// `decisionStateKey`. Mirrors `pendingDecisionPayloads`' lifecycle and
    190     /// durability: the version must survive an app kill so a rebuilt rotation
    191     /// re-asserts at the right generation rather than a stale one. The push
    192     /// secret, display name and block flag are versioned; unversioned decisions
    193     /// (left/pushAddress) have no entry and stay write-once on conflict.
    194     private var pendingDecisionVersions: [String: Int64] = [:]
    195 
    196     private static let pendingDecisionVersionsDefaultsKey =
    197         "SyncEngine.pendingDecisionVersions"
    198 
    199     /// Server system fields adopted while recovering a *versioned* Decision that
    200     /// lost the change-tag race but won on version — stashed so the next
    201     /// `buildRecord` carries the server's tag and the overwrite is accepted
    202     /// rather than re-colliding. In-memory only: the version in
    203     /// `pendingDecisionVersions` carries correctness across a relaunch (a
    204     /// tagless re-send simply re-hits the conflict and re-recovers), so this is
    205     /// a round-trip optimization, not durable state. Keyed by
    206     /// `decisionStateKey` — the same record name carries a different change
    207     /// tag in each zone it lives in. Cleared once the record saves or settles.
    208     private var decisionSystemFields: [String: Data] = [:]
    209 
    210     /// Key for the per-zone decision state above. A decision's record name is
    211     /// deterministic, so the same name can be pending in the account zone and
    212     /// several friend zones simultaneously; the zone name disambiguates.
    213     nonisolated static func decisionStateKey(_ recordID: CKRecord.ID) -> String {
    214         "\(recordID.zoneID.zoneName)/\(recordID.recordName)"
    215     }
    216 
    217     struct PingPayload: Codable {
    218         let gameID: UUID
    219         let authorID: String
    220         let deviceID: String
    221         let playerName: String
    222         let puzzleTitle: String
    223         let eventTimestampMs: Int64
    224         let kind: PingKind
    225         let payload: String?
    226         let addressee: String?
    227         let zoneName: String
    228         let zoneOwnerName: String
    229         let databaseScope: DatabaseScope
    230         /// Present only on game invitations whose CKShare seat was created by
    231         /// this Ping. Optional so durable outbox rows written by older builds
    232         /// continue to decode.
    233         let rollbackParticipantOnFailure: Bool?
    234 
    235         var recordZoneID: CKRecordZone.ID {
    236             CKRecordZone.ID(zoneName: zoneName, ownerName: zoneOwnerName)
    237         }
    238     }
    239 
    240     /// Label for the in-flight fetch — surfaced in traces so the diagnostics
    241     /// log can distinguish push-driven fetches from polls / foreground / etc.
    242     /// `nil` means CKSyncEngine drove the fetch itself (its internal scheduler).
    243     private var currentFetchSource: String?
    244     /// Game zones whose root metadata says they completed before the initial
    245     /// seven-day Game List window. CKSyncEngine must not eagerly hydrate these;
    246     /// the metadata pager or the separate migration path fetches them directly.
    247     private var privateCompletedFetchExclusions: Set<CKRecordZone.ID>?
    248     private var sharedCompletedFetchExclusions: Set<CKRecordZone.ID>?
    249     /// One-shot flag — set the first time we observe shared-DB content
    250     /// arriving via a push-triggered fetch. Confirms the silent-push path is
    251     /// actually wired up end-to-end.
    252     private var loggedFirstSharedPushPayload = false
    253 
    254     var onRemoteMovesUpdated: (@MainActor @Sendable (Set<UUID>) async -> Void)?
    255     /// Fires with the game IDs for which a collaborator's `Player` record was
    256     /// seen for the **first time** (a new `PlayerEntity` was created) — not on
    257     /// their subsequent name / cursor updates. Independent of moves; the
    258     /// friendship bootstrap keys off this so a collaborator becomes a friend
    259     /// once, as soon as their identity syncs, without waiting for a move.
    260     var onRemotePlayersUpdated: (@MainActor @Sendable (Set<UUID>) async -> Void)?
    261     /// Fires when a remote collaborator's `Player` record updates active
    262     /// presence state (selection set/cleared or refreshed). Unlike
    263     /// `onRemotePlayersUpdated`, this fires for existing Player records too,
    264     /// so live engagement can start when a known collaborator opens a puzzle.
    265     var onRemotePlayerPresenceChanged: (@MainActor @Sendable (Set<UUID>) async -> Void)?
    266     /// Fires with the game IDs whose Game-record `engagement` creds just
    267     /// changed (a peer minted or rotated the room). Drives the receiver to
    268     /// reconcile its live connection toward the new creds.
    269     var onRemoteEngagementChanged: (@MainActor @Sendable (Set<UUID>) async -> Void)?
    270     /// Fires with the game IDs whose zone-wide share just lost a previously
    271     /// accepted participant. The handler rotates the game's push credentials
    272     /// so the departed device's cached copy stops granting push access.
    273     var onPushCredentialRotationNeeded: (@MainActor @Sendable (Set<UUID>) async -> Void)?
    274     /// Fires with the game IDs whose inbound Game record changed the push
    275     /// credential blob (a peer minted — or rotated — it). The handler re-runs
    276     /// push registration so this device binds under the new credID promptly
    277     /// and drops its old binding, instead of waiting for the next launch or
    278     /// token refresh.
    279     var onRemoteCredentialsChanged: (@MainActor @Sendable (Set<UUID>) async -> Void)?
    280     var onPings: (@MainActor @Sendable ([Ping]) async -> Void)?
    281     private var onPingDeliveryUpdate: (@MainActor @Sendable (PingDeliveryUpdate) -> Void)?
    282     private var onAccountChange: (@MainActor @Sendable () async -> Void)?
    283     private var onGameAccessRevoked: (@MainActor @Sendable (UUID) async -> Void)?
    284     private var onGameRemoved: (@MainActor @Sendable (UUID) async -> Void)?
    285     /// Fires when an inbound Game record transitions a local row to completed.
    286     /// App-level side effects that are not sync-engine state (for example
    287     /// closing public share tickets) hang off this edge.
    288     var onGameCompleted: (@MainActor @Sendable (UUID) async -> Void)?
    289     /// Fires when late contributor journals land so provisional Chronicles can
    290     /// be rebuilt without waiting for the next cold-launch reconciliation.
    291     private var onReplayJournalsSynced:
    292         (@MainActor @Sendable (Set<UUID>) async -> Void)?
    293     private var onCompletionRecordsSaved: (@MainActor @Sendable ([UUID: Set<CompletionDurableRecordKind>]) async -> Void)?
    294     /// Fires with the game ID of a shared zone that just appeared locally —
    295     /// the user joined the game here or on a sibling device. Drives cleanup
    296     /// of the now-redundant pending invite row.
    297     private var onGameJoined: (@MainActor @Sendable (UUID) async -> Void)?
    298     /// Fires with Ping records that were just deleted on the server (a sibling
    299     /// device consumed a directed ping). Drives cross-device withdrawal of the
    300     /// notification and any local durable invite row this device may hold.
    301     var onPingDeleted: (@MainActor @Sendable ([(recordName: String, gameID: UUID)]) async -> Void)?
    302     /// Fires with (gameID, presenceUntil) pairs lifted from inbound Player records
    303     /// whose authorID matches the local user. A sibling device has recorded
    304     /// the account's read horizon; active sessions may move it into the near
    305     /// future and later close it with a lower current-time value.
    306     var onIncomingReadCursor: (@MainActor @Sendable ([(UUID, Date, Date?)]) async -> Void)?
    307     var onAccountPushAddress: (@MainActor @Sendable (String) async -> Void)?
    308     var onAccountPushSecret: (@MainActor @Sendable (String, Int64) async -> Void)?
    309     var onBlockedFriendsChanged: (@MainActor @Sendable (Set<String>) async -> Void)?
    310     var onGameVisibilityCandidates: (@MainActor @Sendable (Set<UUID>) async -> Void)?
    311     private var localAuthorIDProvider: (@MainActor @Sendable () -> String?)?
    312     private var tracer: (@MainActor @Sendable (String) -> Void)?
    313     /// Fires when a delegate event reports a successful round-trip with the
    314     /// CloudKit server (fetched DB changes, fetched zone changes, or sent
    315     /// zone changes with no failures). Bumps `Last Success` in the
    316     /// diagnostics view so the timestamp reflects actual engine activity
    317     /// rather than only instrumented phases run through `SyncMonitor.run`.
    318     private var successCheckpoint: (@MainActor @Sendable () -> Void)?
    319     var liveQueryCheckpoints: [String: Date] = [:]
    320     let liveQueryCheckpointOverlap: TimeInterval = 5
    321 
    322     /// Per-scope checkpoint for the background ping fast path. Independent of
    323     /// CKSyncEngine's change tokens and of `liveQueryCheckpoints` (which are
    324     /// per-zone and Moves/Player oriented).
    325     var pingPushCheckpoints: [DatabaseScope: Date] = [:]
    326     let pingPushCheckpointOverlap: TimeInterval = 30
    327     /// Per-scope record-name → modificationDate of Ping records already
    328     /// surfaced by the fast path. The time-window query deliberately re-fetches
    329     /// anything within `pingPushCheckpointOverlap` of the floor (skew safety),
    330     /// so without this a record — unboundedly, the newest one — would re-emit
    331     /// on every push. Pruned to the overlap window each scan, so it stays
    332     /// small. Mirrors the presentation-layer dedupe in `NotificationState`.
    333     var seenPingRecords: [DatabaseScope: [String: Date]] = [:]
    334     let backgroundSessionLookback: TimeInterval = 10 * 60
    335 
    336     func setTracer(_ t: @MainActor @Sendable @escaping (String) -> Void) {
    337         tracer = t
    338     }
    339 
    340     func setSuccessCheckpoint(_ cb: @MainActor @Sendable @escaping () -> Void) {
    341         successCheckpoint = cb
    342     }
    343 
    344     private func noteRoundTripSuccess() async {
    345         guard let successCheckpoint else { return }
    346         await successCheckpoint()
    347     }
    348 
    349     func setOnRemoteMovesUpdated(_ cb: @MainActor @Sendable @escaping (Set<UUID>) async -> Void) {
    350         onRemoteMovesUpdated = cb
    351     }
    352 
    353     func setOnRemotePlayersUpdated(_ cb: @MainActor @Sendable @escaping (Set<UUID>) async -> Void) {
    354         onRemotePlayersUpdated = cb
    355     }
    356 
    357     func setOnRemotePlayerPresenceChanged(_ cb: @MainActor @Sendable @escaping (Set<UUID>) async -> Void) {
    358         onRemotePlayerPresenceChanged = cb
    359     }
    360 
    361     func setOnRemoteEngagementChanged(_ cb: @MainActor @Sendable @escaping (Set<UUID>) async -> Void) {
    362         onRemoteEngagementChanged = cb
    363     }
    364 
    365     func setOnPushCredentialRotationNeeded(_ cb: @MainActor @Sendable @escaping (Set<UUID>) async -> Void) {
    366         onPushCredentialRotationNeeded = cb
    367     }
    368 
    369     func setOnRemoteCredentialsChanged(_ cb: @MainActor @Sendable @escaping (Set<UUID>) async -> Void) {
    370         onRemoteCredentialsChanged = cb
    371     }
    372 
    373     func setOnPings(_ cb: @MainActor @Sendable @escaping ([Ping]) async -> Void) {
    374         onPings = cb
    375     }
    376 
    377     func setOnPingDeliveryUpdate(
    378         _ cb: @MainActor @Sendable @escaping (PingDeliveryUpdate) -> Void
    379     ) {
    380         onPingDeliveryUpdate = cb
    381     }
    382 
    383     func setOnAccountChange(_ cb: @MainActor @Sendable @escaping () async -> Void) {
    384         onAccountChange = cb
    385     }
    386 
    387     func setOnGameAccessRevoked(_ cb: @MainActor @Sendable @escaping (UUID) async -> Void) {
    388         onGameAccessRevoked = cb
    389     }
    390 
    391     func setOnGameRemoved(_ cb: @MainActor @Sendable @escaping (UUID) async -> Void) {
    392         onGameRemoved = cb
    393     }
    394 
    395     func setOnGameCompleted(_ cb: @MainActor @Sendable @escaping (UUID) async -> Void) {
    396         onGameCompleted = cb
    397     }
    398 
    399     func setOnReplayJournalsSynced(
    400         _ cb: @MainActor @Sendable @escaping (Set<UUID>) async -> Void
    401     ) {
    402         onReplayJournalsSynced = cb
    403     }
    404 
    405     func setOnCompletionRecordsSaved(
    406         _ cb: @MainActor @Sendable @escaping ([UUID: Set<CompletionDurableRecordKind>]) async -> Void
    407     ) {
    408         onCompletionRecordsSaved = cb
    409     }
    410 
    411     func setOnGameJoined(_ cb: @MainActor @Sendable @escaping (UUID) async -> Void) {
    412         onGameJoined = cb
    413     }
    414 
    415     func setOnPingDeleted(
    416         _ cb: @MainActor @Sendable @escaping ([(recordName: String, gameID: UUID)]) async -> Void
    417     ) {
    418         onPingDeleted = cb
    419     }
    420 
    421     func setOnIncomingReadCursor(_ cb: @MainActor @Sendable @escaping ([(UUID, Date, Date?)]) async -> Void) {
    422         onIncomingReadCursor = cb
    423     }
    424 
    425     func setOnAccountPushAddress(_ cb: @MainActor @Sendable @escaping (String) async -> Void) {
    426         onAccountPushAddress = cb
    427     }
    428 
    429     func setOnAccountPushSecret(_ cb: @MainActor @Sendable @escaping (String, Int64) async -> Void) {
    430         onAccountPushSecret = cb
    431     }
    432 
    433     func setOnBlockedFriendsChanged(_ cb: @MainActor @Sendable @escaping (Set<String>) async -> Void) {
    434         onBlockedFriendsChanged = cb
    435     }
    436 
    437     func setOnGameVisibilityCandidates(_ cb: @MainActor @Sendable @escaping (Set<UUID>) async -> Void) {
    438         onGameVisibilityCandidates = cb
    439     }
    440 
    441     func setLocalAuthorIDProvider(_ cb: @MainActor @Sendable @escaping () -> String?) {
    442         localAuthorIDProvider = cb
    443     }
    444 
    445     init(container: CKContainer, persistence: PersistenceController) {
    446         self.container = container
    447         self.persistence = persistence
    448     }
    449 
    450     // MARK: - Lifecycle
    451 
    452     /// Creates both `CKSyncEngine` instances, restoring previously-saved state
    453     /// so pending changes and change tokens survive restarts. Call once after
    454     /// wiring callbacks. Idempotent — extra calls are no-ops so a race between
    455     /// `services.start()` and a scene-active foreground sync can't double-
    456     /// initialise the engines or re-fire subscription setup.
    457     func start() async {
    458         guard privateEngine == nil, sharedEngine == nil, !isStarting else { return }
    459         isStarting = true
    460         defer { isStarting = false }
    461 
    462         // CKSyncEngine restores its pending `.saveRecord` changes from the
    463         // serialized state below; restore the matching Ping/Decision payloads
    464         // so pending records rebuild with their bodies.
    465         await restorePendingPings()
    466         restorePendingDecisionPayloads()
    467         restorePendingDecisionVersions()
    468 
    469         let ctx = syncStateContext
    470         let (privateData, sharedData): (Data?, Data?) = await ctx.perform {
    471             let entity = SyncStateEntity.current(in: ctx)
    472             return (entity.ckPrivateEngineState, entity.ckSharedEngineState)
    473         }
    474 
    475         let privateState = await decodeEngineState(privateData, label: "private")
    476         privateEngine = CKSyncEngine(CKSyncEngine.Configuration(
    477             database: container.privateCloudDatabase,
    478             stateSerialization: privateState,
    479             delegate: self
    480         ))
    481 
    482         let sharedState = await decodeEngineState(sharedData, label: "shared")
    483         sharedEngine = CKSyncEngine(CKSyncEngine.Configuration(
    484             database: container.sharedCloudDatabase,
    485             stateSerialization: sharedState,
    486             delegate: self
    487         ))
    488 
    489         // The outbox is authoritative. A process can terminate after its row
    490         // commits but before CKSyncEngine serializes the matching pending ID,
    491         // so re-add every durable Ping on startup. Record IDs are stable and
    492         // CKSyncEngine deduplicates repeated pending saves.
    493         reconcilePendingPingOutbox()
    494     }
    495 
    496     /// Kicks CKSyncEngine's outbound drain after pending state has been queued.
    497     /// This must be detached: several enqueue paths are reachable from
    498     /// CKSyncEngine delegate callbacks, and a plain `Task {}` can inherit the
    499     /// callback's executor and re-enter CKSyncEngine before the callback
    500     /// unwinds, tripping CloudKit's serialization guard. Keep one detached
    501     /// send in flight per scope so repeated enqueues for the same record
    502     /// coalesce instead of racing separate drains against adjacent etags.
    503     private func sendChangesDetached(on engine: CKSyncEngine) {
    504         let scope = scope(for: engine)
    505         if sendChangesInFlight.contains(scope) {
    506             sendChangesPending.insert(scope)
    507             return
    508         }
    509         sendChangesInFlight.insert(scope)
    510         Task.detached { [engine] in
    511             try? await engine.sendChanges()
    512             await self.finishDetachedSendChanges(for: scope)
    513         }
    514     }
    515 
    516     /// The database an engine is bound to. Read off the engine itself rather
    517     /// than compared against `privateEngine`/`sharedEngine`: `resetSyncState`
    518     /// (and the account-switch / v4-migration purges that call it) replaces
    519     /// both instances while their fetches are still in flight, and a retired
    520     /// engine matches neither property. Identity comparison then misreports
    521     /// the retired engine's scope — for `handleEvent` it silently resolved
    522     /// *private* events to shared, which minted "Joining…" placeholders for
    523     /// the user's own zones, cross-wrote engine state between scopes, and
    524     /// applied private zone deletions as share revocations. The binding is
    525     /// immutable for an engine's lifetime, so it stays correct after a swap.
    526     private nonisolated func scope(for engine: CKSyncEngine) -> DatabaseScope {
    527         DatabaseScope(isPrivate: engine.database.databaseScope == .private)
    528     }
    529 
    530     private func finishDetachedSendChanges(for scope: DatabaseScope) {
    531         sendChangesInFlight.remove(scope)
    532         guard sendChangesPending.remove(scope) != nil else { return }
    533         let engine = scope == .shared ? sharedEngine : privateEngine
    534         guard let engine else { return }
    535         sendChangesDetached(on: engine)
    536     }
    537 
    538     /// Per-scope burst depth. `enqueuePlayer` consults this — while
    539     /// non-zero for a scope, it records that a drain is owed instead of
    540     /// firing `sendChanges` immediately. The outermost frame issues one
    541     /// `sendChanges` on exit if any enqueue landed during the burst.
    542     /// Replaces an earlier time-window coalesce that had to be tuned to
    543     /// match `PlayerSelectionPublisher`'s debounce: the open path now
    544     /// explicitly fans out read cursor, name, and the initial selection
    545     /// inside a burst, so the batch is bounded by the work that produces
    546     /// it rather than by a wall-clock window.
    547     private var playerSendBurstDepth: [DatabaseScope: Int] = [:]
    548     private var playerSendBurstPending: Set<DatabaseScope> = []
    549     private var sendChangesInFlight: Set<DatabaseScope> = []
    550     private var sendChangesPending: Set<DatabaseScope> = []
    551 
    552     /// Opens a Player-record send burst for `gameID`'s scope. Subsequent
    553     /// `enqueuePlayer` calls on that scope skip their immediate
    554     /// drain; the caller must pair this with `endPlayerSendBurst(scope:)`,
    555     /// which fires one `sendChanges` if any enqueue landed in between.
    556     /// Returns the scope so the caller can close the matching burst even
    557     /// if the game's zone routing changes after the call.
    558     func beginPlayerSendBurst(gameID: UUID) -> DatabaseScope? {
    559         let ctx = persistence.container.newBackgroundContext()
    560         guard let info = zoneInfo(forGameID: gameID, in: ctx) else { return nil }
    561         playerSendBurstDepth[info.scope, default: 0] += 1
    562         return info.scope
    563     }
    564 
    565     /// Closes a burst opened by `beginPlayerSendBurst`. The outermost
    566     /// frame drains the affected engine if any enqueue landed during the
    567     /// burst; nested frames just decrement the counter.
    568     func endPlayerSendBurst(scope: DatabaseScope) {
    569         guard let depth = playerSendBurstDepth[scope], depth > 0 else { return }
    570         if depth > 1 {
    571             playerSendBurstDepth[scope] = depth - 1
    572             return
    573         }
    574         playerSendBurstDepth.removeValue(forKey: scope)
    575         guard playerSendBurstPending.remove(scope) != nil else { return }
    576         let engine = scope == .shared ? sharedEngine : privateEngine
    577         guard let engine else { return }
    578         sendChangesDetached(on: engine)
    579     }
    580 
    581     // MARK: - Outbound
    582 
    583     /// Registers each game's local-device Moves record as a pending save,
    584     /// routed per-game to the correct engine. Called by the `MovesUpdater`
    585     /// sink after the device's `MovesEntity` row has been merged and persisted.
    586     ///
    587     /// `drain` controls whether to force an eager `sendChanges()`:
    588     ///
    589     /// - `false` (live typing debounce): the engagement socket already carries
    590     ///   the letters via `cellEdit`/`cellEditBatch`, so CloudKit is the durable
    591     ///   backstop ("appears on sync"), not the live path. With no peer watching
    592     ///   the durable write in real time, leaving the drain to CKSyncEngine's
    593     ///   automatic scheduler lets it coalesce a typing burst into far fewer
    594     ///   round-trips and avoids the self-induced oplock races two concurrent
    595     ///   drains produced over the same `CKRecord.ID`.
    596     /// - `true` (explicit `flush()` on leave/background, and recovery via
    597     ///   `enqueueUnconfirmedMoves`): the solver's final letters must reach
    598     ///   CloudKit promptly even when no peer is on the socket, so force the
    599     ///   send rather than waiting for the next foreground.
    600     func enqueueMoves(gameIDs: Set<UUID>, drain: Bool = true) async {
    601         guard !gameIDs.isEmpty else { return }
    602         // The local-device row is matched by author as well as device: after
    603         // an iCloud account switch this device's old rows persist under the
    604         // previous authorID (the record name embeds the author, so they are
    605         // distinct rows), and a device-only `fetchLimit = 1` lookup could pick
    606         // the old account's record and push a save the user no longer owns.
    607         // When no author is known yet (provider unset, first launch) fall back
    608         // to the device-only match, which is unambiguous until a switch has
    609         // happened.
    610         let localAuthorID = await currentLocalAuthorID()
    611         let ctx = persistence.container.newBackgroundContext()
    612         let (privateRecordIDs, sharedRecordIDs): ([CKRecord.ID], [CKRecord.ID]) = await ctx.perform {
    613             var privateIDs: [CKRecord.ID] = []
    614             var sharedIDs: [CKRecord.ID] = []
    615             for gameID in gameIDs {
    616                 guard let info = self.zoneInfo(forGameID: gameID, in: ctx) else { continue }
    617                 let isShared = info.scope == .shared
    618                 let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity")
    619                 if let localAuthorID, !localAuthorID.isEmpty {
    620                     req.predicate = NSPredicate(
    621                         format: "game.id == %@ AND deviceID == %@ AND authorID == %@",
    622                         gameID as CVarArg,
    623                         RecordSerializer.localDeviceID,
    624                         localAuthorID
    625                     )
    626                 } else {
    627                     req.predicate = NSPredicate(
    628                         format: "game.id == %@ AND deviceID == %@",
    629                         gameID as CVarArg,
    630                         RecordSerializer.localDeviceID
    631                     )
    632                 }
    633                 req.fetchLimit = 1
    634                 guard let entity = try? ctx.fetch(req).first,
    635                       let recordName = entity.ckRecordName
    636                 else { continue }
    637                 let recordID = CKRecord.ID(recordName: recordName, zoneID: info.zoneID)
    638                 if isShared {
    639                     sharedIDs.append(recordID)
    640                 } else {
    641                     privateIDs.append(recordID)
    642                 }
    643             }
    644             return (privateIDs, sharedIDs)
    645         }
    646         if !privateRecordIDs.isEmpty, let engine = privateEngine {
    647             engine.state.add(
    648                 pendingRecordZoneChanges: privateRecordIDs.map { .saveRecord($0) }
    649             )
    650             if drain { sendChangesDetached(on: engine) }
    651         }
    652         if !sharedRecordIDs.isEmpty, let engine = sharedEngine {
    653             engine.state.add(
    654                 pendingRecordZoneChanges: sharedRecordIDs.map { .saveRecord($0) }
    655             )
    656             if drain { sendChangesDetached(on: engine) }
    657         }
    658     }
    659 
    660     /// Re-enqueues locally persisted Moves rows that do not yet have CloudKit
    661     /// system fields. Covers crash/relaunch recovery and any save that reached
    662     /// Core Data before CKSyncEngine recorded the pending change. Scoped to the
    663     /// current author for the same reason as `enqueueMoves`: an account
    664     /// switch leaves the old account's unconfirmed rows behind on this device,
    665     /// and recovery must not push them under the new account.
    666     @discardableResult
    667     func enqueueUnconfirmedMoves() async -> Int {
    668         let localAuthorID = await currentLocalAuthorID()
    669         let ctx = persistence.container.newBackgroundContext()
    670         let gameIDs: Set<UUID> = await ctx.perform {
    671             let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity")
    672             if let localAuthorID, !localAuthorID.isEmpty {
    673                 req.predicate = NSPredicate(
    674                     format: "ckSystemFields == nil AND deviceID == %@ AND authorID == %@",
    675                     RecordSerializer.localDeviceID,
    676                     localAuthorID
    677                 )
    678             } else {
    679                 req.predicate = NSPredicate(
    680                     format: "ckSystemFields == nil AND deviceID == %@",
    681                     RecordSerializer.localDeviceID
    682                 )
    683             }
    684             let entities = (try? ctx.fetch(req)) ?? []
    685             return Set(entities.compactMap { $0.game?.id })
    686         }
    687         await enqueueMoves(gameIDs: gameIDs)
    688         return gameIDs.count
    689     }
    690 
    691     /// Registers record deletions as pending sends. Extracts the game UUID
    692     /// from the record name and routes to the correct engine.
    693     /// Registers the game's CloudKit zone for deletion. Each game owns its
    694     /// own zone, so this removes all remote records for the puzzle, including
    695     /// moves, player records, pings, and share metadata.
    696     func enqueueDeleteGame(_ deletion: GameCloudDeletion) {
    697         if deletion.deletesLiveZone {
    698             let zoneID = CKRecordZone.ID(
    699                 zoneName: deletion.ckZoneName,
    700                 ownerName: deletion.ckZoneOwnerName
    701             )
    702             let engine = deletion.databaseScope == .shared ? sharedEngine : privateEngine
    703             if let engine {
    704                 engine.state.add(pendingDatabaseChanges: [.deleteZone(zoneID)])
    705                 sendChangesDetached(on: engine)
    706             }
    707         }
    708 
    709         guard let privateEngine else { return }
    710         if let archiveRecordName = deletion.archiveRecordName {
    711             let recordID = CKRecord.ID(
    712                 recordName: archiveRecordName,
    713                 zoneID: Archive.zoneID
    714             )
    715             privateEngine.state.add(pendingRecordZoneChanges: [.deleteRecord(recordID)])
    716         }
    717         if let legacyZoneName = deletion.legacyArchiveZoneName {
    718             let legacyZoneID = CKRecordZone.ID(
    719                 zoneName: legacyZoneName,
    720                 ownerName: CKCurrentUserDefaultName
    721             )
    722             privateEngine.state.add(pendingDatabaseChanges: [.deleteZone(legacyZoneID)])
    723         }
    724         sendChangesDetached(on: privateEngine)
    725     }
    726 
    727     /// Retires an owned completed game's live zone after its archive policy has
    728     /// been satisfied. Unlike user deletion, this deliberately leaves the
    729     /// account's Archive record intact.
    730     func enqueueRetireOwnedGameZone(_ zoneID: CKRecordZone.ID) {
    731         guard let privateEngine else { return }
    732         privateEngine.state.add(pendingDatabaseChanges: [.deleteZone(zoneID)])
    733         sendChangesDetached(on: privateEngine)
    734     }
    735 
    736     /// Removes a migrated legacy per-game archive zone after the compact record
    737     /// has saved in the common archive zone.
    738     func enqueueDeleteLegacyArchiveZone(_ zoneID: CKRecordZone.ID) {
    739         guard let privateEngine else { return }
    740         privateEngine.state.add(pendingDatabaseChanges: [.deleteZone(zoneID)])
    741         sendChangesDetached(on: privateEngine)
    742     }
    743 
    744     private func storePendingPing(
    745         _ ping: PingPayload,
    746         recordName: String
    747     ) async throws {
    748         let data = try JSONEncoder().encode(ping)
    749         let ctx = pendingPingContext
    750         try await ctx.perform {
    751             let req = NSFetchRequest<PendingPingEntity>(entityName: "PendingPingEntity")
    752             req.predicate = NSPredicate(format: "recordName == %@", recordName)
    753             req.fetchLimit = 1
    754             let entity = try ctx.fetch(req).first ?? PendingPingEntity(context: ctx)
    755             entity.recordName = recordName
    756             entity.zoneName = ping.zoneName
    757             entity.zoneOwnerName = ping.zoneOwnerName
    758             entity.databaseScope = ping.databaseScope.rawValue
    759             entity.payloadData = data
    760             try ctx.save()
    761         }
    762         pendingPings[recordName] = ping
    763         await publishPingDeliveryUpdate(.queued, recordName: recordName, ping: ping)
    764     }
    765 
    766     private func removePendingPing(recordName: String) async {
    767         pendingPings.removeValue(forKey: recordName)
    768         let ctx = pendingPingContext
    769         await ctx.perform {
    770             let req = NSFetchRequest<PendingPingEntity>(entityName: "PendingPingEntity")
    771             req.predicate = NSPredicate(format: "recordName == %@", recordName)
    772             for entity in (try? ctx.fetch(req)) ?? [] {
    773                 ctx.delete(entity)
    774             }
    775             if ctx.hasChanges { try? ctx.save() }
    776         }
    777     }
    778 
    779     private func confirmPendingPing(recordName: String) async {
    780         scheduledPingRetries.remove(recordName)
    781         if let ping = pendingPings[recordName] {
    782             await publishPingDeliveryUpdate(.sent, recordName: recordName, ping: ping)
    783         }
    784         await removePendingPing(recordName: recordName)
    785         clearPingDeliveryTimeout(recordName: recordName)
    786         pingDeliveryWaiters.removeValue(forKey: recordName)?.resume()
    787     }
    788 
    789     private func failPendingPing(recordName: String, error: NSError? = nil) async {
    790         scheduledPingRetries.remove(recordName)
    791         if let ping = pendingPings[recordName] {
    792             let failure: InviteDeliveryFailure = error?.code
    793                 == CKError.quotaExceeded.rawValue ? .quotaExceeded : .other
    794             await publishPingDeliveryUpdate(
    795                 .failed,
    796                 recordName: recordName,
    797                 ping: ping,
    798                 failure: failure
    799             )
    800         }
    801         await removePendingPing(recordName: recordName)
    802         clearPingDeliveryTimeout(recordName: recordName)
    803         pingDeliveryWaiters.removeValue(forKey: recordName)?
    804             .resume(throwing: PingOutboxError.deliveryFailed(code: error?.code))
    805     }
    806 
    807     private func waitForPingDelivery(recordName: String) async throws {
    808         try await withTaskCancellationHandler {
    809             try await withCheckedThrowingContinuation { continuation in
    810                 pingDeliveryWaiters[recordName] = continuation
    811                 armPingDeliveryTimeout(recordName: recordName)
    812             }
    813         } onCancel: {
    814             Task { await self.cancelPingDeliveryWait(recordName: recordName) }
    815         }
    816     }
    817 
    818     private func cancelPingDeliveryWait(recordName: String) {
    819         clearPingDeliveryTimeout(recordName: recordName)
    820         pingDeliveryWaiters.removeValue(forKey: recordName)?
    821             .resume(throwing: CancellationError())
    822     }
    823 
    824     /// Bounds a `waitForServerConfirmation` send so it never blocks forever.
    825     /// On expiry the Ping is left queued (the durable outbox keeps retrying and
    826     /// `confirmPendingPing`/`failPendingPing` still resolves its true outcome
    827     /// later); only the caller's wait ends, with `.deliveryPending`.
    828     private func armPingDeliveryTimeout(recordName: String) {
    829         pingDeliveryTimeouts[recordName]?.cancel()
    830         pingDeliveryTimeouts[recordName] = Task { [self] in
    831             try? await Task.sleep(for: pingConfirmationTimeout)
    832             guard !Task.isCancelled else { return }
    833             timeoutPingDeliveryWait(recordName: recordName)
    834         }
    835     }
    836 
    837     private func clearPingDeliveryTimeout(recordName: String) {
    838         pingDeliveryTimeouts.removeValue(forKey: recordName)?.cancel()
    839     }
    840 
    841     private func timeoutPingDeliveryWait(recordName: String) {
    842         pingDeliveryTimeouts.removeValue(forKey: recordName)
    843         pingDeliveryWaiters.removeValue(forKey: recordName)?
    844             .resume(throwing: PingOutboxError.deliveryPending)
    845     }
    846 
    847     private func restorePendingPings() async {
    848         let ctx = pendingPingContext
    849         pendingPings = await ctx.perform {
    850             let req = NSFetchRequest<PendingPingEntity>(entityName: "PendingPingEntity")
    851             let rows = (try? ctx.fetch(req)) ?? []
    852             var restored: [String: PingPayload] = [:]
    853             for row in rows {
    854                 guard let name = row.recordName,
    855                       let data = row.payloadData,
    856                       let ping = try? JSONDecoder().decode(PingPayload.self, from: data)
    857                 else {
    858                     ctx.delete(row)
    859                     continue
    860                 }
    861                 restored[name] = ping
    862             }
    863             if ctx.hasChanges { try? ctx.save() }
    864             return restored
    865         }
    866         for (recordName, ping) in pendingPings {
    867             await publishPingDeliveryUpdate(.queued, recordName: recordName, ping: ping)
    868         }
    869     }
    870 
    871     private func publishPingDeliveryUpdate(
    872         _ state: PingDeliveryState,
    873         recordName: String,
    874         ping: PingPayload,
    875         failure: InviteDeliveryFailure? = nil
    876     ) async {
    877         guard ping.kind == .invite,
    878               let addressee = ping.addressee,
    879               !addressee.isEmpty,
    880               let onPingDeliveryUpdate
    881         else { return }
    882         await onPingDeliveryUpdate(PingDeliveryUpdate(
    883             recordName: recordName,
    884             gameID: ping.gameID,
    885             addressee: addressee,
    886             state: state,
    887             rollbackParticipantOnFailure: ping.rollbackParticipantOnFailure == true,
    888             failure: failure
    889         ))
    890     }
    891 
    892     private func reconcilePendingPingOutbox() {
    893         for (recordName, ping) in pendingPings {
    894             let engine = ping.databaseScope == .shared ? sharedEngine : privateEngine
    895             let recordID = CKRecord.ID(recordName: recordName, zoneID: ping.recordZoneID)
    896             engine?.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)])
    897         }
    898     }
    899 
    900     func pendingInvitationPingCount() -> Int {
    901         pendingPings.values.count { $0.kind == PingKind.invite }
    902     }
    903 
    904     /// Registers a Ping record as a pending send. Current clients only write
    905     /// bootstrap kinds (`.friend` / `.invite` / `.decline`) — the
    906     /// user-facing play events go through the push worker. Sender-only
    907     /// state: the payload is committed to a durable outbox before the
    908     /// CKSyncEngine save is queued, then retained until CloudKit confirms it.
    909     @discardableResult
    910     func enqueuePing(
    911         kind: PingKind,
    912         gameID: UUID,
    913         authorID: String,
    914         playerName: String,
    915         payload: String? = nil,
    916         addressee: String? = nil
    917     ) async -> Bool {
    918         let ctx = persistence.container.newBackgroundContext()
    919         let zoneAndTitle: (info: ZoneInfo, title: String)? = await ctx.perform {
    920             guard let info = self.zoneInfo(forGameID: gameID, in: ctx) else { return nil }
    921             let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    922             req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
    923             req.fetchLimit = 1
    924             let entity = try? ctx.fetch(req).first
    925             let title = PuzzleNotificationText.title(for: entity)
    926             return (info, title)
    927         }
    928         guard let zoneAndTitle else {
    929             await trace(
    930                 "ping send: SKIPPED kind=\(kind.rawValue) " +
    931                 "game=\(gameID.uuidString) " +
    932                 "— no zone info (game not yet synced/shared on this device)"
    933             )
    934             return false
    935         }
    936         let engine = zoneAndTitle.info.scope == .shared ? sharedEngine : privateEngine
    937         guard let engine else {
    938             await trace(
    939                 "ping send: SKIPPED kind=\(kind.rawValue) " +
    940                 "game=\(gameID.uuidString) " +
    941                 "— no CKSyncEngine for " +
    942                 "\(zoneAndTitle.info.scope == .shared ? "shared" : "private") scope"
    943             )
    944             return false
    945         }
    946         let deviceID = RecordSerializer.localDeviceID
    947         let eventTimestampMs = Int64(Date().timeIntervalSince1970 * 1000)
    948         let recordName = RecordSerializer.recordName(
    949             forPingInGame: gameID,
    950             authorID: authorID,
    951             deviceID: deviceID,
    952             eventTimestampMs: eventTimestampMs
    953         )
    954         let ping = PingPayload(
    955             gameID: gameID,
    956             authorID: authorID,
    957             deviceID: deviceID,
    958             playerName: playerName,
    959             puzzleTitle: zoneAndTitle.title,
    960             eventTimestampMs: eventTimestampMs,
    961             kind: kind,
    962             payload: payload,
    963             addressee: addressee,
    964             zoneName: zoneAndTitle.info.zoneID.zoneName,
    965             zoneOwnerName: zoneAndTitle.info.zoneID.ownerName,
    966             databaseScope: zoneAndTitle.info.scope,
    967             rollbackParticipantOnFailure: nil
    968         )
    969         do {
    970             try await storePendingPing(ping, recordName: recordName)
    971         } catch {
    972             await trace("ping send: failed to persist outbox record \(recordName) — \(error)")
    973             return false
    974         }
    975         let recordID = CKRecord.ID(recordName: recordName, zoneID: zoneAndTitle.info.zoneID)
    976         engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)])
    977         await trace(
    978             "ping send: enqueued kind=\(kind.rawValue) " +
    979             "game=\(gameID.uuidString) " +
    980             "target=\(zoneAndTitle.info.scope == .shared ? "shared" : "private") " +
    981             "zone=\(zoneAndTitle.info.zoneID.zoneName) record=\(recordName)"
    982         )
    983         sendChangesDetached(on: engine)
    984         return true
    985     }
    986 
    987 
    988     /// Registers a directed Ping (`.invite` or `.decline`) into an existing
    989     /// *friend* zone. Unlike `enqueuePing`, the target zone is the friend zone
    990     /// (not the game zone), so the zone and engine are passed in explicitly:
    991     /// `.shared` means we joined the friend zone (it lives in our shared DB
    992     /// → shared engine); `.private` means we own it (private DB → private
    993     /// engine). The zone already exists by the time an invite or decline is
    994     /// possible, so no `saveZone`. `gameID` is the game in question; it rides
    995     /// the record name so the recipient resolves it without reading the game
    996     /// zone. `authorID` is the sender, so an invite carries the inviter and a
    997     /// decline carries the decliner.
    998     func enqueueFriendZonePing(
    999         kind: PingKind,
   1000         gameID: UUID,
   1001         gameTitle: String,
   1002         authorID: String,
   1003         playerName: String,
   1004         addressee: String,
   1005         friendZoneID: CKRecordZone.ID,
   1006         friendZoneScope: DatabaseScope,
   1007         payload: String? = nil,
   1008         rollbackParticipantOnFailure: Bool = false,
   1009         waitForServerConfirmation: Bool = false
   1010     ) async throws {
   1011         let engine = friendZoneScope == .shared ? sharedEngine : privateEngine
   1012         guard let engine else {
   1013             throw PingOutboxError.syncEngineUnavailable
   1014         }
   1015         let deviceID = RecordSerializer.localDeviceID
   1016         let eventTimestampMs = Int64(Date().timeIntervalSince1970 * 1000)
   1017         let recordName = RecordSerializer.recordName(
   1018             forPingInGame: gameID,
   1019             authorID: authorID,
   1020             deviceID: deviceID,
   1021             eventTimestampMs: eventTimestampMs
   1022         )
   1023         let ping = PingPayload(
   1024             gameID: gameID,
   1025             authorID: authorID,
   1026             deviceID: deviceID,
   1027             playerName: playerName,
   1028             puzzleTitle: gameTitle,
   1029             eventTimestampMs: eventTimestampMs,
   1030             kind: kind,
   1031             payload: payload,
   1032             addressee: addressee,
   1033             zoneName: friendZoneID.zoneName,
   1034             zoneOwnerName: friendZoneID.ownerName,
   1035             databaseScope: friendZoneScope,
   1036             rollbackParticipantOnFailure: rollbackParticipantOnFailure
   1037         )
   1038         try await storePendingPing(ping, recordName: recordName)
   1039         let recordID = CKRecord.ID(recordName: recordName, zoneID: friendZoneID)
   1040         engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)])
   1041         sendChangesDetached(on: engine)
   1042         if waitForServerConfirmation {
   1043             try await waitForPingDelivery(recordName: recordName)
   1044         }
   1045     }
   1046 
   1047     /// Registers a durable `Decision` record into the account zone so the fact
   1048     /// reaches the user's own other devices. Decisions without payloads are
   1049     /// reconstructable from their names; payload-bearing decisions mirror their
   1050     /// body to UserDefaults so CKSyncEngine's persisted pending save survives an
   1051     /// app kill without rebuilding as a payload-less record. Idempotent — a
   1052     /// re-send of an existing decision is a benign conflict the send path drops.
   1053     func enqueueDecision(
   1054         kind: String,
   1055         key: String,
   1056         payload: String? = nil,
   1057         version: Int64? = nil
   1058     ) {
   1059         guard let engine = privateEngine else { return }
   1060         let zoneID = RecordSerializer.accountZoneID
   1061         // CKSyncEngine dedupes redundant saveZone requests, so it's safe to
   1062         // repeat — block may be the first thing ever written to this zone.
   1063         engine.state.add(pendingDatabaseChanges: [.saveZone(CKRecordZone(zoneID: zoneID))])
   1064         registerDecisionSave(
   1065             kind: kind, key: key, payload: payload, version: version,
   1066             zoneID: zoneID, engine: engine
   1067         )
   1068     }
   1069 
   1070     /// Registers a `Decision` into an existing *friend* zone — the channel a
   1071     /// display name rides to reach the other participant. Engine selection
   1072     /// mirrors `enqueueFriendInvitePing`: `.shared` means we joined the
   1073     /// zone (shared engine), `.private` means we own it (private engine).
   1074     /// The zone already exists by the time a friendship is recorded, so no
   1075     /// `saveZone`.
   1076     /// Returns whether the decision was actually enqueued. It is dropped when
   1077     /// the target engine isn't up yet; callers that gate on a "published"
   1078     /// marker must not record success on a drop, or the write never re-heals.
   1079     @discardableResult
   1080     func enqueueFriendDecision(
   1081         kind: String,
   1082         key: String,
   1083         payload: String? = nil,
   1084         version: Int64? = nil,
   1085         friendZoneID: CKRecordZone.ID,
   1086         friendZoneScope: DatabaseScope
   1087     ) -> Bool {
   1088         guard let engine = friendZoneScope == .shared ? sharedEngine : privateEngine else { return false }
   1089         registerDecisionSave(
   1090             kind: kind, key: key, payload: payload, version: version,
   1091             zoneID: friendZoneID, engine: engine
   1092         )
   1093         return true
   1094     }
   1095 
   1096     /// Routes a display-name Decision to its zone. The account zone may not
   1097     /// exist yet, so that path rides `enqueueDecision`'s `saveZone` backstop;
   1098     /// a friend zone always exists by the time a friendship is recorded.
   1099     func enqueueNameDecision(
   1100         authorID: String,
   1101         name: String,
   1102         version: Int64,
   1103         zoneID: CKRecordZone.ID,
   1104         scope: DatabaseScope
   1105     ) {
   1106         if zoneID == RecordSerializer.accountZoneID {
   1107             enqueueDecision(
   1108                 kind: RecordSerializer.nameDecisionKind,
   1109                 key: authorID,
   1110                 payload: name,
   1111                 version: version
   1112             )
   1113         } else {
   1114             enqueueFriendDecision(
   1115                 kind: RecordSerializer.nameDecisionKind,
   1116                 key: authorID,
   1117                 payload: name,
   1118                 version: version,
   1119                 friendZoneID: zoneID,
   1120                 friendZoneScope: scope
   1121             )
   1122         }
   1123     }
   1124 
   1125     private func registerDecisionSave(
   1126         kind: String,
   1127         key: String,
   1128         payload: String?,
   1129         version: Int64?,
   1130         zoneID: CKRecordZone.ID,
   1131         engine: CKSyncEngine
   1132     ) {
   1133         let name = RecordSerializer.decisionRecordName(kind: kind, key: key)
   1134         let recordID = CKRecord.ID(recordName: name, zoneID: zoneID)
   1135         let stateKey = Self.decisionStateKey(recordID)
   1136         if let payload {
   1137             pendingDecisionPayloads[stateKey] = payload
   1138             persistPendingDecisionPayloads()
   1139         }
   1140         if let version {
   1141             pendingDecisionVersions[stateKey] = version
   1142             persistPendingDecisionVersions()
   1143         }
   1144         engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)])
   1145         sendChangesDetached(on: engine)
   1146     }
   1147 
   1148     /// Deletes a durable `Decision` record (account zone) so a fact that no
   1149     /// longer holds stops propagating — e.g. a `left` decision is voided when
   1150     /// the user re-joins that game. Deleting an absent record is benign
   1151     /// (CloudKit reports it gone; the send path does not retry-loop it).
   1152     func enqueueDecisionDeletion(kind: String, key: String) {
   1153         guard let engine = privateEngine else { return }
   1154         let name = RecordSerializer.decisionRecordName(kind: kind, key: key)
   1155         let recordID = CKRecord.ID(recordName: name, zoneID: RecordSerializer.accountZoneID)
   1156         engine.state.add(pendingRecordZoneChanges: [.deleteRecord(recordID)])
   1157         sendChangesDetached(on: engine)
   1158     }
   1159 
   1160     /// Mirrors `pendingDecisionPayloads` to durable storage. CKSyncEngine
   1161     /// persists the pending `.saveRecord` on its own, so the payload must be
   1162     /// equally durable or a relaunch sends a payload-less Decision.
   1163     private func persistPendingDecisionPayloads() {
   1164         if pendingDecisionPayloads.isEmpty {
   1165             UserDefaults.standard.removeObject(
   1166                 forKey: Self.pendingDecisionPayloadsDefaultsKey
   1167             )
   1168         } else {
   1169             UserDefaults.standard.set(
   1170                 pendingDecisionPayloads,
   1171                 forKey: Self.pendingDecisionPayloadsDefaultsKey
   1172             )
   1173         }
   1174     }
   1175 
   1176     private func restorePendingDecisionPayloads() {
   1177         pendingDecisionPayloads = (UserDefaults.standard.dictionary(
   1178             forKey: Self.pendingDecisionPayloadsDefaultsKey
   1179         ) as? [String: String]) ?? [:]
   1180     }
   1181 
   1182     /// Mirrors `pendingDecisionVersions` to durable storage, alongside
   1183     /// `persistPendingDecisionPayloads`. NSNumber/Int64 is plist-representable.
   1184     private func persistPendingDecisionVersions() {
   1185         if pendingDecisionVersions.isEmpty {
   1186             UserDefaults.standard.removeObject(
   1187                 forKey: Self.pendingDecisionVersionsDefaultsKey
   1188             )
   1189         } else {
   1190             UserDefaults.standard.set(
   1191                 pendingDecisionVersions.mapValues { NSNumber(value: $0) },
   1192                 forKey: Self.pendingDecisionVersionsDefaultsKey
   1193             )
   1194         }
   1195     }
   1196 
   1197     private func restorePendingDecisionVersions() {
   1198         let raw = (UserDefaults.standard.dictionary(
   1199             forKey: Self.pendingDecisionVersionsDefaultsKey
   1200         ) as? [String: NSNumber]) ?? [:]
   1201         pendingDecisionVersions = raw.mapValues { $0.int64Value }
   1202     }
   1203 
   1204     /// Consume-deletes a single Ping the local account has handled (shown,
   1205     /// suppressed, or a duplicate). The deletion syncs through the game zone so
   1206     /// this user's other devices withdraw any notification they showed for it.
   1207     /// Deleting an absent record is benign (CloudKit reports it gone; the send
   1208     /// path does not retry-loop it). The deletion is queued into `engine.state`
   1209     /// synchronously; only the `sendChanges` drain is deferred — and via
   1210     /// `Task.detached`, not a plain `Task {}`. The completion-ack consume path
   1211     /// (`presentPings` → `consumeIfDirected`) reaches this from inside the
   1212     /// `onPings` delegate callback, so an un-detached Task could re-enter
   1213     /// CKSyncEngine before the callback unwinds (same class as the
   1214     /// friend-invite `fetchChanges` trap). Detaching keeps it off the
   1215     /// callback's actor; the drain only needs to land eventually.
   1216     func deletePing(recordName: String, gameID: UUID) async {
   1217         let ctx = persistence.container.newBackgroundContext()
   1218         guard let info = zoneInfo(forGameID: gameID, in: ctx) else { return }
   1219         let engine = info.scope == .shared ? sharedEngine : privateEngine
   1220         guard let engine else { return }
   1221         await failPendingPing(recordName: recordName)
   1222         let recordID = CKRecord.ID(recordName: recordName, zoneID: info.zoneID)
   1223         engine.state.add(pendingRecordZoneChanges: [.deleteRecord(recordID)])
   1224         sendChangesDetached(on: engine)
   1225     }
   1226 
   1227     /// Deletes a Ping from a known non-game zone, currently used for accepted
   1228     /// friend invites. Unlike `deletePing(recordName:gameID:)`, the GameEntity
   1229     /// may not exist before acceptance, so the caller supplies the friend-zone
   1230     /// route directly.
   1231     func deletePing(recordName: String, zoneID: CKRecordZone.ID, databaseScope: DatabaseScope) async {
   1232         let engine = databaseScope == .shared ? sharedEngine : privateEngine
   1233         guard let engine else { return }
   1234         await failPendingPing(recordName: recordName)
   1235         let recordID = CKRecord.ID(recordName: recordName, zoneID: zoneID)
   1236         engine.state.add(pendingRecordZoneChanges: [.deleteRecord(recordID)])
   1237         sendChangesDetached(on: engine)
   1238     }
   1239 
   1240     /// Registers a Player record as a pending send. One record per
   1241     /// (game, authorID), so participants only ever write their own slot.
   1242     /// `reason` is logged so the diagnostics view can attribute each enqueue
   1243     /// to its caller (rename, read cursor, cursor track, …); it does not
   1244     /// affect routing. Drains immediately unless a burst is open for this
   1245     /// scope (see `beginPlayerSendBurst`) — the open path uses a burst to
   1246     /// ship read cursor + name + initial selection in one round-trip.
   1247     ///
   1248     /// `drain: false` enqueues the change durably but does not force an
   1249     /// immediate `sendChanges()`; the record ships on CKSyncEngine's own
   1250     /// schedule instead. Used on the way to the background / on puzzle leave,
   1251     /// where a forced drain would race the scarce suspension budget to deliver
   1252     /// a presence write the engagement socket already carries live (and that
   1253     /// CloudKit delivers durably regardless).
   1254     func enqueuePlayer(gameID: UUID, authorID: String, reason: String, drain: Bool = true) async {
   1255         let ctx = persistence.container.newBackgroundContext()
   1256         guard let info = zoneInfo(forGameID: gameID, in: ctx) else { return }
   1257         // The shared zone has already been confirmed missing server-side
   1258         // (see `applyZoneOrphaning`). Re-saving a Player record would just
   1259         // fail with `.zoneNotFound` and drag the orphan handler through
   1260         // another round of the same teardown work — open-game UI keeps
   1261         // calling here for read-cursor / selection / name-open while the
   1262         // user lingers on the revoked puzzle. Silently dropping the enqueue
   1263         // is the only sensible response.
   1264         guard !info.isAccessRevoked else {
   1265             await trace(
   1266                 "enqueue Player[\(gameID.uuidString.prefix(8))] skipped " +
   1267                 "(access revoked) reason=\(reason)"
   1268             )
   1269             return
   1270         }
   1271         let engine = info.scope == .shared ? sharedEngine : privateEngine
   1272         guard let engine else { return }
   1273         let recordName = RecordSerializer.recordName(forPlayerInGame: gameID, authorID: authorID)
   1274         let recordID = CKRecord.ID(recordName: recordName, zoneID: info.zoneID)
   1275         engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)])
   1276         await trace("enqueue Player[\(gameID.uuidString.prefix(8))] reason=\(reason)")
   1277         // Durably enqueued above. A `drain: false` caller leaves the send to
   1278         // CKSyncEngine's automatic scheduling — and skips the burst-pending
   1279         // mark too, so a concurrent burst won't drain on this record's behalf.
   1280         guard drain else { return }
   1281         if (playerSendBurstDepth[info.scope] ?? 0) > 0 {
   1282             playerSendBurstPending.insert(info.scope)
   1283         } else {
   1284             sendChangesDetached(on: engine)
   1285         }
   1286     }
   1287 
   1288     /// Registers a Game record as a pending send and ensures its zone is
   1289     /// created in CloudKit first. Called when a new game is created locally.
   1290     func enqueueGame(ckRecordName: String) {
   1291         guard let gameID = gameID(fromRecordName: ckRecordName) else { return }
   1292         let ctx = persistence.container.newBackgroundContext()
   1293         guard let info = zoneInfo(forGameID: gameID, in: ctx) else { return }
   1294         let engine = info.scope == .shared ? sharedEngine : privateEngine
   1295         guard let engine else { return }
   1296         // Save the zone before the game record so it exists when records arrive.
   1297         engine.state.add(pendingDatabaseChanges: [.saveZone(CKRecordZone(zoneID: info.zoneID))])
   1298         let recordID = CKRecord.ID(recordName: ckRecordName, zoneID: info.zoneID)
   1299         engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)])
   1300         sendChangesDetached(on: engine)
   1301     }
   1302 
   1303     /// Registers this device's move journal as a pending send, into the game's
   1304     /// existing zone (no `saveZone` — by completion the zone is long since
   1305     /// created). One `Journal` record per (game, author, device); the asset is
   1306     /// rebuilt from the durable local `JournalEntity` log in `buildRecord`, so
   1307     /// no payload is stashed here. Called once at completion; a re-send is a
   1308     /// benign conflict the send path drops. Skipped on an access-revoked game,
   1309     /// where any save would just fail with `.zoneNotFound`.
   1310     func enqueueJournalUpload(gameID: UUID, authorID: String) {
   1311         let ctx = persistence.container.newBackgroundContext()
   1312         guard let info = zoneInfo(forGameID: gameID, in: ctx),
   1313               !info.isAccessRevoked else { return }
   1314         // A device that logged nothing produces no JournalEntity rows, so the
   1315         // build-time reap drops the save before it reaches CloudKit — an empty
   1316         // journal never uploads, so it can't add a phantom device key to
   1317         // replay's present set. No need to guard the enqueue itself.
   1318         let engine = info.scope == .shared ? sharedEngine : privateEngine
   1319         guard let engine else { return }
   1320         let recordName = RecordSerializer.recordName(
   1321             forJournalInGame: gameID,
   1322             authorID: authorID,
   1323             deviceID: RecordSerializer.localDeviceID
   1324         )
   1325         let recordID = CKRecord.ID(recordName: recordName, zoneID: info.zoneID)
   1326         engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)])
   1327         sendChangesDetached(on: engine)
   1328     }
   1329 
   1330     // MARK: - Explicit sync triggers (called by AppServices / diagnostics view)
   1331 
   1332     func fetchChanges(source: String = "manual") async throws {
   1333         currentFetchSource = source
   1334         defer { currentFetchSource = nil }
   1335         async let p: Void = privateEngine?.fetchChanges() ?? ()
   1336         async let s: Void = sharedEngine?.fetchChanges() ?? ()
   1337         _ = try await (p, s)
   1338     }
   1339 
   1340     /// Zone-scoped fetch for a single game. Returns `false` if the game's zone
   1341     /// isn't known locally (e.g. a freshly-invited share before its zone has
   1342     /// landed) so the caller can fall back to a full `fetchChanges`. Records
   1343     /// arrive via the normal `fetchedRecordZoneChanges` delegate path; the
   1344     /// engine's change token is the only checkpoint.
   1345     func fetchChangesForGame(
   1346         scope: CKDatabase.Scope,
   1347         gameID: UUID,
   1348         source: String = "manual"
   1349     ) async throws -> Bool {
   1350         let engine: CKSyncEngine?
   1351         let scopeValue: DatabaseScope
   1352         switch scope {
   1353         case .private:
   1354             engine = privateEngine
   1355             scopeValue = .private
   1356         case .shared:
   1357             engine = sharedEngine
   1358             scopeValue = .shared
   1359         case .public:
   1360             return false
   1361         @unknown default:
   1362             return false
   1363         }
   1364         guard let engine else { return false }
   1365         let ctx = persistence.container.newBackgroundContext()
   1366         guard let info = zoneInfo(forGameID: gameID, in: ctx),
   1367               info.scope == scopeValue
   1368         else { return false }
   1369         currentFetchSource = source
   1370         defer { currentFetchSource = nil }
   1371         let options = CKSyncEngine.FetchChangesOptions(scope: .zoneIDs([info.zoneID]))
   1372         try await engine.fetchChanges(options)
   1373         return true
   1374     }
   1375 
   1376     func pushChanges() async throws {
   1377         async let p: Void = privateEngine?.sendChanges() ?? ()
   1378         async let s: Void = sharedEngine?.sendChanges() ?? ()
   1379         _ = try await (p, s)
   1380     }
   1381 
   1382     // MARK: - Diagnostics
   1383 
   1384 
   1385     /// Clears the saved state for both engines and replaces the in-memory
   1386     /// engine instances so subsequent fetches walk every zone from scratch.
   1387     /// Clearing the persisted state alone is ineffective: the running engines
   1388     /// hold their tokens in memory and the next `stateUpdate` event saves
   1389     /// those tokens back, so the wipe is undone before the user can act on it.
   1390     /// Pending records already in CloudKit are unaffected. Locally-unconfirmed
   1391     /// moves are re-enqueued so the new engines push them on the next cycle.
   1392     func resetSyncState() async {
   1393         let ctx = syncStateContext
   1394         let failureMessage: String? = await ctx.perform {
   1395             let entity = SyncStateEntity.current(in: ctx)
   1396             entity.ckPrivateEngineState = nil
   1397             entity.ckSharedEngineState = nil
   1398             do {
   1399                 try ctx.save()
   1400                 return nil
   1401             } catch {
   1402                 return "resetSyncState ctx.save failed — \(error)"
   1403             }
   1404         }
   1405         if let failureMessage { await trace(failureMessage) }
   1406         privateEngine = CKSyncEngine(CKSyncEngine.Configuration(
   1407             database: container.privateCloudDatabase,
   1408             stateSerialization: nil,
   1409             delegate: self
   1410         ))
   1411         sharedEngine = CKSyncEngine(CKSyncEngine.Configuration(
   1412             database: container.sharedCloudDatabase,
   1413             stateSerialization: nil,
   1414             delegate: self
   1415         ))
   1416         reconcilePendingPingOutbox()
   1417         pendingDecisionPayloads = [:]
   1418         persistPendingDecisionPayloads()
   1419         pendingDecisionVersions = [:]
   1420         persistPendingDecisionVersions()
   1421         decisionSystemFields = [:]
   1422         pingPushCheckpoints = [:]
   1423         seenPingRecords = [:]
   1424         liveQueryCheckpoints = [:]
   1425         loggedFirstSharedPushPayload = false
   1426         playerSendBurstDepth = [:]
   1427         playerSendBurstPending = []
   1428         _ = await enqueueUnconfirmedMoves()
   1429     }
   1430 
   1431     // MARK: - Private helpers
   1432 
   1433     func currentLocalAuthorID() async -> String? {
   1434         guard let localAuthorIDProvider else { return nil }
   1435         return await MainActor.run {
   1436             localAuthorIDProvider()
   1437         }
   1438     }
   1439 
   1440 
   1441     func trace(_ message: String) async {
   1442         guard let tracer else { return }
   1443         await tracer(message)
   1444     }
   1445 
   1446     /// Decodes a persisted `CKSyncEngine.State.Serialization` payload.
   1447     /// On failure, traces the cold-start cause so it isn't silent in the
   1448     /// diagnostics log — the engine will rebuild change tokens and resend
   1449     /// pending changes from scratch, which is visible-to-the-user behavior.
   1450     private func decodeEngineState(_ data: Data?, label: String) async -> CKSyncEngine.State.Serialization? {
   1451         guard let data else { return nil }
   1452         do {
   1453             return try JSONDecoder().decode(CKSyncEngine.State.Serialization.self, from: data)
   1454         } catch {
   1455             await trace("\(label) engine state decode failed (\(data.count) bytes) — cold-starting sync: \(describe(error))")
   1456             return nil
   1457         }
   1458     }
   1459 
   1460     private func saveEngineState(
   1461         _ serialization: CKSyncEngine.State.Serialization,
   1462         isPrivate: Bool
   1463     ) async {
   1464         let ctx = syncStateContext
   1465         let failureMessage: String? = await ctx.perform {
   1466             guard let data = try? JSONEncoder().encode(serialization) else {
   1467                 return "saveEngineState encode failed — engine state not persisted"
   1468             }
   1469             let entity = SyncStateEntity.current(in: ctx)
   1470             if isPrivate {
   1471                 entity.ckPrivateEngineState = data
   1472             } else {
   1473                 entity.ckSharedEngineState = data
   1474             }
   1475             do {
   1476                 try ctx.save()
   1477                 return nil
   1478             } catch {
   1479                 return "saveEngineState ctx.save failed — \(error)"
   1480             }
   1481         }
   1482         if let failureMessage { await trace(failureMessage) }
   1483     }
   1484 
   1485     private func handleFetchedDatabaseChanges(
   1486         _ event: CKSyncEngine.Event.FetchedDatabaseChanges,
   1487         isPrivate: Bool
   1488     ) async {
   1489         let src = currentFetchSource ?? "framework"
   1490         await trace(
   1491             "\(isPrivate ? "private" : "shared") db changes [src=\(src)]: " +
   1492             "\(event.modifications.count) zone mods, \(event.deletions.count) zone deletions"
   1493         )
   1494         await noteRoundTripSuccess()
   1495         if isPrivate {
   1496             privateCompletedFetchExclusions = nil
   1497         } else {
   1498             sharedCompletedFetchExclusions = nil
   1499         }
   1500 
   1501         if isPrivate,
   1502            event.modifications.contains(where: {
   1503                $0.zoneID.zoneName == Archive.zoneName
   1504            }) {
   1505             await MainActor.run {
   1506                 NotificationCenter.default.post(name: .chronicleZoneDidChange, object: nil)
   1507             }
   1508         }
   1509 
   1510         // Private-DB zone deletions reflect the user removing one of their own
   1511         // games on another device — hard-delete locally so the row stops
   1512         // hanging around forever. Shared-DB zone deletions reflect the owner
   1513         // removing this account from the share — mark access-revoked instead
   1514         // so the UI can surface "no longer have access" rather than silently
   1515         // vanishing the row mid-game. Modifications on the shared DB also
   1516         // create placeholder GameEntities for newly-joined shares.
   1517         let ctx = persistence.container.newBackgroundContext()
   1518         ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
   1519         let deletedGameZones = Set(event.deletions.map(\.zoneID).filter {
   1520             $0.zoneName.hasPrefix("game-")
   1521         })
   1522         let (rejoinedIDs, failureMessages): ([UUID], [String]) = await ctx.perform {
   1523             var rejoined: [UUID] = []
   1524             var messages: [String] = []
   1525             if !isPrivate {
   1526                 for mod in event.modifications {
   1527                     let zoneID = mod.zoneID
   1528                     let zoneName = zoneID.zoneName
   1529                     guard zoneName.hasPrefix("game-") else { continue }
   1530                     // A zone in the shared database always belongs to somebody
   1531                     // else — CloudKit rejects any own-owner zone there ("Only
   1532                     // shared zones can be accessed in the shared DB"). An
   1533                     // own-owner zone reaching this branch therefore means the
   1534                     // *private* database's changes were routed here, and
   1535                     // seating a `databaseScope == 1` row for a zone the user
   1536                     // owns produces a row no code path can ever heal: the
   1537                     // private Game record that would fill it in matches on
   1538                     // `ckZoneOwnerName == NIL` and so forks a second row
   1539                     // instead, leaving this one permanently empty and shadowing
   1540                     // the real game in every `id`-keyed lookup.
   1541                     guard zoneID.ownerName != CKCurrentUserDefaultName else {
   1542                         messages.append(
   1543                             "refused shared placeholder for own-owner zone \(zoneName)"
   1544                         )
   1545                         continue
   1546                     }
   1547                     let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
   1548                     req.predicate = NSPredicate(format: "ckZoneName == %@", zoneName)
   1549                     req.fetchLimit = 1
   1550                     if (try? ctx.fetch(req).first) == nil {
   1551                         // Placeholder until the Game record arrives.
   1552                         let entity = GameEntity(context: ctx)
   1553                         let uuidString = String(zoneName.dropFirst("game-".count))
   1554                         let gid = UUID(uuidString: uuidString)
   1555                         entity.id = gid
   1556                         entity.ckRecordName = zoneName
   1557                         entity.ckZoneName = zoneName
   1558                         entity.ckZoneOwnerName = zoneID.ownerName
   1559                         entity.databaseScope = 1
   1560                         entity.syncVersion = GameSyncVersion.legacy
   1561                         entity.title = "Joining\u{2026}"
   1562                         entity.puzzleSource = ""
   1563                         entity.createdAt = Date()
   1564                         entity.updatedAt = Date()
   1565                         // Gaining access to this shared zone means the user
   1566                         // (re)joined. Any prior `left` decision for this game
   1567                         // is now void — clear it so a re-invited game isn't
   1568                         // re-deleted on this or a sibling device by the stale
   1569                         // durable fact — and any pending invite row for it is
   1570                         // now redundant.
   1571                         if let gid { rejoined.append(gid) }
   1572                     }
   1573                 }
   1574             }
   1575             if ctx.hasChanges {
   1576                 do {
   1577                     try ctx.save()
   1578                 } catch {
   1579                     // A dropped save here loses placeholder rows / revocation
   1580                     // flags with no redelivery (the change token advances on
   1581                     // return) — trace it so the diagnostics log shows the drop.
   1582                     let nsError = error as NSError
   1583                     messages.append(
   1584                         "db-changes ctx.save FAILED — domain=\(nsError.domain) " +
   1585                         "code=\(nsError.code) \(nsError.localizedDescription)"
   1586                     )
   1587                 }
   1588             }
   1589             return (rejoined, messages)
   1590         }
   1591 
   1592         for message in failureMessages {
   1593             await trace(message)
   1594         }
   1595         if !deletedGameZones.isEmpty {
   1596             await applyZoneOrphaning(deletedGameZones, isPrivate: isPrivate, source: "fetch")
   1597         }
   1598         // enqueueDecisionDeletion defers its CKSyncEngine work via Task — it
   1599         // never awaits sync from this delegate callback's context. onGameJoined
   1600         // only touches Core Data, so awaiting it directly is safe.
   1601         for id in rejoinedIDs {
   1602             enqueueDecisionDeletion(kind: "left", key: id.uuidString)
   1603             if let cb = onGameJoined { await cb(id) }
   1604         }
   1605     }
   1606 
   1607     private func handleFetchedRecordZoneChanges(
   1608         _ event: CKSyncEngine.Event.FetchedRecordZoneChanges,
   1609         isPrivate: Bool
   1610     ) async {
   1611         let scope = DatabaseScope(isPrivate: isPrivate)
   1612         let src = currentFetchSource ?? "framework"
   1613         await trace(
   1614             "\(isPrivate ? "private" : "shared") fetch [src=\(src)]: " +
   1615             "\(event.modifications.count) modifications, \(event.deletions.count) deletions"
   1616         )
   1617         await noteRoundTripSuccess()
   1618         if !isPrivate, !loggedFirstSharedPushPayload, src == "push",
   1619            event.modifications.count + event.deletions.count > 0 {
   1620             loggedFirstSharedPushPayload = true
   1621             await trace("✅ first shared-DB push payload received — silent-push path is live")
   1622         }
   1623 
   1624         let ctx = persistence.container.newBackgroundContext()
   1625         ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
   1626         let localAuthorID = await currentLocalAuthorID()
   1627         let effects: BatchEffects = await ctx.perform {
   1628             var effects = BatchEffects()
   1629             for mod in event.modifications {
   1630                 let record = mod.record
   1631                 guard !RecordSerializer.isGameScopedRecordType(record.recordType)
   1632                     || RecordSerializer.isTrustedGameScopedRecord(record)
   1633                 else {
   1634                     effects.traces.append(
   1635                         "rejected untrusted fetched record \(record.recordType) " +
   1636                         "\(record.recordID.recordName) in \(record.recordID.zoneID.zoneName)"
   1637                     )
   1638                     continue
   1639                 }
   1640                 switch record.recordType {
   1641                 case "Game":
   1642                     let entity = RecordSerializer.applyGameRecord(
   1643                         record,
   1644                         to: ctx,
   1645                         databaseScope: scope,
   1646                         onEngagementChange: { effects.engagementChanged.insert($0) },
   1647                         onCompletedTransition: { effects.completedTransitions.insert($0) },
   1648                         onContentKeyChange: { effects.contentKeysChanged.insert($0) },
   1649                         onStaleCredentials: { effects.staleCredentialRecords.insert($0) },
   1650                         onDiagnostic: { effects.traces.append($0) }
   1651                     )
   1652                     if let id = entity.id {
   1653                         effects.rosterRelevant.insert(id)
   1654                         effects.visibilityCandidateGameIDs.insert(id)
   1655                     }
   1656                 case "Moves":
   1657                     if let value = RecordSerializer.parseMovesRecord(record) {
   1658                         effects.visibilityCandidateGameIDs.insert(value.gameID)
   1659                         let cellsChanged = RecordSerializer.applyMovesRecord(
   1660                             record,
   1661                             value: value,
   1662                             to: ctx,
   1663                             databaseScope: scope,
   1664                             localAuthorID: localAuthorID,
   1665                             onNewAuthor: { _ in effects.rosterRelevant.insert(value.gameID) }
   1666                         )
   1667                         if cellsChanged { effects.movesUpdated.insert(value.gameID) }
   1668                     }
   1669                 case "Player":
   1670                     if let (gameID, _) = RecordSerializer.parsePlayerRecordName(record.recordID.recordName) {
   1671                         effects.visibilityCandidateGameIDs.insert(gameID)
   1672                         self.applyPlayerRecord(
   1673                             record,
   1674                             in: ctx,
   1675                             databaseScope: scope,
   1676                             localAuthorID: localAuthorID,
   1677                             onFirstTime: { effects.playersUpdated.insert($0) },
   1678                             onPresenceChange: { effects.playerPresenceChanged.insert($0) },
   1679                             onReadCursor: { effects.readCursors.append(($0, $1, $2)) }
   1680                         )
   1681                         effects.rosterRelevant.insert(gameID)
   1682                     }
   1683                 case "Ping":
   1684                     if let ping = Ping.parseRecord(record, fetchedFrom: isPrivate ? .private : .shared) {
   1685                         effects.pings.append(ping)
   1686                     }
   1687                 case "Decision":
   1688                     if let address = RecordSerializer.parseAccountPushAddressDecision(record, databaseScope: scope) {
   1689                         effects.accountPushAddresses.append(address)
   1690                     }
   1691                     if let parsed = RecordSerializer.parseAccountPushSecretDecision(record, databaseScope: scope) {
   1692                         effects.accountPushSecrets.append(parsed)
   1693                     }
   1694                     let wrote = RecordSerializer.applyDecisionRecord(
   1695                         record,
   1696                         to: ctx,
   1697                         localAuthorID: localAuthorID,
   1698                         databaseScope: scope
   1699                     )
   1700                     // The decision-apply path is otherwise silent, which makes
   1701                     // a "synced fact that never landed" (e.g. a nickname that
   1702                     // applied on the sender but not here) impossible to diagnose
   1703                     // from the on-device log. Record the fetched decision name,
   1704                     // its zone, and whether it applied so the receive side is
   1705                     // observable.
   1706                     effects.traces.append(
   1707                         "decision applied=\(wrote) " +
   1708                         "\(record.recordID.recordName) " +
   1709                         "zone=\(record.recordID.zoneID.zoneName)/" +
   1710                         "\(record.recordID.zoneID.ownerName) scope=\(scope)"
   1711                     )
   1712                     // Our own name Decision echoed back from the account zone
   1713                     // (or a friend zone a sibling seeded): adopt its version so
   1714                     // this device's next rename supersedes it rather than
   1715                     // colliding at an equal generation.
   1716                     if let (subject, _, version) = RecordSerializer.parseNameDecision(record),
   1717                        let localAuthorID, subject == localAuthorID {
   1718                         effects.selfNameVersions.append(version)
   1719                     }
   1720                     // A `left` decision hard-deletes a game row; surface it so
   1721                     // an open PuzzleView / the game list reacts, the same as
   1722                     // the private zone-deletion path does via onGameRemoved.
   1723                     if wrote,
   1724                        let (dKind, dKey) = RecordSerializer.parseDecisionRecordName(
   1725                            record.recordID.recordName
   1726                        ) {
   1727                         if dKind == "left", let gid = UUID(uuidString: dKey) {
   1728                             effects.removed.insert(gid)
   1729                         }
   1730                         if dKind == RecordSerializer.blockDecisionKind {
   1731                             effects.blockedFriendAuthorIDs.insert(dKey)
   1732                         }
   1733                         // A friend's own rename or this user's nickname landed
   1734                         // — either side of an App Group nickname-directory
   1735                         // entry, so it's rebuilt after the batch saves.
   1736                         if dKind == RecordSerializer.nameDecisionKind
   1737                             || dKind == RecordSerializer.nicknameDecisionKind {
   1738                             effects.friendNamesChanged = true
   1739                         }
   1740                     }
   1741                 case "Journal":
   1742                     // Journals are never applied to Core Data from the sync
   1743                     // delegate — the replay loader (`fetchReplay`) pulls them on
   1744                     // demand with a plain CKQuery. But the record landing is the
   1745                     // signal a contributor finished and uploaded, so note the
   1746                     // game to wake a waiting replay banner and refresh its
   1747                     // provisional Chronicle (dispatched below).
   1748                     if let (gid, _, _) = RecordSerializer.parseJournalRecordName(
   1749                         record.recordID.recordName
   1750                     ) {
   1751                         effects.journalsSynced.insert(gid)
   1752                     }
   1753                 case Archive.recordType, Archive.legacyRecordType:
   1754                     // A compact Chronicle or legacy Archive in this user's
   1755                     // private DB. Inert where the live original still exists;
   1756                     // hydrated into a standalone completed game on a device
   1757                     // that lacks it (fresh install / after revocation).
   1758                     if let id = self.applyArchiveRecord(
   1759                         record,
   1760                         in: ctx,
   1761                         onDiagnostic: { effects.traces.append($0) }
   1762                     ) {
   1763                         effects.rosterRelevant.insert(id)
   1764                         effects.visibilityCandidateGameIDs.insert(id)
   1765                     }
   1766                 case CKRecord.SystemType.share:
   1767                     // The zone-wide share is the authoritative membership. A
   1768                     // previously accepted participant disappearing from it is
   1769                     // the departure/revocation signal: rotate the game's push
   1770                     // credentials so the departed device's cached copy stops
   1771                     // granting push access.
   1772                     if let share = record as? CKShare,
   1773                        let gameID = self.applyShareRecord(share, databaseScope: scope, in: ctx) {
   1774                         effects.credentialRotations.insert(gameID)
   1775                         effects.traces.append(
   1776                             "share roster shrank for \(gameID.uuidString) — rotating push credentials"
   1777                         )
   1778                     }
   1779                 default:
   1780                     break
   1781                 }
   1782             }
   1783             for deletion in event.deletions {
   1784                 guard !RecordSerializer.isGameScopedRecordType(deletion.recordType)
   1785                     || RecordSerializer.isTrustedGameScopedDeletion(
   1786                         recordID: deletion.recordID,
   1787                         recordType: deletion.recordType
   1788                     )
   1789                 else {
   1790                     effects.traces.append(
   1791                         "rejected untrusted fetched deletion \(deletion.recordType) " +
   1792                         "\(deletion.recordID.recordName) in \(deletion.recordID.zoneID.zoneName)"
   1793                     )
   1794                     continue
   1795                 }
   1796                 self.applyDeletion(
   1797                     recordID: deletion.recordID,
   1798                     recordType: deletion.recordType,
   1799                     databaseScope: scope,
   1800                     in: ctx
   1801                 )
   1802                 if let id = self.gameID(fromRecordName: deletion.recordID.recordName) {
   1803                     effects.rosterRelevant.insert(id)
   1804                     effects.visibilityCandidateGameIDs.insert(id)
   1805                 }
   1806             }
   1807             for gameID in effects.movesUpdated {
   1808                 effects.traces += self.replayCellCache(for: gameID, in: ctx)
   1809             }
   1810             // CKSyncEngine advances its change token whenever the delegate
   1811             // returns from fetchedRecordZoneChanges, regardless of whether we
   1812             // persisted anything. A silent failure here means the records are
   1813             // gone from the engine's "to deliver" set — they won't come back
   1814             // without a `resetSyncState`. Surface failures so we can act —
   1815             // through the tracer, which is the only channel that reaches the
   1816             // on-device diagnostics log this project debugs Production from.
   1817             if ctx.hasChanges {
   1818                 do {
   1819                     try ctx.save()
   1820                 } catch {
   1821                     let nsError = error as NSError
   1822                     effects.traces.append(
   1823                         "fetchedRecordZoneChanges ctx.save FAILED " +
   1824                         "— domain=\(nsError.domain) code=\(nsError.code) " +
   1825                         "\(nsError.localizedDescription)"
   1826                     )
   1827                 }
   1828             }
   1829             // Re-mirror the App Group key directory once the batch is saved, so
   1830             // a just-adopted content key is available to the NSE immediately.
   1831             if !effects.contentKeysChanged.isEmpty {
   1832                 GameEntity.rebuildContentKeyDirectory(in: ctx)
   1833             }
   1834             return effects
   1835         }
   1836 
   1837         for message in effects.traces {
   1838             await trace(message)
   1839         }
   1840         if let onGameVisibilityCandidates, !effects.visibilityCandidateGameIDs.isEmpty {
   1841             await onGameVisibilityCandidates(effects.visibilityCandidateGameIDs)
   1842         }
   1843         if let onRemoteMovesUpdated, !effects.movesUpdated.isEmpty {
   1844             await onRemoteMovesUpdated(effects.movesUpdated)
   1845         }
   1846         if let onRemotePlayersUpdated, !effects.playersUpdated.isEmpty {
   1847             await onRemotePlayersUpdated(effects.playersUpdated)
   1848         }
   1849         if let onRemotePlayerPresenceChanged, !effects.playerPresenceChanged.isEmpty {
   1850             await onRemotePlayerPresenceChanged(effects.playerPresenceChanged)
   1851         }
   1852         if let onRemoteEngagementChanged, !effects.engagementChanged.isEmpty {
   1853             await onRemoteEngagementChanged(effects.engagementChanged)
   1854         }
   1855         if let onPushCredentialRotationNeeded, !effects.credentialRotations.isEmpty {
   1856             await onPushCredentialRotationNeeded(effects.credentialRotations)
   1857         }
   1858         if let onRemoteCredentialsChanged, !effects.contentKeysChanged.isEmpty {
   1859             await onRemoteCredentialsChanged(effects.contentKeysChanged)
   1860         }
   1861         if let onIncomingReadCursor, !effects.readCursors.isEmpty {
   1862             await onIncomingReadCursor(effects.readCursors)
   1863         }
   1864         if let onPings, !effects.pings.isEmpty {
   1865             await onPings(effects.pings)
   1866         }
   1867         if let onAccountPushAddress {
   1868             for address in effects.accountPushAddresses {
   1869                 await onAccountPushAddress(address)
   1870             }
   1871         }
   1872         // The push secret converges across the account's own devices purely
   1873         // through this inbound path (the account zone lives in the private DB,
   1874         // which reliably syncs to every one of the account's devices). On a
   1875         // simultaneous-mint race the loser adopts the winner's secret here on
   1876         // the next fetch — no send-failure-recovery shortcut needed.
   1877         if let onAccountPushSecret {
   1878             for entry in effects.accountPushSecrets {
   1879                 await onAccountPushSecret(entry.secret, entry.version)
   1880             }
   1881         }
   1882         if let localAuthorID, !localAuthorID.isEmpty,
   1883            let maxVersion = effects.selfNameVersions.max() {
   1884             NameVersionStore.adopt(maxVersion, authorID: localAuthorID)
   1885         }
   1886         if effects.friendNamesChanged {
   1887             let mirrorCtx = persistence.container.newBackgroundContext()
   1888             await mirrorCtx.perform {
   1889                 FriendEntity.rebuildNicknameDirectory(in: mirrorCtx)
   1890             }
   1891         }
   1892         if !effects.blockedFriendAuthorIDs.isEmpty {
   1893             await onBlockedFriendsChanged?(effects.blockedFriendAuthorIDs)
   1894         }
   1895         for id in effects.removed {
   1896             if let cb = onGameRemoved { await cb(id) }
   1897         }
   1898         // Re-push games whose inbound record tried to downgrade a rotated
   1899         // credential: the local (newer) value was kept and marked pending, so
   1900         // this send heals the server copy.
   1901         for recordName in effects.staleCredentialRecords {
   1902             enqueueGame(ckRecordName: recordName)
   1903         }
   1904         // A game just learned it's complete via sync: upload this device's
   1905         // journal (no-op if it logged nothing) so replay can converge. The
   1906         // enqueue defers its CKSyncEngine drain via `sendChangesDetached`, so
   1907         // it never awaits sync from inside this delegate callback.
   1908         if let localAuthorID, !localAuthorID.isEmpty {
   1909             for id in effects.completedTransitions {
   1910                 enqueueJournalUpload(gameID: id, authorID: localAuthorID)
   1911             }
   1912         }
   1913         if let onGameCompleted {
   1914             for id in effects.completedTransitions {
   1915                 await onGameCompleted(id)
   1916             }
   1917         }
   1918         let deletedPings = event.deletions.compactMap { deletion -> (recordName: String, gameID: UUID)? in
   1919             let recordName = deletion.recordID.recordName
   1920             guard recordName.hasPrefix("ping-"),
   1921                   let gameID = gameID(fromRecordName: recordName)
   1922             else { return nil }
   1923             return (recordName, gameID)
   1924         }
   1925         if let onPingDeleted, !deletedPings.isEmpty {
   1926             await onPingDeleted(deletedPings)
   1927         }
   1928         if !effects.rosterRelevant.isEmpty {
   1929             NotificationCenter.default.post(
   1930                 name: .playerRosterShouldRefresh,
   1931                 object: nil,
   1932                 userInfo: ["gameIDs": effects.rosterRelevant]
   1933             )
   1934         }
   1935         await notifyReplayJournalsSynced(effects.journalsSynced)
   1936     }
   1937 
   1938     func notifyReplayJournalsSynced(_ gameIDs: Set<UUID>) async {
   1939         guard !gameIDs.isEmpty else { return }
   1940         NotificationCenter.default.post(
   1941             name: .replayJournalDidSync,
   1942             object: nil,
   1943             userInfo: ["gameIDs": gameIDs]
   1944         )
   1945         await onReplayJournalsSynced?(gameIDs)
   1946     }
   1947 
   1948     private nonisolated static func recordTypeSummary(_ counts: [String: Int]) -> String {
   1949         counts
   1950             .filter { $0.value > 0 }
   1951             .sorted { lhs, rhs in
   1952                 if lhs.key == rhs.key { return lhs.value > rhs.value }
   1953                 return lhs.key < rhs.key
   1954             }
   1955             .map { "\($0.key)=\($0.value)" }
   1956             .joined(separator: ", ")
   1957     }
   1958 
   1959     private nonisolated static func inferredRecordType(for recordID: CKRecord.ID) -> String {
   1960         let name = recordID.recordName
   1961         if name.hasPrefix("moves-") { return "Moves" }
   1962         if name.hasPrefix("journal-") { return "Journal" }
   1963         if name.hasPrefix("player-") { return "Player" }
   1964         if name.hasPrefix("game-") { return "Game" }
   1965         if name.hasPrefix("ping-") { return "Ping" }
   1966         if name.hasPrefix("decision-") { return "Decision" }
   1967         return "Unknown"
   1968     }
   1969 
   1970     /// A pending Ping remains durable for conditions CloudKit documents as
   1971     /// retryable. These errors instead mean the particular write cannot
   1972     /// succeed without a new user action or a corrected request, so leaving it
   1973     /// queued would show a clock forever and retain an unusable CKShare seat.
   1974     nonisolated static func isTerminalPingSaveError(_ error: NSError) -> Bool {
   1975         guard error.domain == CKErrorDomain,
   1976               let code = CKError.Code(rawValue: error.code)
   1977         else { return false }
   1978         switch code {
   1979         case .badContainer,
   1980              .missingEntitlement,
   1981              .notAuthenticated,
   1982              .permissionFailure,
   1983              .unknownItem,
   1984              .invalidArguments,
   1985              .serverRejectedRequest,
   1986              .incompatibleVersion,
   1987              .constraintViolation,
   1988              .badDatabase,
   1989              .quotaExceeded,
   1990              .zoneNotFound,
   1991              .limitExceeded,
   1992              .userDeletedZone,
   1993              .tooManyParticipants,
   1994              .referenceViolation,
   1995              .managedAccountRestricted:
   1996             return true
   1997         default:
   1998             return false
   1999         }
   2000     }
   2001 
   2002     nonisolated static func pingSaveRetryDelay(_ error: NSError) -> TimeInterval? {
   2003         guard error.domain == CKErrorDomain,
   2004               let code = CKError.Code(rawValue: error.code)
   2005         else { return nil }
   2006         switch code {
   2007         case .internalError,
   2008              .networkUnavailable,
   2009              .networkFailure,
   2010              .serviceUnavailable,
   2011              .requestRateLimited,
   2012              .zoneBusy,
   2013              .operationCancelled,
   2014              .serverResponseLost,
   2015              .accountTemporarilyUnavailable:
   2016             let serverDelay = (error.userInfo[CKErrorRetryAfterKey] as? NSNumber)?
   2017                 .doubleValue
   2018             return max(3, serverDelay ?? 0)
   2019         default:
   2020             return nil
   2021         }
   2022     }
   2023 
   2024     private func schedulePendingPingRetry(
   2025         recordID: CKRecord.ID,
   2026         isPrivate: Bool,
   2027         after delay: TimeInterval
   2028     ) {
   2029         let recordName = recordID.recordName
   2030         guard pendingPings[recordName] != nil,
   2031               scheduledPingRetries.insert(recordName).inserted
   2032         else { return }
   2033         Task {
   2034             try? await Task.sleep(for: .seconds(delay))
   2035             scheduledPingRetries.remove(recordName)
   2036             guard !Task.isCancelled, pendingPings[recordName] != nil else { return }
   2037             let engine = isPrivate ? privateEngine : sharedEngine
   2038             engine?.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)])
   2039             if let engine { sendChangesDetached(on: engine) }
   2040         }
   2041     }
   2042 
   2043     private func handleSentRecordZoneChanges(
   2044         _ event: CKSyncEngine.Event.SentRecordZoneChanges,
   2045         isPrivate: Bool
   2046     ) async {
   2047         var completionRecords: [UUID: Set<CompletionDurableRecordKind>] = [:]
   2048         for record in event.savedRecords {
   2049             let name = record.recordID.recordName
   2050             if record.recordType == "Game",
   2051                name.hasPrefix("game-"),
   2052                record["completedAt"] as? Date != nil,
   2053                let gameID = UUID(uuidString: String(name.dropFirst("game-".count))) {
   2054                 completionRecords[gameID, default: []].insert(.game)
   2055             } else if record.recordType == "Moves",
   2056                       let gameID = RecordSerializer.parseMovesRecordName(name)?.0 {
   2057                 completionRecords[gameID, default: []].insert(.moves)
   2058             } else if record.recordType == "Journal",
   2059                       let gameID = RecordSerializer.parseJournalRecordName(name)?.0 {
   2060                 completionRecords[gameID, default: []].insert(.journal)
   2061             }
   2062         }
   2063         let savedTypes = Dictionary(
   2064             grouping: event.savedRecords,
   2065             by: \.recordType
   2066         ).mapValues(\.count)
   2067         let failedTypes = Dictionary(
   2068             grouping: event.failedRecordSaves,
   2069             by: { $0.record.recordType }
   2070         ).mapValues(\.count)
   2071         let deletedTypes = Dictionary(
   2072             grouping: event.deletedRecordIDs,
   2073             by: Self.inferredRecordType(for:)
   2074         ).mapValues(\.count)
   2075         let savedSummary = Self.recordTypeSummary(savedTypes)
   2076         let failedSummary = Self.recordTypeSummary(failedTypes)
   2077         let deletedSummary = Self.recordTypeSummary(deletedTypes)
   2078 
   2079         await trace(
   2080             "\(isPrivate ? "private" : "shared") sent: " +
   2081             "\(event.savedRecords.count) saved" +
   2082             "\(savedSummary.isEmpty ? "" : " (\(savedSummary))"), " +
   2083             "\(event.failedRecordSaves.count) failed" +
   2084             "\(failedSummary.isEmpty ? "" : " (\(failedSummary))"), " +
   2085             "\(event.deletedRecordIDs.count) deleted" +
   2086             "\(deletedSummary.isEmpty ? "" : " (\(deletedSummary))")"
   2087         )
   2088         // Only bump on a clean batch: a round trip with per-record failures
   2089         // is not "success" from the user's point of view, and the existing
   2090         // SyncMonitor.recordError path owns reporting whatever did break.
   2091         if event.failedRecordSaves.isEmpty {
   2092             await noteRoundTripSuccess()
   2093         }
   2094         for record in event.savedRecords {
   2095             let name = record.recordID.recordName
   2096             if name.hasPrefix("ping-") {
   2097                 await confirmPendingPing(recordName: name)
   2098             } else if name.hasPrefix("decision-") {
   2099                 let stateKey = Self.decisionStateKey(record.recordID)
   2100                 pendingDecisionPayloads.removeValue(forKey: stateKey)
   2101                 persistPendingDecisionPayloads()
   2102                 pendingDecisionVersions.removeValue(forKey: stateKey)
   2103                 persistPendingDecisionVersions()
   2104                 decisionSystemFields.removeValue(forKey: stateKey)
   2105             }
   2106         }
   2107         // Snapshot the intended versions on-actor so the off-actor conflict
   2108         // resolution can tell a deliberate, newer write from a stale one.
   2109         let pendingVersionsSnapshot = pendingDecisionVersions
   2110         let ctx = persistence.container.newBackgroundContext()
   2111         ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
   2112         let (failureMessages, orphanedZones, resolvedDecisions, settledPings, settledJournals,
   2113              resolvedAccountAddresses, resolvedAccountSecrets, decisionWins, recoveredSaves,
   2114              batchFailedSaves, unresolvedFailureZones):
   2115             ([String], Set<CKRecordZone.ID>, Set<CKRecord.ID>, Set<CKRecord.ID>, Set<CKRecord.ID>,
   2116              [String], [(secret: String, version: Int64)],
   2117              [(recordID: CKRecord.ID, stateKey: String, systemFields: Data)],
   2118              [CKRecord.ID], [CKRecord.ID], Set<CKRecordZone.ID>) = await ctx.perform {
   2119             var messages: [String] = []
   2120             var orphaned = Set<CKRecordZone.ID>()
   2121             var settledDecisions = Set<CKRecord.ID>()
   2122             var settledPings = Set<CKRecord.ID>()
   2123             var settledJournals = Set<CKRecord.ID>()
   2124             var accountAddresses: [String] = []
   2125             var accountSecrets: [(secret: String, version: Int64)] = []
   2126             // Versioned decisions that lost the change-tag race but win on
   2127             // version: (decision state key, server system fields to adopt for
   2128             // the overwrite retry).
   2129             var decisionWins: [(recordID: CKRecord.ID, stateKey: String, systemFields: Data)] = []
   2130             // Game/Moves/Player saves that lost a change-tag race and had the
   2131             // server's system fields written back: re-enqueued below. Like
   2132             // decisions, a `serverRecordChanged` failure leaves nothing pending,
   2133             // so without the re-add these heal only when unrelated activity
   2134             // happens to re-enqueue the record — reliable enough for the
   2135             // keystroke-cadence Moves/Player writes, but a Game record (share
   2136             // metadata, completion) can strand on an idle game until its next
   2137             // change.
   2138             var recoveredSaves: [CKRecord.ID] = []
   2139             // Atomic operations report the causal record's substantive error
   2140             // and `.batchRequestFailed` for otherwise-valid siblings. The
   2141             // latter have already fallen out of CKSyncEngine's pending state;
   2142             // retain them for an explicit retry after every causal record in
   2143             // their zone has been settled or recovered.
   2144             var batchFailedSaves: [CKRecord.ID] = []
   2145             var unresolvedFailureZones = Set<CKRecordZone.ID>()
   2146             for record in event.savedRecords {
   2147                 self.writeBackSystemFields(record: record, in: ctx)
   2148                 let savedName = record.recordID.recordName
   2149                 if savedName.hasPrefix("game-") {
   2150                     self.clearPendingSaveFlag(for: savedName, in: ctx)
   2151                 } else if savedName.hasPrefix("journal-"),
   2152                           let (gid, _, _) = RecordSerializer.parseJournalRecordName(savedName) {
   2153                     // Confirmed durable upload of this device's journal — record
   2154                     // it so the level-triggered backstop
   2155                     // (`reconcilePendingJournalUploads`) stops re-enqueuing it.
   2156                     self.markJournalUploaded(gameID: gid, in: ctx)
   2157                 }
   2158             }
   2159             for failure in event.failedRecordSaves {
   2160                 let name = failure.record.recordID.recordName
   2161                 let err = failure.error as NSError
   2162                 // A settled failure is one CloudKit rejected but we treat as
   2163                 // success (the durable record already exists), so it skips the
   2164                 // verbose "failed to save" log below — that line reads as an
   2165                 // error and is pure noise next to the "settled" message.
   2166                 var settled = false
   2167                 if err.domain == CKErrorDomain,
   2168                    err.code == CKError.batchRequestFailed.rawValue {
   2169                     batchFailedSaves.append(failure.record.recordID)
   2170                     settled = true
   2171                 } else if err.domain == CKErrorDomain,
   2172                    err.code == CKError.zoneNotFound.rawValue {
   2173                     orphaned.insert(failure.record.recordID.zoneID)
   2174                 } else if !isPrivate,
   2175                           self.isInvalidSharedZoneOwnerError(err) {
   2176                     orphaned.insert(failure.record.recordID.zoneID)
   2177                 } else if name.hasPrefix("ping-"),
   2178                           err.domain == CKErrorDomain,
   2179                           err.code == CKError.serverRecordChanged.rawValue {
   2180                     // Pings are immutable and their retry keeps the original
   2181                     // record ID. A conflict therefore means the outbox item is
   2182                     // already durable (for example, the app terminated after
   2183                     // CloudKit saved it but before processing the sent event).
   2184                     settledPings.insert(failure.record.recordID)
   2185                     settled = true
   2186                     messages.append("send: ping \(name) already present — settled")
   2187                 } else if name.hasPrefix("decision-"),
   2188                           err.domain == CKErrorDomain,
   2189                           err.code == CKError.serverRecordChanged.rawValue {
   2190                     let serverRecord = err.userInfo[CKRecordChangedErrorServerRecordKey] as? CKRecord
   2191                     let stateKey = Self.decisionStateKey(failure.record.recordID)
   2192                     let intended = pendingVersionsSnapshot[stateKey]
   2193                     let serverVersion = serverRecord.map(RecordSerializer.decisionVersion)
   2194                     if let intended, let serverRecord, let serverVersion,
   2195                        intended > serverVersion,
   2196                        let serverFields = RecordSerializer.encodeSystemFields(of: serverRecord) {
   2197                         // A deliberate, newer write (e.g. a rotated push secret
   2198                         // or a rename) that lost only the change-tag race. Adopt
   2199                         // the server's tag so the next build overwrites instead
   2200                         // of re-colliding, then re-enqueue the save below: a
   2201                         // `serverRecordChanged` failure does NOT leave the change
   2202                         // in CKSyncEngine's pending state (the original code
   2203                         // assumed it did), so the retry must re-add it explicitly
   2204                         // or the overwrite is silently dropped.
   2205                         decisionWins.append((failure.record.recordID, stateKey, serverFields))
   2206                         settled = true
   2207                         messages.append(
   2208                             "send: decision \(name) lost tag race but wins on version " +
   2209                             "(\(intended) > \(serverVersion)) — overwriting"
   2210                         )
   2211                     } else {
   2212                         // Write-once (left/pushAddress) or an equal/older
   2213                         // version: the durable fact already on the server wins.
   2214                         // Drop the pending change so the record doesn't
   2215                         // retry-loop, and adopt the winner's payload straight off
   2216                         // the conflict instead of waiting for a later fetch — for
   2217                         // the push secret that closes the window in which this
   2218                         // device would keep deriving divergent per-game addresses.
   2219                         settledDecisions.insert(failure.record.recordID)
   2220                         if let serverRecord {
   2221                             let scope = DatabaseScope(isPrivate: isPrivate)
   2222                             if let address = RecordSerializer.parseAccountPushAddressDecision(serverRecord, databaseScope: scope) {
   2223                                 accountAddresses.append(address)
   2224                             }
   2225                             if let parsed = RecordSerializer.parseAccountPushSecretDecision(serverRecord, databaseScope: scope) {
   2226                                 accountSecrets.append(parsed)
   2227                             }
   2228                         }
   2229                         settled = true
   2230                         messages.append("send: decision \(name) already present — settled")
   2231                     }
   2232                 } else if name.hasPrefix("journal-"),
   2233                           err.domain == CKErrorDomain,
   2234                           err.code == CKError.serverRecordChanged.rawValue,
   2235                           let (gid, _, _) = RecordSerializer.parseJournalRecordName(name) {
   2236                     // Journals are write-once at completion: "record to insert
   2237                     // already exists" means this device's journal is already
   2238                     // durable server-side (a backstop re-enqueue, or a first
   2239                     // upload on a build predating the `journalUploaded` flag).
   2240                     // There is no system-fields archive to adopt for an update
   2241                     // and the content is frozen, so the re-send is a no-op —
   2242                     // settle it like a Decision: drop the pending change and
   2243                     // mark the game uploaded so `reconcilePendingJournalUploads`
   2244                     // stops re-enqueuing it. Without this the save fails every
   2245                     // sweep and `Pending Changes` never drains.
   2246                     settledJournals.insert(failure.record.recordID)
   2247                     self.markJournalUploaded(gameID: gid, in: ctx)
   2248                     settled = true
   2249                     messages.append("send: journal \(name) already present — settled")
   2250                 } else if self.recoverServerChangedSave(failure.error, failedRecordName: name, in: ctx) {
   2251                     recoveredSaves.append(failure.record.recordID)
   2252                     settled = true
   2253                     messages.append(
   2254                         "send: recovered stale system fields for \(name) from CloudKit server record"
   2255                     )
   2256                 }
   2257                 guard !settled else { continue }
   2258                 unresolvedFailureZones.insert(failure.record.recordID.zoneID)
   2259                 let userInfo = err.userInfo
   2260                     .map { "\($0.key)=\($0.value)" }
   2261                     .joined(separator: " | ")
   2262                 messages.append(
   2263                     "send: failed to save \(name) — domain=\(err.domain) code=\(err.code) \(err.localizedDescription) | userInfo: \(userInfo)"
   2264                 )
   2265             }
   2266             if ctx.hasChanges {
   2267                 do {
   2268                     try ctx.save()
   2269                 } catch {
   2270                     let nsError = error as NSError
   2271                     messages.append(
   2272                         "send: writeBack ctx.save failed — domain=\(nsError.domain) code=\(nsError.code) \(nsError.localizedDescription)"
   2273                     )
   2274                 }
   2275             }
   2276             return (messages, orphaned, settledDecisions, settledPings, settledJournals,
   2277                     accountAddresses, accountSecrets, decisionWins, recoveredSaves,
   2278                     batchFailedSaves, unresolvedFailureZones)
   2279         }
   2280         if !orphanedZones.isEmpty {
   2281             await applyZoneOrphaning(orphanedZones, isPrivate: isPrivate, source: "send")
   2282         }
   2283         let terminalPingFailures = event.failedRecordSaves.filter { failure in
   2284             failure.record.recordID.recordName.hasPrefix("ping-")
   2285                 && Self.isTerminalPingSaveError(failure.error as NSError)
   2286         }
   2287         if !terminalPingFailures.isEmpty {
   2288             let engine = isPrivate ? privateEngine : sharedEngine
   2289             let changes = terminalPingFailures.map {
   2290                 CKSyncEngine.PendingRecordZoneChange.saveRecord($0.record.recordID)
   2291             }
   2292             engine?.state.remove(pendingRecordZoneChanges: changes)
   2293             for failure in terminalPingFailures {
   2294                 let recordName = failure.record.recordID.recordName
   2295                 guard pendingPings[recordName] != nil else { continue }
   2296                 await failPendingPing(
   2297                     recordName: recordName,
   2298                     error: failure.error as NSError
   2299                 )
   2300             }
   2301         }
   2302         for failure in event.failedRecordSaves {
   2303             let recordID = failure.record.recordID
   2304             guard recordID.recordName.hasPrefix("ping-"),
   2305                   let delay = Self.pingSaveRetryDelay(failure.error as NSError)
   2306             else { continue }
   2307             schedulePendingPingRetry(
   2308                 recordID: recordID,
   2309                 isPrivate: isPrivate,
   2310                 after: delay
   2311             )
   2312         }
   2313         // Settle/retry against the engine this sent-event belongs to: account-
   2314         // zone decisions ride the private engine, but a name Decision in a
   2315         // joined friend zone rides the shared one.
   2316         let decisionEngine = isPrivate ? privateEngine : sharedEngine
   2317         if !resolvedDecisions.isEmpty, let decisionEngine {
   2318             settleDecisionRecords(resolvedDecisions, on: decisionEngine)
   2319         }
   2320         // Adopt the server's tag for each versioned decision we're overwriting,
   2321         // re-enqueue the save (the failed change is no longer pending), then
   2322         // nudge the send loop so the retry (now carrying the tag) goes out
   2323         // promptly rather than on CKSyncEngine's own cadence.
   2324         if !decisionWins.isEmpty, let decisionEngine {
   2325             for win in decisionWins {
   2326                 decisionSystemFields[win.stateKey] = win.systemFields
   2327                 decisionEngine.state.add(
   2328                     pendingRecordZoneChanges: [.saveRecord(win.recordID)]
   2329                 )
   2330             }
   2331             sendChangesDetached(on: decisionEngine)
   2332         }
   2333         // Re-enqueue Game/Moves/Player saves whose stale system fields we just
   2334         // healed: a `serverRecordChanged` failure leaves nothing pending (see
   2335         // `decisionWins`), so the now-current record must be re-added or the
   2336         // local change waits for the next unrelated enqueue. The engine for
   2337         // this sent-event is the same one decisions ride.
   2338         if !recoveredSaves.isEmpty, let engine = isPrivate ? privateEngine : sharedEngine {
   2339             engine.state.add(
   2340                 pendingRecordZoneChanges: recoveredSaves.map { .saveRecord($0) }
   2341             )
   2342             sendChangesDetached(on: engine)
   2343         }
   2344         if let onAccountPushAddress {
   2345             for address in resolvedAccountAddresses {
   2346                 await onAccountPushAddress(address)
   2347             }
   2348         }
   2349         if let onAccountPushSecret {
   2350             for entry in resolvedAccountSecrets {
   2351                 await onAccountPushSecret(entry.secret, entry.version)
   2352             }
   2353         }
   2354         if !settledJournals.isEmpty {
   2355             // Drop from whichever engine owns the zone (private for solo games,
   2356             // shared for collaborations) — unlike decisions, journals ride both.
   2357             let engine = isPrivate ? privateEngine : sharedEngine
   2358             engine?.state.remove(
   2359                 pendingRecordZoneChanges: settledJournals.map { .saveRecord($0) }
   2360             )
   2361         }
   2362         if !settledPings.isEmpty {
   2363             let engine = isPrivate ? privateEngine : sharedEngine
   2364             engine?.state.remove(
   2365                 pendingRecordZoneChanges: settledPings.map { .saveRecord($0) }
   2366             )
   2367             for recordID in settledPings {
   2368                 await confirmPendingPing(recordName: recordID.recordName)
   2369             }
   2370         }
   2371         let retryableBatchSiblings = batchFailedSaves.filter {
   2372             !orphanedZones.contains($0.zoneID)
   2373                 && !unresolvedFailureZones.contains($0.zoneID)
   2374         }
   2375         if !retryableBatchSiblings.isEmpty {
   2376             await recoverBatchFailedSaves(retryableBatchSiblings, isPrivate: isPrivate)
   2377         }
   2378         for message in failureMessages {
   2379             await trace(message)
   2380         }
   2381         if !completionRecords.isEmpty, let onCompletionRecordsSaved {
   2382             await onCompletionRecordsSaved(completionRecords)
   2383         }
   2384     }
   2385 
   2386     private func settleDecisionRecords(
   2387         _ recordIDs: Set<CKRecord.ID>,
   2388         on engine: CKSyncEngine
   2389     ) {
   2390         engine.state.remove(
   2391             pendingRecordZoneChanges: recordIDs.map { .saveRecord($0) }
   2392         )
   2393         for recordID in recordIDs {
   2394             let stateKey = Self.decisionStateKey(recordID)
   2395             pendingDecisionPayloads.removeValue(forKey: stateKey)
   2396             pendingDecisionVersions.removeValue(forKey: stateKey)
   2397             decisionSystemFields.removeValue(forKey: stateKey)
   2398         }
   2399         persistPendingDecisionPayloads()
   2400         persistPendingDecisionVersions()
   2401     }
   2402 
   2403     /// Re-adds only atomic-failure siblings whose records can still be built.
   2404     /// The caller invokes this after resolving all substantive failures in the
   2405     /// zone, preventing a blind retry of the original atomic batch.
   2406     private func recoverBatchFailedSaves(
   2407         _ recordIDs: [CKRecord.ID],
   2408         isPrivate: Bool
   2409     ) async {
   2410         guard let engine = isPrivate ? privateEngine : sharedEngine else { return }
   2411         let scope = DatabaseScope(isPrivate: isPrivate)
   2412         let pingSnapshot = pendingPings
   2413         let decisionSnapshot = pendingDecisionPayloads
   2414         let decisionVersionSnapshot = pendingDecisionVersions
   2415         let decisionSystemFieldsSnapshot = decisionSystemFields
   2416         let ctx = persistence.container.newBackgroundContext()
   2417         var recovered: [CKRecord.ID] = []
   2418         for recordID in recordIDs {
   2419             guard buildRecord(
   2420                 for: recordID,
   2421                 in: ctx,
   2422                 databaseScope: scope,
   2423                 pings: pingSnapshot,
   2424                 decisions: decisionSnapshot,
   2425                 decisionVersions: decisionVersionSnapshot,
   2426                 decisionSystemFields: decisionSystemFieldsSnapshot
   2427             ) != nil else {
   2428                 await trace(
   2429                     "send: atomic sibling \(recordID.recordName) is no longer reconstructable — dropped"
   2430                 )
   2431                 continue
   2432             }
   2433             recovered.append(recordID)
   2434         }
   2435         guard !recovered.isEmpty else { return }
   2436         engine.state.add(
   2437             pendingRecordZoneChanges: recovered.map { .saveRecord($0) }
   2438         )
   2439         await trace(
   2440             "send: re-enqueued \(recovered.count) reconstructable atomic-failure sibling" +
   2441             (recovered.count == 1 ? "" : "s")
   2442         )
   2443         sendChangesDetached(on: engine)
   2444     }
   2445 
   2446     /// Test seam for the atomic-failure recovery sequence. CKSyncEngine's sent
   2447     /// event has no public initializer, so tests supply the causal Decision and
   2448     /// collateral record IDs directly after arranging their pending payloads.
   2449     func recoverAtomicBatchForTesting(
   2450         settledDecisionID: CKRecord.ID,
   2451         batchFailedRecordIDs: [CKRecord.ID],
   2452         scope: CKDatabase.Scope
   2453     ) async {
   2454         guard let engine = scope == .shared ? sharedEngine : privateEngine else { return }
   2455         let allIDs = [settledDecisionID] + batchFailedRecordIDs
   2456         engine.state.remove(
   2457             pendingRecordZoneChanges: allIDs.map { .saveRecord($0) }
   2458         )
   2459         settleDecisionRecords([settledDecisionID], on: engine)
   2460         await recoverBatchFailedSaves(
   2461             batchFailedRecordIDs,
   2462             isPrivate: scope != .shared
   2463         )
   2464     }
   2465 
   2466     nonisolated func isInvalidSharedZoneOwnerError(_ error: NSError) -> Bool {
   2467         let values = [error.localizedDescription] + error.userInfo.map { "\($0.value)" }
   2468         return values.contains {
   2469             $0.localizedCaseInsensitiveContains("Cannot convert userId to dsId") ||
   2470             $0.localizedCaseInsensitiveContains("invalid userId")
   2471         }
   2472     }
   2473 
   2474     /// Reflects missing game zones locally after CloudKit reports them gone.
   2475     /// The same cleanup applies whether the absence arrives as a fetched
   2476     /// database-zone deletion or as a `.zoneNotFound` send/query failure: drop
   2477     /// pending sends that target the zone, delete private games or mark shared
   2478     /// games access-revoked, and notify upstream observers. Without this,
   2479     /// queued changes can retry forever and `Last Error` stays stuck on
   2480     /// `Failed to send changes` indefinitely.
   2481     /// Internal-rather-than-private so the test suite can drive it directly;
   2482     /// `CKSyncEngine.Event` payloads have no public initializer so we cannot
   2483     /// exercise `handleSentRecordZoneChanges` end-to-end.
   2484     func applyZoneOrphaning(
   2485         _ zones: Set<CKRecordZone.ID>,
   2486         isPrivate: Bool,
   2487         source: String = "sync"
   2488     ) async {
   2489         let engine = isPrivate ? privateEngine : sharedEngine
   2490         if let engine {
   2491             let toRemove = engine.state.pendingRecordZoneChanges.filter { change in
   2492                 switch change {
   2493                 case .saveRecord(let id):
   2494                     return zones.contains(id.zoneID)
   2495                 case .deleteRecord(let id):
   2496                     return zones.contains(id.zoneID)
   2497                 @unknown default:
   2498                     return false
   2499                 }
   2500             }
   2501             if !toRemove.isEmpty {
   2502                 engine.state.remove(pendingRecordZoneChanges: toRemove)
   2503             }
   2504         }
   2505 
   2506         let orphanedPingNames = pendingPings.compactMap { name, ping in
   2507             zones.contains(ping.recordZoneID) ? name : nil
   2508         }
   2509         for name in orphanedPingNames {
   2510             await failPendingPing(recordName: name)
   2511         }
   2512 
   2513         let ctx = persistence.container.newBackgroundContext()
   2514         ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
   2515         let (removedIDs, revokedIDs, failureMessages): ([UUID], [UUID], [String]) = await ctx.perform {
   2516             var removed: [UUID] = []
   2517             var revoked: [UUID] = []
   2518             var messages: [String] = []
   2519             for zone in zones {
   2520                 let zoneName = zone.zoneName
   2521                 guard zoneName.hasPrefix("game-") else { continue }
   2522                 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
   2523                 req.predicate = NSPredicate(format: "ckZoneName == %@", zoneName)
   2524                 req.fetchLimit = 1
   2525                 guard let entity = try? ctx.fetch(req).first else { continue }
   2526                 if isPrivate {
   2527                     if let id = entity.id { removed.append(id) }
   2528                     ctx.delete(entity)
   2529                 } else {
   2530                     if !entity.isAccessRevoked, let id = entity.id {
   2531                         revoked.append(id)
   2532                     }
   2533                     entity.isAccessRevoked = true
   2534                 }
   2535             }
   2536             if ctx.hasChanges {
   2537                 do {
   2538                     try ctx.save()
   2539                 } catch {
   2540                     let nsError = error as NSError
   2541                     messages.append(
   2542                         "orphan-zone ctx.save FAILED — domain=\(nsError.domain) " +
   2543                         "code=\(nsError.code) \(nsError.localizedDescription)"
   2544                     )
   2545                 }
   2546             }
   2547             return (removed, revoked, messages)
   2548         }
   2549 
   2550         for message in failureMessages {
   2551             await trace(message)
   2552         }
   2553         await trace(
   2554             "\(isPrivate ? "private" : "shared") orphaned \(zones.count) zone(s) [src=\(source)]: " +
   2555             zones.map(\.zoneName).sorted().joined(separator: ", ")
   2556         )
   2557 
   2558         for id in removedIDs {
   2559             if let cb = onGameRemoved { await cb(id) }
   2560         }
   2561         for id in revokedIDs {
   2562             if let cb = onGameAccessRevoked { await cb(id) }
   2563         }
   2564     }
   2565 
   2566     /// CKSyncEngine reports optimistic-lock conflicts as failed saves, but the
   2567     /// error payload often includes the current server record. Adopt only that
   2568     /// record's system fields so a retry can use the fresh change tag while
   2569     /// preserving the local values that caused the pending save.
   2570     private nonisolated func recoverServerChangedSave(
   2571         _ error: Error,
   2572         failedRecordName: String,
   2573         in ctx: NSManagedObjectContext
   2574     ) -> Bool {
   2575         let nsError = error as NSError
   2576         guard nsError.domain == CKErrorDomain,
   2577               nsError.code == CKError.serverRecordChanged.rawValue,
   2578               let serverRecord = nsError.userInfo[CKRecordChangedErrorServerRecordKey] as? CKRecord,
   2579               serverRecord.recordID.recordName == failedRecordName
   2580         else { return false }
   2581 
   2582         writeBackSystemFields(record: serverRecord, in: ctx)
   2583         adoptNewerServerCredentials(from: serverRecord, in: ctx)
   2584         return true
   2585     }
   2586 
   2587     /// The oplock-recovery retry re-pushes the whole local Game record, and
   2588     /// while this entity's `hasPendingSave` was set its inbound applies were
   2589     /// skipped — so the local `notification` blob may predate a credential
   2590     /// rotation a peer performed in the meantime. Re-pushing it verbatim would
   2591     /// land the *old* credential back on the server and re-admit a departed
   2592     /// participant's push access. Before the retry, adopt the server's
   2593     /// credential whenever its rotation generation is at least ours (the tie
   2594     /// goes to the server copy, converging concurrent rotations); a *lower*
   2595     /// server generation is the stale state the retry exists to heal, so local
   2596     /// wins there.
   2597     private nonisolated func adoptNewerServerCredentials(
   2598         from serverRecord: CKRecord,
   2599         in ctx: NSManagedObjectContext
   2600     ) {
   2601         let name = serverRecord.recordID.recordName
   2602         guard name.hasPrefix("game-") else { return }
   2603         let serverBlob = (serverRecord["pushCredential"] as? Data)
   2604             .flatMap { String(data: $0, encoding: .utf8) }
   2605         guard let serverBlob, let serverCreds = GamePushCredentials.decode(serverBlob) else { return }
   2606 
   2607         let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
   2608         req.predicate = NSPredicate(format: "ckRecordName == %@", name)
   2609         req.fetchLimit = 1
   2610         guard let entity = try? ctx.fetch(req).first,
   2611               entity.notification != serverBlob
   2612         else { return }
   2613         let localGen = GamePushCredentials.decode(entity.notification)?.generation
   2614         guard localGen == nil || serverCreds.generation >= localGen! else { return }
   2615         entity.notification = serverBlob
   2616         // The adopted blob may carry a new content key; re-mirror the App Group
   2617         // directory so the NSE can decrypt pushes sealed under it.
   2618         GameEntity.rebuildContentKeyDirectory(in: ctx)
   2619     }
   2620 
   2621     private nonisolated func writeBackSystemFields(
   2622         record: CKRecord,
   2623         in ctx: NSManagedObjectContext
   2624     ) {
   2625         let name = record.recordID.recordName
   2626         let entityName: String
   2627         if name.hasPrefix("moves-") { entityName = "MovesEntity" }
   2628         else if name.hasPrefix("player-") { entityName = "PlayerEntity" }
   2629         else if name.hasPrefix("game-") { entityName = "GameEntity" }
   2630         else { return }
   2631 
   2632         let req = NSFetchRequest<NSManagedObject>(entityName: entityName)
   2633         req.predicate = NSPredicate(format: "ckRecordName == %@", name)
   2634         req.fetchLimit = 1
   2635         guard let obj = try? ctx.fetch(req).first else { return }
   2636         obj.setValue(RecordSerializer.encodeSystemFields(of: record), forKey: "ckSystemFields")
   2637         if entityName == "GameEntity" {
   2638             obj.setValue(Date(), forKey: "lastSyncedAt")
   2639         }
   2640     }
   2641 
   2642     /// Clears the `hasPendingSave` guard on `GameEntity` after a successful
   2643     /// push so future inbound fetches resume adopting server fields. Called
   2644     /// from `handleSentRecordZoneChanges` only on confirmed saves — *not* from
   2645     /// the oplock-recovery path, which only adopts a fresh etag for retry.
   2646     /// Also clears `hasPushPending`, the one-shot flag that forces the next
   2647     /// push to re-include the `puzzleSource` asset (used by the NYT re-upgrade
   2648     /// path to replace an already-uploaded puzzle).
   2649     private nonisolated func clearPendingSaveFlag(
   2650         for recordName: String,
   2651         in ctx: NSManagedObjectContext
   2652     ) {
   2653         let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
   2654         req.predicate = NSPredicate(format: "ckRecordName == %@", recordName)
   2655         req.fetchLimit = 1
   2656         guard let entity = try? ctx.fetch(req).first else { return }
   2657         entity.hasPendingSave = false
   2658         entity.hasPushPending = false
   2659     }
   2660 
   2661     private nonisolated func markJournalUploaded(
   2662         gameID: UUID,
   2663         in ctx: NSManagedObjectContext
   2664     ) {
   2665         let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
   2666         req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
   2667         req.fetchLimit = 1
   2668         guard let entity = try? ctx.fetch(req).first else { return }
   2669         entity.journalUploaded = true
   2670     }
   2671 
   2672     private func completedFetchExclusions(
   2673         isPrivate: Bool
   2674     ) async -> Set<CKRecordZone.ID> {
   2675         if isPrivate, let privateCompletedFetchExclusions {
   2676             return privateCompletedFetchExclusions
   2677         }
   2678         if !isPrivate, let sharedCompletedFetchExclusions {
   2679             return sharedCompletedFetchExclusions
   2680         }
   2681 
   2682         let database = isPrivate
   2683             ? container.privateCloudDatabase
   2684             : container.sharedCloudDatabase
   2685         do {
   2686             let zoneIDs = try await database.allRecordZones()
   2687                 .map(\.zoneID)
   2688                 .filter {
   2689                     RecordSerializer.gameID(fromGameRecordName: $0.zoneName) != nil
   2690                 }
   2691             let recordIDs = zoneIDs.map {
   2692                 CKRecord.ID(recordName: $0.zoneName, zoneID: $0)
   2693             }
   2694             let cutoff = Date().addingTimeInterval(-7 * 24 * 60 * 60)
   2695             var exclusions = Set<CKRecordZone.ID>()
   2696             for start in stride(from: 0, to: recordIDs.count, by: 200) {
   2697                 let end = min(start + 200, recordIDs.count)
   2698                 let results = try await database.records(
   2699                     for: Array(recordIDs[start..<end]),
   2700                     desiredKeys: ["completedAt"]
   2701                 )
   2702                 for (recordID, result) in results {
   2703                     guard let record = try? result.get(),
   2704                           let completedAt = record["completedAt"] as? Date,
   2705                           completedAt < cutoff
   2706                     else { continue }
   2707                     exclusions.insert(recordID.zoneID)
   2708                 }
   2709             }
   2710             if isPrivate {
   2711                 privateCompletedFetchExclusions = exclusions
   2712             } else {
   2713                 sharedCompletedFetchExclusions = exclusions
   2714             }
   2715             await trace(
   2716                 "\(isPrivate ? "private" : "shared") completed fetch exclusions: " +
   2717                 "\(exclusions.count) zone(s)"
   2718             )
   2719             return exclusions
   2720         } catch {
   2721             await trace(
   2722                 "\(isPrivate ? "private" : "shared") completed fetch exclusions failed: " +
   2723                 describe(error)
   2724             )
   2725             return []
   2726         }
   2727     }
   2728 
   2729 }
   2730 
   2731 // MARK: - CKSyncEngineDelegate
   2732 
   2733 extension SyncEngine: CKSyncEngineDelegate {
   2734     func nextFetchChangesOptions(
   2735         _ context: CKSyncEngine.FetchChangesContext,
   2736         syncEngine: CKSyncEngine
   2737     ) async -> CKSyncEngine.FetchChangesOptions {
   2738         var options = context.options
   2739         let isPrivate = scope(for: syncEngine) == .private
   2740         var excluded = await completedFetchExclusions(isPrivate: isPrivate)
   2741         if isPrivate { excluded.insert(Archive.zoneID) }
   2742 
   2743         switch options.scope {
   2744         case .all:
   2745             options.scope = .allExcluding(Array(excluded))
   2746         case .allExcluding(var zoneIDs):
   2747             let existing = Set(zoneIDs)
   2748             for zoneID in excluded where !existing.contains(zoneID) {
   2749                 zoneIDs.append(zoneID)
   2750             }
   2751             options.scope = .allExcluding(zoneIDs)
   2752         case .zoneIDs(let zoneIDs):
   2753             options.scope = .zoneIDs(zoneIDs.filter {
   2754                 !excluded.contains($0)
   2755             })
   2756         @unknown default:
   2757             options.scope = .allExcluding(Array(excluded))
   2758         }
   2759         return options
   2760     }
   2761 
   2762     func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) async {
   2763         let isPrivate = scope(for: syncEngine) == .private
   2764         switch event {
   2765         case .stateUpdate(let e):
   2766             await saveEngineState(e.stateSerialization, isPrivate: isPrivate)
   2767 
   2768         case .accountChange(let e):
   2769             await trace("account change: \(e.changeType)")
   2770             if let onAccountChange { await onAccountChange() }
   2771 
   2772         case .fetchedDatabaseChanges(let e):
   2773             await handleFetchedDatabaseChanges(e, isPrivate: isPrivate)
   2774 
   2775         case .fetchedRecordZoneChanges(let e):
   2776             await handleFetchedRecordZoneChanges(e, isPrivate: isPrivate)
   2777 
   2778         case .sentDatabaseChanges:
   2779             break
   2780 
   2781         case .sentRecordZoneChanges(let e):
   2782             await handleSentRecordZoneChanges(e, isPrivate: isPrivate)
   2783 
   2784         case .willFetchChanges, .didFetchChanges,
   2785              .willFetchRecordZoneChanges, .didFetchRecordZoneChanges,
   2786              .willSendChanges, .didSendChanges:
   2787             break
   2788 
   2789         @unknown default:
   2790             break
   2791         }
   2792     }
   2793 
   2794     func nextRecordZoneChangeBatch(
   2795         _ context: CKSyncEngine.SendChangesContext,
   2796         syncEngine: CKSyncEngine
   2797     ) async -> CKSyncEngine.RecordZoneChangeBatch? {
   2798         await makeRecordZoneChangeBatch(for: syncEngine)
   2799     }
   2800 
   2801     /// Builds the next outbound batch for `engine`, reaping any pending
   2802     /// `.saveRecord` whose record can no longer be reconstructed. For a ping
   2803     /// that means its durable outbox row was missing or unreadable; for
   2804     /// game/moves/player it means the Core Data entity was deleted. Either way
   2805     /// the save can never succeed, so the change is
   2806     /// dropped instead of returning a nil batch that leaves it queued forever
   2807     /// — `Pending Changes` would never drain and no error is ever surfaced.
   2808     /// Mirrors Apple's CKSyncEngine reference implementation, which reaps
   2809     /// un-buildable changes inside the record provider. Internal-rather-than-
   2810     /// private so the test suite can drive it directly; the delegate entry
   2811     /// point can't be exercised because `CKSyncEngine.SendChangesContext` has
   2812     /// no public initializer.
   2813     func makeRecordZoneChangeBatch(
   2814         for engine: CKSyncEngine
   2815     ) async -> CKSyncEngine.RecordZoneChangeBatch? {
   2816         let pending = engine.state.pendingRecordZoneChanges
   2817         guard !pending.isEmpty else { return nil }
   2818         await traceForeignPlayerWrites(in: pending)
   2819         let scope = scope(for: engine)
   2820         let pingSnapshot = pendingPings
   2821         let decisionSnapshot = pendingDecisionPayloads
   2822         let decisionVersionSnapshot = pendingDecisionVersions
   2823         let decisionSystemFieldsSnapshot = decisionSystemFields
   2824         let buildContext = persistence.container.newBackgroundContext()
   2825         return await CKSyncEngine.RecordZoneChangeBatch(pendingChanges: pending) { [weak self] recordID in
   2826             guard let self else { return nil }
   2827             if let record = self.buildRecord(
   2828                 for: recordID,
   2829                 in: buildContext,
   2830                 databaseScope: scope,
   2831                 pings: pingSnapshot,
   2832                 decisions: decisionSnapshot,
   2833                 decisionVersions: decisionVersionSnapshot,
   2834                 decisionSystemFields: decisionSystemFieldsSnapshot
   2835             ) {
   2836                 return record
   2837             }
   2838             engine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)])
   2839             return nil
   2840         }
   2841     }
   2842 
   2843     /// Diagnostic for the recurring "ghost peer": flags any *peer's* Player
   2844     /// slot in our outbound batch. A participant must only ever write its own
   2845     /// `(game, authorID)` record — `enqueuePlayer` is keyed to the local
   2846     /// author. A foreign authorID here means this device is about to upload
   2847     /// someone else's presence, which `RecordBuilder` stamps with our own
   2848     /// game-wide `lastReadOtherMoveAt` (`RecordBuilder.swift:131`). If that
   2849     /// horizon is a live read lease, the peer is resurrected as present with
   2850     /// our future `presenceUntil`. We log the value the build will send so a future
   2851     /// lease (the ghost) is distinguishable from a current-time close, and so
   2852     /// the next recurrence names the culprit in one line rather than leaving it
   2853     /// to inference. Silent in the normal case (own slot only), so it adds no
   2854     /// noise until the bug actually fires.
   2855     private func traceForeignPlayerWrites(
   2856         in pending: [CKSyncEngine.PendingRecordZoneChange]
   2857     ) async {
   2858         guard let localAuthorID = await currentLocalAuthorID() else { return }
   2859         var foreign: [(UUID, String)] = []
   2860         for change in pending {
   2861             guard case .saveRecord(let recordID) = change,
   2862                   let (gameID, authorID) =
   2863                     RecordSerializer.parsePlayerRecordName(recordID.recordName),
   2864                   authorID != localAuthorID
   2865             else { continue }
   2866             foreign.append((gameID, authorID))
   2867         }
   2868         guard !foreign.isEmpty else { return }
   2869         let ctx = persistence.container.newBackgroundContext()
   2870         for (gameID, authorID) in foreign {
   2871             let presenceUntil: Date? = await ctx.perform {
   2872                 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
   2873                 req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
   2874                 req.fetchLimit = 1
   2875                 return (try? ctx.fetch(req).first)?.lastReadOtherMoveAt
   2876             }
   2877             let leaseDesc: String
   2878             if let presenceUntil {
   2879                 let delta = Int(presenceUntil.timeIntervalSinceNow)
   2880                 leaseDesc = "presenceUntil=\(presenceUntil.ISO8601Format()) " +
   2881                     (delta > 0 ? "(future +\(delta)s)" : "(past \(-delta)s)")
   2882             } else {
   2883                 leaseDesc = "presenceUntil=nil"
   2884             }
   2885             await trace(
   2886                 "‼️ OUTBOUND peer Player[\(gameID.uuidString.prefix(8))] " +
   2887                 "author=\(authorID.prefix(8)) local=\(localAuthorID.prefix(8)) " +
   2888                 "\(leaseDesc) — uploading a peer's slot"
   2889             )
   2890         }
   2891     }
   2892 
   2893     /// Test seam: drives `makeRecordZoneChangeBatch` for the given scope's
   2894     /// engine. Mirrors `pendingSaveRecordNames(scope:)`'s scope routing.
   2895     func makeRecordZoneChangeBatch(
   2896         forTestingScope scope: CKDatabase.Scope
   2897     ) async -> CKSyncEngine.RecordZoneChangeBatch? {
   2898         let engine = scope == .shared ? sharedEngine : privateEngine
   2899         guard let engine else { return nil }
   2900         return await makeRecordZoneChangeBatch(for: engine)
   2901     }
   2902 
   2903     func pendingPingRecordNamesForTesting() -> [String] {
   2904         Array(pendingPings.keys).sorted()
   2905     }
   2906 
   2907     func confirmPendingPingForTesting(recordName: String) async {
   2908         await confirmPendingPing(recordName: recordName)
   2909     }
   2910 
   2911     func setPingConfirmationTimeoutForTesting(_ duration: Duration) {
   2912         pingConfirmationTimeout = duration
   2913     }
   2914 }