crossmate

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

Archive.swift (44595B)


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