crossmate

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

Archive.swift (46053B)


      1 import CloudKit
      2 import Compression
      3 import CoreData
      4 import CryptoKit
      5 import Foundation
      6 
      7 /// Serialization + materialization for a finished game's compact private
      8 /// archive.
      9 ///
     10 /// When a participant (not the owner) finishes a shared game, that game's data
     11 /// lives only in the owner's shared zone; if the owner later deletes it, the
     12 /// participant keeps a local copy but has no CloudKit backing, so a new device
     13 /// or reinstall loses it. To close that gap every involved account writes a
     14 /// self-contained snapshot — final grid + the full multi-author move journal —
     15 /// into one common zone in *its own* private database. A finished game is immutable
     16 /// (`isCompleted` latches at completion), so the snapshot needs no
     17 /// reconciliation: it is written once and only ever read back to rebuild a
     18 /// standalone completed game on another device or after the original is revoked.
     19 ///
     20 /// The snapshot is deliberately *not* a clone of the live multi-record game.
     21 /// The live representation keys one Core Data entity to one `CKRecord` identity
     22 /// tied to the shared zone (`RecordBuilder`), and the journal-upload path only
     23 /// uploads *this device's own* rows — so it cannot reproduce the full
     24 /// multi-author journal replay needs. Instead everything is folded into a single
     25 /// `Chronicle` record carrying all metadata and game data in one compressed
     26 /// payload asset. Once every accepted participant acknowledges that snapshot
     27 /// (or the retention deadline expires), the owner can delete the much larger
     28 /// live per-game zone without deleting the compact archive.
     29 enum Archive {
     30     /// The compact v1.1.0 record. It deliberately has a new CloudKit type so
     31     /// its schema contains only the single payload Asset.
     32     static let recordType = "Chronicle"
     33     /// Three-asset records written before v1.1.0.
     34     static let legacyRecordType = "Archive"
     35     static let zoneName = "completed-archives"
     36     static let payloadKey = "payload"
     37 
     38     /// The keys any fetch must ask for to decode a Chronicle. `completedAt` is
     39     /// not optional extra metadata: `payload(from:)` cross-checks it against the
     40     /// blob's own completion time, and a record fetched without it fails that
     41     /// identity guard exactly as a tampered one would.
     42     static let payloadDesiredKeys = ["completedAt", payloadKey]
     43 
     44     // MARK: - Inbound asset bounds
     45 
     46     /// Byte cap on the `cells` asset, checked on disk before it is read. The
     47     /// largest admissible grid (`XD.maxGridDimension`²) at ~120 bytes of JSON
     48     /// per cell is under 2 MiB; real puzzles are a few kilobytes.
     49     static let maxCellsAssetBytes = 2_097_152
     50 
     51     /// Byte cap on the merged `journals` asset, checked on disk before it is
     52     /// read. Wraps per-device `JournalCodec` blobs (each independently capped
     53     /// at `JournalCodec.maxAssetBytes`) in base64; a real finished game's
     54     /// merged log is a few hundred kilobytes, so 8 MiB rejects nothing
     55     /// genuine.
     56     static let maxJournalsAssetBytes = 8_388_608
     57 
     58     /// Bounds both the compressed asset read and the allocation used to
     59     /// decompress its versioned payload. The payload contains the three legacy
     60     /// assets plus a small amount of metadata; this leaves ample headroom while
     61     /// preventing a hostile envelope from requesting an arbitrary allocation.
     62     static let maxPayloadAssetBytes = 12_582_912
     63     static let maxDecodedPayloadBytes = 16_777_216
     64     static let currentPayloadFormatVersion = 3
     65     private static let maxParticipantCount = 64
     66 
     67     /// Upper bound on decoded final-grid cells; `XD.maxGridDimension`² is the
     68     /// largest cell count any admissible puzzle can produce.
     69     static let maxCellCount = XD.maxGridDimension * XD.maxGridDimension
     70 
     71     /// Upper bound on per-device journals in one archive. Every participant
     72     /// device that wrote grid state contributes one; real games have a
     73     /// handful.
     74     static let maxJournalDeviceCount = 64
     75 
     76     /// Namespace for deriving the archive's game id. A fixed random UUID used as
     77     /// the v5 namespace so `archiveGameID(for:)` is stable across the
     78     /// participant's own devices yet distinct from the original game id.
     79     private static let namespace = UUID(uuidString: "1F8B0E2A-3C4D-5E6F-7A8B-9C0D1E2F3A4B")!
     80 
     81     // MARK: - Identity
     82 
     83     /// The deterministic game id of the archived copy. Derived from the original
     84     /// game id so every one of the participant's devices computes the same value
     85     /// (idempotent re-writes, last-writer-wins on a frozen record) while staying
     86     /// distinct from `originalGameID` — the authoring device still holds the live
     87     /// original under that id, and Core Data fetches it by `id`.
     88     static func archiveGameID(for originalGameID: UUID) -> UUID {
     89         var hasher = Insecure.SHA1()
     90         hasher.update(data: withUnsafeBytes(of: namespace.uuid) { Data($0) })
     91         hasher.update(data: withUnsafeBytes(of: originalGameID.uuid) { Data($0) })
     92         let digest = Array(hasher.finalize())
     93         var bytes = Array(digest.prefix(16))
     94         // Stamp version (5) and RFC 4122 variant bits, like a real v5 UUID.
     95         bytes[6] = (bytes[6] & 0x0F) | 0x50
     96         bytes[8] = (bytes[8] & 0x3F) | 0x80
     97         let uuid = (
     98             bytes[0], bytes[1], bytes[2], bytes[3],
     99             bytes[4], bytes[5], bytes[6], bytes[7],
    100             bytes[8], bytes[9], bytes[10], bytes[11],
    101             bytes[12], bytes[13], bytes[14], bytes[15]
    102         )
    103         return UUID(uuid: uuid)
    104     }
    105 
    106     static var zoneID: CKRecordZone.ID {
    107         CKRecordZone.ID(
    108             zoneName: zoneName,
    109             ownerName: CKCurrentUserDefaultName
    110         )
    111     }
    112 
    113     /// The per-game zone used by archives written before v1.1.0. Kept solely
    114     /// for backward-compatible reads and one-way migration into `zoneID`.
    115     static func legacyZoneID(forOriginalGameID gameID: UUID) -> CKRecordZone.ID {
    116         CKRecordZone.ID(
    117             zoneName: "archive-\(gameID.uuidString)",
    118             ownerName: CKCurrentUserDefaultName
    119         )
    120     }
    121 
    122     static func recordName(forOriginalGameID gameID: UUID) -> String {
    123         "chronicle-\(gameID.uuidString)"
    124     }
    125 
    126     static func legacyRecordName(forOriginalGameID gameID: UUID) -> String {
    127         "archive-\(gameID.uuidString)"
    128     }
    129 
    130     /// The original game id encoded in either generation's record name, or
    131     /// `nil` if the name doesn't match.
    132     static func originalGameID(fromName name: String) -> UUID? {
    133         for prefix in ["chronicle-", "archive-"] where name.hasPrefix(prefix) {
    134             return UUID(uuidString: String(name.dropFirst(prefix.count)))
    135         }
    136         return nil
    137     }
    138 
    139     /// Copies the account's mutable unread state from a completed live Game
    140     /// onto its local Chronicle projection. The Chronicle payload itself stays
    141     /// immutable; these fields are device-local projections of the canonical
    142     /// live-game state so the visible Completed tile survives the handoff from
    143     /// the hidden Game row.
    144     static func mirrorReadState(from live: GameEntity, to chronicle: GameEntity) {
    145         if let latest = live.latestOtherMoveAt,
    146            (chronicle.latestOtherMoveAt ?? .distantPast) < latest {
    147             chronicle.latestOtherMoveAt = latest
    148         }
    149         if let readThrough = live.readThroughAt,
    150            (chronicle.readThroughAt ?? .distantPast) < readThrough {
    151             chronicle.readThroughAt = readThrough
    152         }
    153     }
    154 
    155     static func isArchiveZone(_ zoneName: String) -> Bool {
    156         zoneName == self.zoneName || zoneName.hasPrefix("archive-")
    157     }
    158 
    159     // MARK: - Final-grid wire format
    160 
    161     /// The final state of one cell, captured so the materialized game renders
    162     /// (and its library thumbnail fills) without replaying the journal.
    163     struct Cell: Codable, Equatable {
    164         let row: Int16
    165         let col: Int16
    166         let letter: String
    167         let markCode: Int16
    168         let letterAuthorID: String?
    169     }
    170 
    171     /// A frozen roster entry carried inside the compressed Chronicle payload.
    172     /// Names remain optional because v1 Chronicles can recover author IDs from
    173     /// their journals but cannot reconstruct names after the live zone is gone.
    174     struct Participant: Codable, Equatable {
    175         let authorID: String
    176         let name: String?
    177     }
    178 
    179     private static func encodeCells(_ cells: [Cell]) throws -> Data {
    180         try JSONEncoder().encode(cells.sorted {
    181             ($0.row, $0.col) < ($1.row, $1.col)
    182         })
    183     }
    184 
    185     /// A decoded archive asset that exceeds its entry-count bound. The asset
    186     /// is rejected whole — a truncated grid or journal set would materialize a
    187     /// silently incomplete game.
    188     enum LimitError: Error, CustomStringConvertible {
    189         case tooManyCells(count: Int)
    190         case tooManyDeviceJournals(count: Int)
    191         case tooManyParticipants(count: Int)
    192 
    193         var description: String {
    194             switch self {
    195             case .tooManyCells(let count):
    196                 return "cells asset exceeds \(maxCellCount) cells (\(count))"
    197             case .tooManyDeviceJournals(let count):
    198                 return "journals asset exceeds \(maxJournalDeviceCount) device journals (\(count))"
    199             case .tooManyParticipants(let count):
    200                 return "archive payload exceeds \(maxParticipantCount) participants (\(count))"
    201             }
    202         }
    203     }
    204 
    205     private static func decodeCells(_ data: Data) throws -> [Cell] {
    206         let cells = try JSONDecoder().decode([Cell].self, from: data)
    207         guard cells.count <= maxCellCount else {
    208             throw LimitError.tooManyCells(count: cells.count)
    209         }
    210         return cells
    211     }
    212 
    213     // MARK: - Per-device journal wire format
    214 
    215     /// One device's log on the wire: its `(authorID, deviceID)` key plus the same
    216     /// `JournalCodec` payload the live `Journal` records use, so encoding fidelity
    217     /// matches replay exactly.
    218     private struct DeviceJournalWire: Codable {
    219         let authorID: String
    220         let deviceID: String
    221         let entries: Data
    222     }
    223 
    224     /// The complete archive body. It is encoded as JSON only as an internal
    225     /// representation, then wrapped in an authenticated, bounded LZFSE envelope
    226     /// and stored as one CKAsset. No field needs to remain queryable in CloudKit:
    227     /// record identity carries the original game ID and Crossmate materializes
    228     /// the complete payload before displaying it.
    229     private struct Blob: Codable {
    230         let formatVersion: Int
    231         let originalGameID: UUID
    232         let archiveGameID: UUID
    233         let title: String
    234         let puzzleSource: String
    235         let completedAt: Date
    236         let completedBy: String?
    237         let solveSeconds: Int
    238         let replayAvailable: Bool
    239         /// Added in format 3. A positive value distinguishes a provisional
    240         /// Chronicle from the terminal no-replay retention fallback.
    241         let replayMissingDeviceCount: Int?
    242         let cells: [Cell]
    243         let journals: [DeviceJournalWire]
    244         /// Added in format 2. Optional so already-written format-1 payloads
    245         /// continue to decode and can infer contributors from their journals.
    246         let wasShared: Bool?
    247         let participants: [Participant]?
    248     }
    249 
    250     private static let envelopeMagic = Data("CMARCH01".utf8)
    251     private static let envelopeHeaderBytes = 8 + MemoryLayout<UInt64>.size + 32
    252 
    253     enum PayloadError: Error, CustomStringConvertible {
    254         case oversizedCompressedPayload(bytes: Int)
    255         case oversizedDecodedPayload(bytes: Int)
    256         case malformedEnvelope
    257         case unsupportedFormat(Int)
    258         case decompressionFailed
    259         case digestMismatch
    260         case identityMismatch
    261         case oversizedPuzzleSource(bytes: Int)
    262 
    263         var description: String {
    264             switch self {
    265             case .oversizedCompressedPayload(let bytes):
    266                 return "archive payload exceeds \(maxPayloadAssetBytes) compressed bytes (\(bytes))"
    267             case .oversizedDecodedPayload(let bytes):
    268                 return "archive payload exceeds \(maxDecodedPayloadBytes) decoded bytes (\(bytes))"
    269             case .malformedEnvelope:
    270                 return "archive payload envelope is malformed"
    271             case .unsupportedFormat(let version):
    272                 return "archive payload format \(version) is unsupported"
    273             case .decompressionFailed:
    274                 return "archive payload decompression failed"
    275             case .digestMismatch:
    276                 return "archive payload digest does not match"
    277             case .identityMismatch:
    278                 return "archive payload identity does not match its record"
    279             case .oversizedPuzzleSource(let bytes):
    280                 return "archive puzzle source exceeds \(XD.maxSourceBytes) bytes (\(bytes))"
    281             }
    282         }
    283     }
    284 
    285     private static func encodeEnvelope(_ decoded: Data) throws -> Data {
    286         guard decoded.count <= maxDecodedPayloadBytes else {
    287             throw PayloadError.oversizedDecodedPayload(bytes: decoded.count)
    288         }
    289         let compressed = try (decoded as NSData).compressed(using: .lzfse) as Data
    290         var result = Data()
    291         result.reserveCapacity(envelopeHeaderBytes + compressed.count)
    292         result.append(envelopeMagic)
    293         var length = UInt64(decoded.count).bigEndian
    294         withUnsafeBytes(of: &length) { result.append(contentsOf: $0) }
    295         result.append(contentsOf: SHA256.hash(data: decoded))
    296         result.append(compressed)
    297         guard result.count <= maxPayloadAssetBytes else {
    298             throw PayloadError.oversizedCompressedPayload(bytes: result.count)
    299         }
    300         return result
    301     }
    302 
    303     private static func decodeEnvelope(_ envelope: Data) throws -> Data {
    304         guard envelope.count >= envelopeHeaderBytes,
    305               envelope.prefix(envelopeMagic.count) == envelopeMagic
    306         else { throw PayloadError.malformedEnvelope }
    307 
    308         let lengthRange = envelopeMagic.count..<(envelopeMagic.count + MemoryLayout<UInt64>.size)
    309         let decodedLength = envelope[lengthRange].reduce(UInt64(0)) { ($0 << 8) | UInt64($1) }
    310         guard decodedLength <= UInt64(maxDecodedPayloadBytes),
    311               let decodedCount = Int(exactly: decodedLength),
    312               decodedCount > 0
    313         else {
    314             throw PayloadError.oversizedDecodedPayload(bytes: Int(clamping: decodedLength))
    315         }
    316 
    317         let digestStart = lengthRange.upperBound
    318         let digestEnd = digestStart + 32
    319         let expectedDigest = envelope[digestStart..<digestEnd]
    320         let compressed = envelope[digestEnd...]
    321         guard !compressed.isEmpty else { throw PayloadError.malformedEnvelope }
    322         var decoded = Data(count: decodedCount)
    323         let written = decoded.withUnsafeMutableBytes { destination in
    324             compressed.withUnsafeBytes { source in
    325                 compression_decode_buffer(
    326                     destination.bindMemory(to: UInt8.self).baseAddress!,
    327                     decodedCount,
    328                     source.bindMemory(to: UInt8.self).baseAddress!,
    329                     compressed.count,
    330                     nil,
    331                     COMPRESSION_LZFSE
    332                 )
    333             }
    334         }
    335         guard written == decodedCount else { throw PayloadError.decompressionFailed }
    336         guard Data(SHA256.hash(data: decoded)) == expectedDigest else {
    337             throw PayloadError.digestMismatch
    338         }
    339         return decoded
    340     }
    341 
    342     private static func encodeJournals(_ journals: [DeviceJournal]) throws -> Data {
    343         try JSONEncoder().encode(journalWire(journals))
    344     }
    345 
    346     private static func journalWire(_ journals: [DeviceJournal]) throws -> [DeviceJournalWire] {
    347         try journals
    348             .sorted { ($0.key.authorID, $0.key.deviceID) < ($1.key.authorID, $1.key.deviceID) }
    349             .map {
    350                 DeviceJournalWire(
    351                     authorID: $0.key.authorID,
    352                     deviceID: $0.key.deviceID,
    353                     entries: try JournalCodec.encode($0.entries)
    354                 )
    355             }
    356     }
    357 
    358     private static func decodeJournals(_ data: Data) throws -> [DeviceJournal] {
    359         let wire = try JSONDecoder().decode([DeviceJournalWire].self, from: data)
    360         return try decodeJournals(wire)
    361     }
    362 
    363     private static func decodeJournals(_ wire: [DeviceJournalWire]) throws -> [DeviceJournal] {
    364         guard wire.count <= maxJournalDeviceCount else {
    365             throw LimitError.tooManyDeviceJournals(count: wire.count)
    366         }
    367         return wire.map {
    368             DeviceJournal(
    369                 key: JournalDeviceKey(authorID: $0.authorID, deviceID: $0.deviceID),
    370                 // `JournalCodec.decode` enforces its own byte/entry bounds, so
    371                 // one device's over-limit blob degrades to an empty log rather
    372                 // than sinking the whole archive.
    373                 entries: (try? JournalCodec.decode($0.entries)) ?? []
    374             )
    375         }
    376     }
    377 
    378     // MARK: - Snapshot taken from local Core Data
    379 
    380     /// Everything needed to build (or rebuild) the archive record, read off the
    381     /// local game on a background context at archive time.
    382     struct Snapshot {
    383         let originalGameID: UUID
    384         let title: String
    385         let puzzleSource: String
    386         let completedAt: Date
    387         let completedBy: String?
    388         /// The frozen solve-clock value in whole seconds (active solving time, the
    389         /// union across all players) at the moment the game finished. Captured
    390         /// here because the per-player `Player.timeLog` records it lives on do not
    391         /// survive into the archive, so the materialised game would otherwise read
    392         /// zero. Whole seconds — the clock is only ever shown at second
    393         /// resolution.
    394         let solveSeconds: Int
    395         let wasShared: Bool
    396         let participants: [Participant]
    397         let cells: [Cell]
    398         /// The full move log, kept *per contributing device* (not flattened) so
    399         /// the materialized game replays exactly as the live one does: the replay
    400         /// assembler merges one log per device and gates on every expected device
    401         /// being present. See `GameArchiver` for how peers' logs are gathered.
    402         let journal: [DeviceJournal]
    403 
    404         init(
    405             originalGameID: UUID,
    406             title: String,
    407             puzzleSource: String,
    408             completedAt: Date,
    409             completedBy: String?,
    410             solveSeconds: Int,
    411             wasShared: Bool = false,
    412             participants: [Participant] = [],
    413             cells: [Cell],
    414             journal: [DeviceJournal]
    415         ) {
    416             self.originalGameID = originalGameID
    417             self.title = title
    418             self.puzzleSource = puzzleSource
    419             self.completedAt = completedAt
    420             self.completedBy = completedBy
    421             self.solveSeconds = solveSeconds
    422             self.wasShared = wasShared
    423             self.participants = participants
    424             self.cells = cells
    425             self.journal = journal
    426         }
    427     }
    428 
    429     /// Reads the local game's finished state, with the journal grouped by
    430     /// contributing device. Returns `nil` if the game is not a completed game or
    431     /// required fields are missing. The journal here is *local only* — this
    432     /// device's own log plus any peer logs already cached for replay;
    433     /// `GameArchiver` augments it with a `fetchReplay` of the shared zone while it
    434     /// is still reachable.
    435     static func snapshot(
    436         forGameID gameID: UUID,
    437         originalGameID: UUID? = nil,
    438         in ctx: NSManagedObjectContext
    439     ) -> Snapshot? {
    440         let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    441         req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
    442         req.fetchLimit = 1
    443         guard let entity = try? ctx.fetch(req).first,
    444               let completedAt = entity.completedAt,
    445               let source = entity.puzzleSource, !source.isEmpty
    446         else { return nil }
    447 
    448         let cellEntities = (entity.cells as? Set<CellEntity>) ?? []
    449         let cells = cellEntities.map {
    450             Cell(
    451                 row: $0.row,
    452                 col: $0.col,
    453                 letter: $0.letter ?? "",
    454                 markCode: $0.markCode,
    455                 letterAuthorID: $0.letterAuthorID
    456             )
    457         }
    458 
    459         let journal = localDeviceJournals(forGameID: gameID, in: ctx)
    460         var participantsByAuthor: [String: Participant] = [:]
    461         let playerReq = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
    462         playerReq.predicate = NSPredicate(format: "game == %@", entity)
    463         for player in (try? ctx.fetch(playerReq)) ?? [] {
    464             guard let authorID = player.authorID, !authorID.isEmpty else { continue }
    465             let trimmedName = player.name?.trimmingCharacters(
    466                 in: .whitespacesAndNewlines
    467             )
    468             participantsByAuthor[authorID] = Participant(
    469                 authorID: authorID,
    470                 name: trimmedName?.isEmpty == false ? trimmedName : nil
    471             )
    472         }
    473         for encoded in [entity.archiveParticipants, entity.shareParticipants] {
    474             for authorID in encoded?.split(separator: ",").map(String.init) ?? [] {
    475                 guard !authorID.isEmpty else { continue }
    476                 participantsByAuthor[authorID] = participantsByAuthor[authorID]
    477                     ?? Participant(authorID: authorID, name: nil)
    478             }
    479         }
    480         for deviceJournal in journal where !deviceJournal.key.authorID.isEmpty {
    481             let authorID = deviceJournal.key.authorID
    482             participantsByAuthor[authorID] = participantsByAuthor[authorID]
    483                 ?? Participant(authorID: authorID, name: nil)
    484         }
    485 
    486         return Snapshot(
    487             originalGameID: originalGameID ?? gameID,
    488             title: entity.title ?? "",
    489             puzzleSource: source,
    490             completedAt: completedAt,
    491             completedBy: entity.completedBy,
    492             solveSeconds: solveSeconds(forGameID: gameID, asOf: completedAt, in: ctx),
    493             wasShared: entity.ckShareRecordName != nil || entity.databaseScope == 1,
    494             participants: participantsByAuthor.values.sorted {
    495                 $0.authorID < $1.authorID
    496             },
    497             cells: cells,
    498             journal: journal
    499         )
    500     }
    501 
    502     /// The union of every player's solve-clock intervals for `gameID`, frozen at
    503     /// `asOf` (the completion instant). Mirrors `PlayerRoster.solveTime` but reads
    504     /// from the supplied background context for the archive snapshot.
    505     private static func solveSeconds(
    506         forGameID gameID: UUID,
    507         asOf: Date,
    508         in ctx: NSManagedObjectContext
    509     ) -> Int {
    510         let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity")
    511         req.predicate = NSPredicate(format: "game.id == %@", gameID as CVarArg)
    512         let logs = ((try? ctx.fetch(req)) ?? []).map { TimeLog.decode($0.timeLog) }
    513         return Int(TimeLog.accumulatedSeconds(
    514             forLogs: logs,
    515             localDeviceID: RecordSerializer.localDeviceID,
    516             asOf: asOf
    517         ))
    518     }
    519 
    520     /// Groups the local `JournalEntity` rows for a game into per-device logs.
    521     /// Own rows (`sourceDeviceID == nil`) form one log keyed to this device; peer
    522     /// rows cached for replay carry their own source key.
    523     static func localDeviceJournals(
    524         forGameID gameID: UUID,
    525         in ctx: NSManagedObjectContext
    526     ) -> [DeviceJournal] {
    527         let req = NSFetchRequest<JournalEntity>(entityName: "JournalEntity")
    528         req.predicate = NSPredicate(format: "gameID == %@", gameID as CVarArg)
    529         req.sortDescriptors = [NSSortDescriptor(key: "seq", ascending: true)]
    530         let rows = (try? ctx.fetch(req)) ?? []
    531 
    532         var byKey: [JournalDeviceKey: [JournalValue]] = [:]
    533         for row in rows {
    534             let key: JournalDeviceKey
    535             if let device = row.sourceDeviceID {
    536                 key = JournalDeviceKey(authorID: row.sourceAuthorID ?? "", deviceID: device)
    537             } else {
    538                 // This device's own log: keyed to the local device, authored by
    539                 // whoever typed it (consistently the local user).
    540                 key = JournalDeviceKey(
    541                     authorID: row.actingAuthorID ?? "",
    542                     deviceID: RecordSerializer.localDeviceID
    543                 )
    544             }
    545             byKey[key, default: []].append(MovesJournal.value(from: row))
    546         }
    547         return byKey.map { DeviceJournal(key: $0.key, entries: $0.value) }
    548     }
    549 
    550     /// Merges peer logs (e.g. from a `fetchReplay`) into a snapshot's journal,
    551     /// keeping the local copy of any device already present (it is the
    552     /// authoritative, possibly-fresher log for this device).
    553     static func merging(
    554         _ snapshot: Snapshot,
    555         peerJournals: [DeviceJournal]
    556     ) -> Snapshot {
    557         var byKey: [JournalDeviceKey: [JournalValue]] = [:]
    558         for journal in peerJournals { byKey[journal.key] = journal.entries }
    559         for journal in snapshot.journal { byKey[journal.key] = journal.entries }
    560         return Snapshot(
    561             originalGameID: snapshot.originalGameID,
    562             title: snapshot.title,
    563             puzzleSource: snapshot.puzzleSource,
    564             completedAt: snapshot.completedAt,
    565             completedBy: snapshot.completedBy,
    566             solveSeconds: snapshot.solveSeconds,
    567             wasShared: snapshot.wasShared,
    568             participants: snapshot.participants,
    569             cells: snapshot.cells,
    570             journal: byKey.map { DeviceJournal(key: $0.key, entries: $0.value) }
    571         )
    572     }
    573 
    574     // MARK: - Record building
    575 
    576     struct RecordPackage {
    577         let record: CKRecord
    578         let temporaryAssetFileURLs: [URL]
    579     }
    580 
    581     static func recordPackage(
    582         from snapshot: Snapshot,
    583         replayState: ReplayState = .available,
    584         formatVersion: Int = currentPayloadFormatVersion
    585     ) throws -> RecordPackage {
    586         let zone = zoneID
    587         let recordID = CKRecord.ID(
    588             recordName: recordName(forOriginalGameID: snapshot.originalGameID),
    589             zoneID: zone
    590         )
    591         let record = CKRecord(recordType: recordType, recordID: recordID)
    592 
    593         let replayAvailable = replayState == .available
    594         let blob = Blob(
    595             formatVersion: formatVersion,
    596             originalGameID: snapshot.originalGameID,
    597             archiveGameID: archiveGameID(for: snapshot.originalGameID),
    598             title: snapshot.title,
    599             puzzleSource: snapshot.puzzleSource,
    600             completedAt: snapshot.completedAt,
    601             completedBy: snapshot.completedBy,
    602             solveSeconds: snapshot.solveSeconds,
    603             replayAvailable: replayAvailable,
    604             replayMissingDeviceCount: {
    605                 guard formatVersion >= 3,
    606                       case .waiting(let missing) = replayState
    607                 else { return nil }
    608                 return missing
    609             }(),
    610             cells: snapshot.cells.sorted { ($0.row, $0.col) < ($1.row, $1.col) },
    611             journals: replayAvailable ? try journalWire(snapshot.journal) : [],
    612             wasShared: formatVersion >= 2 ? snapshot.wasShared : nil,
    613             participants: formatVersion >= 2 ? snapshot.participants : nil
    614         )
    615         let encoded = try JSONEncoder().encode(blob)
    616         let payload = try asset(for: encodeEnvelope(encoded), ext: "cmarchive")
    617         record["completedAt"] = snapshot.completedAt
    618         record[payloadKey] = payload.asset
    619         return RecordPackage(
    620             record: record,
    621             temporaryAssetFileURLs: [payload.url]
    622         )
    623     }
    624 
    625     private static func asset(for data: Data, ext: String) throws -> (asset: CKAsset, url: URL) {
    626         let url = FileManager.default.temporaryDirectory
    627             .appendingPathComponent(UUID().uuidString)
    628             .appendingPathExtension(ext)
    629         try data.write(to: url, options: .atomic)
    630         return (CKAsset(fileURL: url), url)
    631     }
    632 
    633     // MARK: - Materialization
    634 
    635     /// The decoded payload of an inbound `Archive` record.
    636     struct Payload {
    637         let formatVersion: Int
    638         let originalGameID: UUID
    639         let archiveGameID: UUID
    640         let title: String
    641         let puzzleSource: String
    642         let completedAt: Date
    643         let completedBy: String?
    644         /// The frozen solve time in whole seconds, or `nil` for archives written
    645         /// before the field existed (their materialised game simply shows no time).
    646         let solveSeconds: Int?
    647         let replayState: ReplayState
    648         var replayAvailable: Bool { replayState == .available }
    649         let wasShared: Bool
    650         let participants: [Participant]
    651         let cells: [Cell]
    652         let journal: [DeviceJournal]
    653     }
    654 
    655     enum ReplayState: Equatable {
    656         case available
    657         case waiting(missing: Int)
    658         case unavailable
    659     }
    660 
    661     /// Builds the materialization payload directly from a local snapshot,
    662     /// without round-tripping through CloudKit. Used to promote the archive on
    663     /// revocation while still offline — the local game data is fully present, so
    664     /// the cloud copy need not have landed back.
    665     static func payload(
    666         from snapshot: Snapshot,
    667         replayState: ReplayState = .available
    668     ) -> Payload {
    669         let replayAvailable = replayState == .available
    670         return Payload(
    671             formatVersion: currentPayloadFormatVersion,
    672             originalGameID: snapshot.originalGameID,
    673             archiveGameID: archiveGameID(for: snapshot.originalGameID),
    674             title: snapshot.title,
    675             puzzleSource: snapshot.puzzleSource,
    676             completedAt: snapshot.completedAt,
    677             completedBy: snapshot.completedBy,
    678             solveSeconds: snapshot.solveSeconds,
    679             replayState: replayState,
    680             wasShared: snapshot.wasShared,
    681             participants: snapshot.participants,
    682             cells: snapshot.cells,
    683             journal: replayAvailable ? snapshot.journal : []
    684         )
    685     }
    686 
    687     static func payload(
    688         from record: CKRecord,
    689         onDiagnostic: ((String) -> Void)? = nil
    690     ) -> Payload? {
    691         switch record.recordType {
    692         case recordType:
    693             return blobPayload(from: record, onDiagnostic: onDiagnostic)
    694         case legacyRecordType:
    695             return legacyPayload(from: record, onDiagnostic: onDiagnostic)
    696         default:
    697             return nil
    698         }
    699     }
    700 
    701     private static func blobPayload(
    702         from record: CKRecord,
    703         onDiagnostic: ((String) -> Void)?
    704     ) -> Payload? {
    705         guard record.recordType == recordType,
    706               let recordOriginalID = originalGameID(fromName: record.recordID.recordName),
    707               record.recordID.recordName == recordName(
    708                 forOriginalGameID: recordOriginalID
    709               ),
    710               let asset = record[payloadKey] as? CKAsset,
    711               let url = asset.fileURL
    712         else { return nil }
    713         do {
    714             let envelope = try RecordSerializer.boundedAssetData(
    715                 at: url,
    716                 limit: maxPayloadAssetBytes
    717             )
    718             let decoded = try decodeEnvelope(envelope)
    719             let blob = try JSONDecoder().decode(Blob.self, from: decoded)
    720             guard (1...currentPayloadFormatVersion).contains(blob.formatVersion) else {
    721                 throw PayloadError.unsupportedFormat(blob.formatVersion)
    722             }
    723             guard let recordCompletedAt = record["completedAt"] as? Date else {
    724                 throw PayloadError.identityMismatch
    725             }
    726             let completionMetadataMatches = abs(
    727                 blob.completedAt.timeIntervalSince(recordCompletedAt)
    728             ) < 0.001
    729             guard blob.originalGameID == recordOriginalID,
    730                   blob.archiveGameID == archiveGameID(for: recordOriginalID),
    731                   completionMetadataMatches
    732             else { throw PayloadError.identityMismatch }
    733             let sourceBytes = blob.puzzleSource.utf8.count
    734             guard sourceBytes <= XD.maxSourceBytes else {
    735                 throw PayloadError.oversizedPuzzleSource(bytes: sourceBytes)
    736             }
    737             guard blob.cells.count <= maxCellCount else {
    738                 throw LimitError.tooManyCells(count: blob.cells.count)
    739             }
    740             let journals = blob.replayAvailable ? try decodeJournals(blob.journals) : []
    741             let replayState: ReplayState
    742             if blob.replayAvailable {
    743                 replayState = .available
    744             } else if let missing = blob.replayMissingDeviceCount,
    745                       (1...maxJournalDeviceCount).contains(missing) {
    746                 replayState = .waiting(missing: missing)
    747             } else if blob.replayMissingDeviceCount != nil {
    748                 // An out-of-range count is a corrupt or hostile payload, but the
    749                 // rest of the Chronicle is still verified and playable. Degrade
    750                 // to the terminal no-replay state rather than rejecting the
    751                 // whole archive over a count we only use to word a progress
    752                 // message.
    753                 replayState = .unavailable
    754             } else {
    755                 replayState = .unavailable
    756             }
    757             let participants = try validatedParticipants(
    758                 blob.participants ?? inferredParticipants(
    759                     journals: journals,
    760                     cells: blob.cells,
    761                     completedBy: blob.completedBy
    762                 )
    763             )
    764             return Payload(
    765                 formatVersion: blob.formatVersion,
    766                 originalGameID: blob.originalGameID,
    767                 archiveGameID: blob.archiveGameID,
    768                 title: blob.title,
    769                 puzzleSource: blob.puzzleSource,
    770                 completedAt: blob.completedAt,
    771                 completedBy: blob.completedBy,
    772                 solveSeconds: blob.solveSeconds,
    773                 replayState: replayState,
    774                 wasShared: blob.wasShared
    775                     ?? (Set(participants.map(\.authorID)).count > 1),
    776                 participants: participants,
    777                 cells: blob.cells,
    778                 journal: journals
    779             )
    780         } catch {
    781             onDiagnostic?("archive payload rejected for \(record.recordID.recordName): \(error)")
    782             return nil
    783         }
    784     }
    785 
    786     private static func legacyPayload(
    787         from record: CKRecord,
    788         onDiagnostic: ((String) -> Void)?
    789     ) -> Payload? {
    790         guard record.recordType == legacyRecordType,
    791               let originalString = record["originalGameID"] as? String,
    792               let originalGameID = UUID(uuidString: originalString),
    793               let archiveString = record["archiveGameID"] as? String,
    794               let archiveGameID = UUID(uuidString: archiveString),
    795               let completedAt = record["completedAt"] as? Date,
    796               record.recordID.recordName == legacyRecordName(
    797                 forOriginalGameID: originalGameID
    798               ),
    799               archiveGameID == self.archiveGameID(for: originalGameID)
    800         else { return nil }
    801 
    802         // Each asset is size-gated on disk before it is read, then count-gated
    803         // on decode. A rejected asset degrades to the same empty default as a
    804         // missing one: `materialize` refuses an empty `puzzleSource`, so a
    805         // hostile blob can't smuggle an unbounded read in through the archive
    806         // path, while a legitimate record with one bad asset still fails soft.
    807         func decoded<T>(
    808             _ key: String,
    809             limit: Int,
    810             _ decode: (Data) throws -> T
    811         ) -> T? {
    812             guard let asset = record[key] as? CKAsset, let url = asset.fileURL
    813             else { return nil }
    814             do {
    815                 let data = try RecordSerializer.boundedAssetData(at: url, limit: limit)
    816                 return try decode(data)
    817             } catch {
    818                 onDiagnostic?(
    819                     "archive \(key) rejected for " +
    820                     "\(record.recordID.recordName): \(error)"
    821                 )
    822                 return nil
    823             }
    824         }
    825 
    826         let puzzleSource = decoded("puzzleSource", limit: XD.maxSourceBytes) {
    827             String(data: $0, encoding: .utf8) ?? ""
    828         } ?? ""
    829         let cells = decoded("cells", limit: maxCellsAssetBytes, decodeCells) ?? []
    830         let journal = decoded("journals", limit: maxJournalsAssetBytes, decodeJournals) ?? []
    831         let participants: [Participant]
    832         do {
    833             participants = try validatedParticipants(
    834                 inferredParticipants(
    835                     journals: journal,
    836                     cells: cells,
    837                     completedBy: record["completedBy"] as? String
    838                 )
    839             )
    840         } catch {
    841             onDiagnostic?(
    842                 "archive participants rejected for " +
    843                 "\(record.recordID.recordName): \(error)"
    844             )
    845             return nil
    846         }
    847 
    848         return Payload(
    849             formatVersion: 0,
    850             originalGameID: originalGameID,
    851             archiveGameID: archiveGameID,
    852             title: record["title"] as? String ?? "",
    853             puzzleSource: puzzleSource,
    854             completedAt: completedAt,
    855             completedBy: record["completedBy"] as? String,
    856             solveSeconds: (record["solveSeconds"] as? Int64).map(Int.init),
    857             replayState: .available,
    858             wasShared: Set(journal.map(\.key.authorID).filter { !$0.isEmpty }).count > 1,
    859             participants: participants,
    860             cells: cells,
    861             journal: journal
    862         )
    863     }
    864 
    865     /// Rebuilds a standalone completed, owned game from an archive payload, under
    866     /// the derived `archiveGameID`. Reapplying refreshes the frozen grid and
    867     /// replay cache, allowing a complete cloud snapshot to replace an earlier
    868     /// local no-replay fallback without creating a duplicate. The row is never
    869     /// enqueued for sync, so it pushes no Game/Moves/Player record — the
    870     /// `Archive` record in the private zone remains its only cloud identity.
    871     ///
    872     /// Each contributing device's log is written as `sourceDeviceID`-tagged
    873     /// `JournalEntity` rows and `replayCacheComplete` is set, so the existing
    874     /// replay path (`GameStore.cachedRemoteJournals`) serves the full merged
    875     /// timeline straight from Core Data — no shared zone to fetch from.
    876     @discardableResult
    877     static func materialize(
    878         _ payload: Payload,
    879         in ctx: NSManagedObjectContext
    880     ) -> GameEntity? {
    881         guard !payload.puzzleSource.isEmpty else { return nil }
    882         let archiveID = payload.archiveGameID
    883 
    884         let request = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    885         request.predicate = NSPredicate(format: "id == %@", archiveID as CVarArg)
    886         request.fetchLimit = 1
    887         let entity: GameEntity
    888         if let existing = try? ctx.fetch(request).first {
    889             entity = existing
    890             for cell in (existing.cells as? Set<CellEntity>) ?? [] {
    891                 ctx.delete(cell)
    892             }
    893             for journal in (existing.journal as? Set<JournalEntity>) ?? [] {
    894                 ctx.delete(journal)
    895             }
    896             for player in (existing.players as? Set<PlayerEntity>) ?? [] {
    897                 ctx.delete(player)
    898             }
    899         } else {
    900             entity = GameEntity(context: ctx)
    901             entity.id = archiveID
    902         }
    903 
    904         // A sentinel record name: distinct from the `game-` form so no sync
    905         // path mistakes the archive for a pushable Game record, while staying
    906         // non-nil for code that fetches games by `ckRecordName`.
    907         entity.ckRecordName = recordName(forOriginalGameID: payload.originalGameID)
    908         entity.ckZoneName = zoneID.zoneName
    909         entity.ckZoneOwnerName = nil
    910         entity.databaseScope = 0
    911         entity.syncVersion = GameSyncVersion.legacy
    912         entity.title = payload.title
    913         entity.puzzleSource = payload.puzzleSource
    914         // Chronicles are ordinary library rows to everything that reads the
    915         // derived columns — the browser's "already have this" lookup queries
    916         // `cachedPublisher`/`cachedPuzzleDate` directly, and `GameSummary`
    917         // would otherwise reparse this XD on every list evaluation. The frozen
    918         // `puzzleParserVersion` is deliberately left alone: it still gates the
    919         // catalog re-derivation in `preparePuzzleForLoad`.
    920         if let xd = try? XD.parse(payload.puzzleSource) {
    921             entity.populateCachedSummaryFields(from: Puzzle(xd: xd))
    922         }
    923         entity.completedAt = payload.completedAt
    924         entity.completedBy = payload.completedBy
    925         // The frozen solve time the live clock reached; `PlayerRoster.solveTime`
    926         // returns this for a materialised archive, which has no `timeLog` rows.
    927         if let solveSeconds = payload.solveSeconds {
    928             entity.finalSolveSeconds = NSNumber(value: solveSeconds)
    929         }
    930         entity.createdAt = payload.completedAt
    931         entity.updatedAt = payload.completedAt
    932         entity.archivedAt = payload.completedAt
    933         entity.archiveGameID = archiveID
    934         entity.archiveParticipants = payload.wasShared
    935             ? payload.participants.map(\.authorID).sorted().joined(separator: ",")
    936             : nil
    937         entity.isSupersededByChronicle = false
    938         // Every path that turns a Chronicle payload into a local row arrives
    939         // here, so this is where the index learns which puzzle the archive
    940         // holds — knowledge the eviction that follows would otherwise discard.
    941         ChronicleLedgerEntity.upsert(
    942             originalGameID: payload.originalGameID,
    943             publisher: entity.cachedPublisher,
    944             puzzleDate: entity.cachedPuzzleDate,
    945             participants: entity.archiveParticipants,
    946             in: ctx
    947         )
    948         // When the live row is still present, it owns the account's mutable
    949         // unread watermark. Mirror that state before the Chronicle becomes the
    950         // visible Completed tile (and before retirement may delete the live
    951         // row), without putting mutable state into the frozen archive payload.
    952         let liveRequest = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    953         liveRequest.predicate = NSPredicate(
    954             format: "id == %@",
    955             payload.originalGameID as CVarArg
    956         )
    957         liveRequest.fetchLimit = 1
    958         if let live = try? ctx.fetch(liveRequest).first {
    959             mirrorReadState(from: live, to: entity)
    960         }
    961         // Pending Chronicles carry no partial journal, but remain visibly
    962         // retryable until reconciliation either captures every device or the
    963         // retention deadline turns them into a terminal no-replay fallback.
    964         switch payload.replayState {
    965         case .available:
    966             entity.replayMissingDeviceCount = nil
    967             entity.replayUnavailable = false
    968         case .waiting(let missing):
    969             entity.replayMissingDeviceCount = NSNumber(value: missing)
    970             entity.replayUnavailable = false
    971         case .unavailable:
    972             entity.replayMissingDeviceCount = nil
    973             entity.replayUnavailable = true
    974         }
    975         entity.replayCacheComplete = payload.replayAvailable
    976 
    977         for cell in payload.cells {
    978             // The payload is peer-controlled; its Int16 fields can't overflow
    979             // (decode throws first) but negatives must not become cache rows.
    980             guard cell.row >= 0, cell.col >= 0 else { continue }
    981             let row = CellEntity(context: ctx)
    982             row.game = entity
    983             row.row = cell.row
    984             row.col = cell.col
    985             row.letter = cell.letter
    986             row.markCode = cell.markCode
    987             row.letterAuthorID = cell.letterAuthorID
    988         }
    989 
    990         for participant in payload.participants {
    991             let player = PlayerEntity(context: ctx)
    992             player.game = entity
    993             player.authorID = participant.authorID
    994             player.name = participant.name ?? ""
    995             player.ckRecordName = RecordSerializer.recordName(
    996                 forPlayerInGame: archiveID,
    997                 authorID: participant.authorID
    998             )
    999             player.updatedAt = payload.completedAt
   1000         }
   1001 
   1002         // Each device's log is stored as `sourceDeviceID`-tagged rows so the
   1003         // replay reader treats every author — including the archiving user's own
   1004         // historical moves — as a cached contributor (the archived game has no
   1005         // *live* local journal to overlay).
   1006         for deviceJournal in payload.replayAvailable ? payload.journal : [] {
   1007             for value in deviceJournal.entries {
   1008                 let row = JournalEntity(context: ctx)
   1009                 row.game = entity
   1010                 MovesJournal.assign(value, to: row, gameID: archiveID)
   1011                 row.sourceAuthorID = deviceJournal.key.authorID
   1012                 row.sourceDeviceID = deviceJournal.key.deviceID
   1013             }
   1014         }
   1015 
   1016         return entity
   1017     }
   1018 
   1019     private static func inferredParticipants(
   1020         journals: [DeviceJournal],
   1021         cells: [Cell],
   1022         completedBy: String?
   1023     ) -> [Participant] {
   1024         var authorIDs = Set(journals.map(\.key.authorID))
   1025         authorIDs.formUnion(cells.compactMap(\.letterAuthorID))
   1026         if let completedBy { authorIDs.insert(completedBy) }
   1027         authorIDs.remove("")
   1028         authorIDs.remove(CKCurrentUserDefaultName)
   1029         return authorIDs.sorted().map { Participant(authorID: $0, name: nil) }
   1030     }
   1031 
   1032     private static func validatedParticipants(
   1033         _ participants: [Participant]
   1034     ) throws -> [Participant] {
   1035         guard participants.count <= maxParticipantCount else {
   1036             throw LimitError.tooManyParticipants(count: participants.count)
   1037         }
   1038         var byAuthor: [String: Participant] = [:]
   1039         for participant in participants where !participant.authorID.isEmpty {
   1040             byAuthor[participant.authorID] = participant
   1041         }
   1042         return byAuthor.values.sorted { $0.authorID < $1.authorID }
   1043     }
   1044 }