crossmate

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

RecordSerializerTests.swift (70961B)


      1 import CloudKit
      2 import CoreData
      3 import Foundation
      4 import Testing
      5 
      6 @testable import Crossmate
      7 
      8 @Suite("RecordSerializer")
      9 struct RecordSerializerTests {
     10 
     11     // MARK: - Record name generation
     12 
     13     @Test("Game record name uses expected format")
     14     func gameRecordNameFormat() {
     15         let id = UUID(uuidString: "12345678-1234-1234-1234-123456789ABC")!
     16         let name = RecordSerializer.recordName(forGameID: id)
     17         #expect(name == "game-12345678-1234-1234-1234-123456789ABC")
     18     }
     19 
     20     @Test("Record names are deterministic")
     21     func recordNamesAreDeterministic() {
     22         let id = UUID()
     23         let a = RecordSerializer.recordName(forGameID: id)
     24         let b = RecordSerializer.recordName(forGameID: id)
     25         #expect(a == b)
     26     }
     27 
     28     @Test("gameID(fromGameRecordName:) round-trips and rejects non-game names")
     29     func gameIDFromGameRecordName() {
     30         let id = UUID()
     31         // Round-trips the forward encoding — this is how a share's zone name
     32         // ("game-<UUID>") is resolved back to the game it covers.
     33         #expect(RecordSerializer.gameID(fromGameRecordName: RecordSerializer.recordName(forGameID: id)) == id)
     34         // Non-game names and malformed UUIDs yield nil rather than a bogus id.
     35         #expect(RecordSerializer.gameID(fromGameRecordName: "account") == nil)
     36         #expect(RecordSerializer.gameID(fromGameRecordName: "friend-\(UUID().uuidString)") == nil)
     37         #expect(RecordSerializer.gameID(fromGameRecordName: "game-not-a-uuid") == nil)
     38     }
     39 
     40     // MARK: - Per-game zone
     41 
     42     @Test("zoneID(for:) uses game-<UUID> as zone name")
     43     func perGameZoneName() {
     44         let id = UUID(uuidString: "12345678-1234-1234-1234-123456789ABC")!
     45         let zone = RecordSerializer.zoneID(for: id)
     46         #expect(zone.zoneName == "game-12345678-1234-1234-1234-123456789ABC")
     47         #expect(zone.ownerName == CKCurrentUserDefaultName)
     48     }
     49 
     50     @Test("zoneID(for:ownerName:) accepts explicit owner")
     51     func perGameZoneExplicitOwner() {
     52         let id = UUID()
     53         let zone = RecordSerializer.zoneID(for: id, ownerName: "alice_record_id")
     54         #expect(zone.ownerName == "alice_record_id")
     55     }
     56 
     57     // MARK: - Player round-trip
     58 
     59     @Test("recordName(forPlayerInGame:authorID:) uses expected format")
     60     func playerRecordNameFormat() {
     61         let id = UUID(uuidString: "12345678-1234-1234-1234-123456789ABC")!
     62         let name = RecordSerializer.recordName(forPlayerInGame: id, authorID: "_abc")
     63         #expect(name == "player-12345678-1234-1234-1234-123456789ABC-_abc")
     64     }
     65 
     66     @Test("parsePlayerRecordName splits gameID and authorID")
     67     func parsePlayerRecordRoundTrip() {
     68         let gameID = UUID()
     69         let authorID = "_someAuthorID"
     70         let recordName = RecordSerializer.recordName(forPlayerInGame: gameID, authorID: authorID)
     71         let parsed = RecordSerializer.parsePlayerRecordName(recordName)
     72         #expect(parsed?.0 == gameID)
     73         #expect(parsed?.1 == authorID)
     74     }
     75 
     76     @Test("parsePlayerRecordName rejects malformed names")
     77     func parsePlayerRecordRejectsBadInput() {
     78         #expect(RecordSerializer.parsePlayerRecordName("game-foo") == nil)
     79         #expect(RecordSerializer.parsePlayerRecordName("player-not-a-uuid") == nil)
     80         #expect(RecordSerializer.parsePlayerRecordName("player-12345678-1234-1234-1234-123456789ABC") == nil)
     81     }
     82 
     83     @Test("Direct fetch key sets include serialized fields")
     84     func directFetchKeySetsIncludeSerializedFields() {
     85         #expect(Set(RecordSerializer.gameDesiredKeys) == [
     86             "title",
     87             "completedAt",
     88             "completedBy",
     89             "shareRecordName",
     90             "roomCredential",
     91             "pushCredential",
     92             "puzzleSource",
     93             "syncVersion",
     94         ])
     95         #expect(Set(RecordSerializer.movesDesiredKeys) == [
     96             "cells",
     97             "updatedAt",
     98         ])
     99         #expect(Set(RecordSerializer.playerDesiredKeys) == [
    100             "name",
    101             "updatedAt",
    102             "selRow",
    103             "selCol",
    104             "selDir",
    105             "presenceUntil",
    106             "readThrough",
    107             "viewedAt",
    108             "timeLog",
    109             "pushAddress",
    110         ])
    111         #expect(Set(RecordSerializer.pingDesiredKeys) == [
    112             "playerName",
    113             "puzzleTitle",
    114             "kind",
    115             "payload",
    116             "addressee",
    117         ])
    118     }
    119 
    120     @Test("playerRecord writes presenceUntil and parses it back")
    121     func playerRecordPresenceUntilRoundTrip() {
    122         let id = UUID()
    123         let zone = CKRecordZone.ID(zoneName: "test-zone", ownerName: CKCurrentUserDefaultName)
    124         let presenceUntil = Date(timeIntervalSince1970: 1_700_000_000)
    125         let record = RecordSerializer.playerRecord(
    126             gameID: id,
    127             authorID: "alice",
    128             name: "Alice",
    129             updatedAt: Date(timeIntervalSince1970: 1_700_000_100),
    130             selection: nil,
    131             presenceUntil: presenceUntil,
    132             zone: zone,
    133             systemFields: nil
    134         )
    135         #expect(record["presenceUntil"] as? Date == presenceUntil)
    136         #expect(RecordSerializer.parsePlayerPresenceUntil(from: record) == presenceUntil)
    137     }
    138 
    139     @Test("playerRecord omits presenceUntil when nil and parser returns nil")
    140     func playerRecordPresenceUntilNil() {
    141         let id = UUID()
    142         let zone = CKRecordZone.ID(zoneName: "test-zone", ownerName: CKCurrentUserDefaultName)
    143         let record = RecordSerializer.playerRecord(
    144             gameID: id,
    145             authorID: "alice",
    146             name: "Alice",
    147             updatedAt: Date(timeIntervalSince1970: 1_700_000_100),
    148             selection: nil,
    149             zone: zone,
    150             systemFields: nil
    151         )
    152         #expect(record["presenceUntil"] == nil)
    153         #expect(RecordSerializer.parsePlayerPresenceUntil(from: record) == nil)
    154     }
    155 
    156     @Test("playerRecord writes pushAddress and parses it back")
    157     func playerRecordPushAddressRoundTrip() {
    158         let id = UUID()
    159         let zone = CKRecordZone.ID(zoneName: "test-zone", ownerName: CKCurrentUserDefaultName)
    160         let address = "abc123_-XYZ"
    161         let record = RecordSerializer.playerRecord(
    162             gameID: id,
    163             authorID: "alice",
    164             name: "Alice",
    165             updatedAt: Date(timeIntervalSince1970: 1_700_000_100),
    166             selection: nil,
    167             pushAddress: address,
    168             zone: zone,
    169             systemFields: nil
    170         )
    171         #expect(record["pushAddress"] as? String == address)
    172         #expect(RecordSerializer.parsePlayerPushAddress(from: record) == address)
    173     }
    174 
    175     @Test("playerRecord omits pushAddress when nil or empty and parser returns nil")
    176     func playerRecordPushAddressNil() {
    177         let id = UUID()
    178         let zone = CKRecordZone.ID(zoneName: "test-zone", ownerName: CKCurrentUserDefaultName)
    179         let record = RecordSerializer.playerRecord(
    180             gameID: id,
    181             authorID: "alice",
    182             name: "Alice",
    183             updatedAt: Date(timeIntervalSince1970: 1_700_000_100),
    184             selection: nil,
    185             pushAddress: "",
    186             zone: zone,
    187             systemFields: nil
    188         )
    189         #expect(record["pushAddress"] == nil)
    190         #expect(RecordSerializer.parsePlayerPushAddress(from: record) == nil)
    191     }
    192 
    193     // MARK: - Ping
    194 
    195     @Test("recordName(forPingInGame:authorID:deviceID:eventTimestampMs:) includes deviceID")
    196     func pingRecordNameIncludesDeviceID() {
    197         let id = UUID(uuidString: "12345678-1234-1234-1234-123456789ABC")!
    198         let name = RecordSerializer.recordName(
    199             forPingInGame: id,
    200             authorID: "alice",
    201             deviceID: "deviceA",
    202             eventTimestampMs: 1700000000000
    203         )
    204         #expect(name == "ping-12345678-1234-1234-1234-123456789ABC-alice-deviceA-1700000000000")
    205     }
    206 
    207     @Test("pingRecord encodes authorID/deviceID in the record name, not fields")
    208     func pingRecordEncodesIdentityInName() {
    209         let id = UUID()
    210         let zone = CKRecordZone.ID(zoneName: "test-zone", ownerName: CKCurrentUserDefaultName)
    211         let record = RecordSerializer.pingRecord(
    212             gameID: id,
    213             authorID: "alice",
    214             deviceID: "deviceA",
    215             playerName: "Alice",
    216             puzzleTitle: "Puzzle",
    217             eventTimestampMs: 1700000000000,
    218             kind: .join,
    219             zone: zone
    220         )
    221         #expect(record["authorID"] == nil)
    222         #expect(record["deviceID"] == nil)
    223         let parsed = RecordSerializer.parsePingRecordName(record.recordID.recordName)
    224         #expect(parsed?.0 == id)
    225         #expect(parsed?.1 == "alice")
    226         #expect(parsed?.2 == "deviceA")
    227         #expect(record["kind"] as? String == "join")
    228     }
    229 
    230     @Test("parsePingRecordName round-trips names, including a dashed authorID")
    231     func parsePingRecordNameHandlesDashedAuthor() {
    232         let gameID = UUID()
    233         let name = RecordSerializer.recordName(
    234             forPingInGame: gameID,
    235             authorID: "au-th-or",
    236             deviceID: "cafef00d",
    237             eventTimestampMs: 42
    238         )
    239         let parsed = RecordSerializer.parsePingRecordName(name)
    240         #expect(parsed?.0 == gameID)
    241         #expect(parsed?.1 == "au-th-or")
    242         #expect(parsed?.2 == "cafef00d")
    243         // A non-ping name parses to nil.
    244         #expect(RecordSerializer.parsePingRecordName("player-\(gameID.uuidString)-alice") == nil)
    245     }
    246 
    247     @Test("pingRecord writes payload when provided and omits it when nil")
    248     func pingRecordPayloadRoundTrip() {
    249         let zone = CKRecordZone.ID(zoneName: "z", ownerName: CKCurrentUserDefaultName)
    250         let withPayload = RecordSerializer.pingRecord(
    251             gameID: UUID(),
    252             authorID: "alice",
    253             deviceID: "deviceA",
    254             playerName: "Alice",
    255             puzzleTitle: "Puzzle",
    256             eventTimestampMs: 1700000000000,
    257             kind: .invite,
    258             payload: #"{"gameShareURL":"https://x"}"#,
    259             zone: zone
    260         )
    261         #expect(withPayload["payload"] as? String == #"{"gameShareURL":"https://x"}"#)
    262         #expect(withPayload["kind"] as? String == "invite")
    263 
    264         let withoutPayload = RecordSerializer.pingRecord(
    265             gameID: UUID(),
    266             authorID: "alice",
    267             deviceID: "deviceA",
    268             playerName: "Alice",
    269             puzzleTitle: "Puzzle",
    270             eventTimestampMs: 1700000000000,
    271             kind: .join,
    272             zone: zone
    273         )
    274         #expect(withoutPayload["payload"] == nil)
    275     }
    276 
    277     @Test("pingRecord writes addressee when directed and omits it when nil")
    278     func pingRecordAddresseeRoundTrip() {
    279         let zone = CKRecordZone.ID(zoneName: "z", ownerName: CKCurrentUserDefaultName)
    280         let directed = RecordSerializer.pingRecord(
    281             gameID: UUID(),
    282             authorID: "alice",
    283             deviceID: "deviceA",
    284             playerName: "Alice",
    285             puzzleTitle: "Puzzle",
    286             eventTimestampMs: 1700000000000,
    287             kind: .invite,
    288             addressee: "bob",
    289             zone: zone
    290         )
    291         #expect(directed["addressee"] as? String == "bob")
    292         #expect(directed["kind"] as? String == "invite")
    293 
    294         let broadcast = RecordSerializer.pingRecord(
    295             gameID: UUID(),
    296             authorID: "alice",
    297             deviceID: "deviceA",
    298             playerName: "Alice",
    299             puzzleTitle: "Puzzle",
    300             eventTimestampMs: 1700000000000,
    301             kind: .join,
    302             zone: zone
    303         )
    304         #expect(broadcast["addressee"] == nil)
    305     }
    306 
    307     @Test("hail ping round-trips payload and device addressee")
    308     func hailPingRoundTrip() throws {
    309         let gameID = UUID()
    310         let zone = RecordSerializer.zoneID(for: gameID)
    311         let payload = #"{"role":"offer","engagementID":"01234567-89AB-CDEF-0123-456789ABCDEF","sdp":"v=0\r\n","candidates":["candidate:1"],"ver":1}"#
    312         let record = RecordSerializer.pingRecord(
    313             gameID: gameID,
    314             authorID: "alice",
    315             deviceID: "deviceA",
    316             playerName: "Alice",
    317             puzzleTitle: "Puzzle",
    318             eventTimestampMs: 1700000000000,
    319             kind: .hail,
    320             payload: payload,
    321             addressee: "bob:deviceB",
    322             zone: zone
    323         )
    324 
    325         let parsed = try #require(Ping.parseRecord(record, fetchedFrom: .shared))
    326         #expect(parsed.gameID == gameID)
    327         #expect(parsed.authorID == "alice")
    328         #expect(parsed.deviceID == "deviceA")
    329         #expect(parsed.playerName == "Alice")
    330         #expect(parsed.puzzleTitle == "Puzzle")
    331         #expect(parsed.kind == .hail)
    332         #expect(parsed.payload == payload)
    333         #expect(parsed.addressee == "bob:deviceB")
    334         #expect(parsed.sourceDatabaseScope == .shared)
    335     }
    336 
    337     @Test("hail ping parse requires fetched addressee for routing")
    338     func hailPingRequiresFetchedAddressee() throws {
    339         let gameID = UUID()
    340         let zone = RecordSerializer.zoneID(for: gameID)
    341         let record = RecordSerializer.pingRecord(
    342             gameID: gameID,
    343             authorID: "alice",
    344             deviceID: "deviceA",
    345             playerName: "Alice",
    346             puzzleTitle: "Puzzle",
    347             eventTimestampMs: 1700000000000,
    348             kind: .hail,
    349             payload: #"{"role":"offer","engagementID":"01234567-89AB-CDEF-0123-456789ABCDEF","sdp":"v=0\r\n","candidates":[],"ver":1}"#,
    350             addressee: "alice",
    351             zone: zone
    352         )
    353 
    354         let parsed = try #require(Ping.parseRecord(record, fetchedFrom: .shared))
    355         #expect(parsed.addressee == "alice")
    356     }
    357 
    358     @Test("accountZoneID is named 'account' in the current user's private DB")
    359     func accountZoneIDShape() {
    360         let zone = RecordSerializer.accountZoneID
    361         #expect(zone.zoneName == "account")
    362         #expect(zone.ownerName == CKCurrentUserDefaultName)
    363     }
    364 
    365     // MARK: - applyGameRecord
    366 
    367     /// Writes `source` to a temp file and returns a `CKAsset` pointing to it.
    368     /// The caller is responsible for removing the file when done.
    369     private func makePuzzleAsset(source: String = "dummy puzzle source") throws -> (CKAsset, URL) {
    370         let url = FileManager.default.temporaryDirectory
    371             .appendingPathComponent(UUID().uuidString)
    372         try source.write(to: url, atomically: true, encoding: .utf8)
    373         return (CKAsset(fileURL: url), url)
    374     }
    375 
    376     @Test("applyGameRecord creates entity with id derived from record name")
    377     @MainActor func applyGameRecordCreatesEntity() throws {
    378         let persistence = makeTestPersistence()
    379         let ctx = persistence.viewContext
    380         let gameID = UUID()
    381         let zone = RecordSerializer.zoneID(for: gameID)
    382         let recordName = RecordSerializer.recordName(forGameID: gameID)
    383         let record = CKRecord(recordType: "Game", recordID: CKRecord.ID(recordName: recordName, zoneID: zone))
    384         record["title"] = "Test Title" as CKRecordValue
    385         let (asset, tmpURL) = try makePuzzleAsset()
    386         defer { try? FileManager.default.removeItem(at: tmpURL) }
    387         record["puzzleSource"] = asset as CKRecordValue
    388 
    389         let entity = RecordSerializer.applyGameRecord(record, to: ctx)
    390         try ctx.save()
    391 
    392         #expect(entity.id == gameID)
    393         #expect(entity.title == "Test Title")
    394         #expect(entity.ckRecordName == recordName)
    395     }
    396 
    397     @Test("applyGameRecord round-trips completedBy and clears it when absent")
    398     @MainActor func applyGameRecordCompletedBy() throws {
    399         let persistence = makeTestPersistence()
    400         let ctx = persistence.viewContext
    401         let gameID = UUID()
    402         let recordID = CKRecord.ID(
    403             recordName: RecordSerializer.recordName(forGameID: gameID),
    404             zoneID: RecordSerializer.zoneID(for: gameID)
    405         )
    406 
    407         // A win carries the solver's authorID.
    408         let (asset, tmpURL) = try makePuzzleAsset()
    409         defer { try? FileManager.default.removeItem(at: tmpURL) }
    410         let win = CKRecord(recordType: "Game", recordID: recordID)
    411         win["title"] = "T" as CKRecordValue
    412         win["completedAt"] = Date() as CKRecordValue
    413         win["completedBy"] = "alice" as CKRecordValue
    414         win["puzzleSource"] = asset as CKRecordValue
    415         let entity = RecordSerializer.applyGameRecord(win, to: ctx)
    416         try ctx.save()
    417         #expect(entity.completedBy == "alice")
    418 
    419         // A later record without completedBy (a resignation) clears it, so
    420         // wins stay distinguishable from resignations.
    421         let resign = CKRecord(recordType: "Game", recordID: recordID)
    422         resign["title"] = "T" as CKRecordValue
    423         resign["completedAt"] = Date() as CKRecordValue
    424         let merged = RecordSerializer.applyGameRecord(resign, to: ctx)
    425         try ctx.save()
    426         #expect(merged === entity)
    427         #expect(merged.completedBy == nil)
    428     }
    429 
    430     @Test("applyGameRecord round-trips the notification push credential and keeps it when absent")
    431     @MainActor func applyGameRecordNotification() throws {
    432         let persistence = makeTestPersistence()
    433         let ctx = persistence.viewContext
    434         let gameID = UUID()
    435         let recordID = CKRecord.ID(
    436             recordName: RecordSerializer.recordName(forGameID: gameID),
    437             zoneID: RecordSerializer.zoneID(for: gameID)
    438         )
    439         let (asset, tmpURL) = try makePuzzleAsset()
    440         defer { try? FileManager.default.removeItem(at: tmpURL) }
    441 
    442         // The credential carries both the worker auth secret and the worker-blind
    443         // content key in one blob (the `notification` field).
    444         let creds = try GamePushCredentials.fresh()
    445         #expect(creds.contentKey != nil)
    446         let record = CKRecord(recordType: "Game", recordID: recordID)
    447         record["title"] = "T" as CKRecordValue
    448         record["pushCredential"] = Data(try creds.encoded().utf8) as CKRecordValue
    449         record["puzzleSource"] = asset as CKRecordValue
    450         var contentKeyChanges: [UUID] = []
    451         let entity = RecordSerializer.applyGameRecord(
    452             record,
    453             to: ctx,
    454             onContentKeyChange: { contentKeyChanges.append($0) }
    455         )
    456         try ctx.save()
    457         #expect(GamePushCredentials.decode(entity.notification) == creds)
    458         #expect(contentKeyChanges == [gameID])
    459 
    460         // A later record without the field never clears a local credential:
    461         // creds are minted once and only ever replaced (rotation), so an
    462         // absent field is always a stale record. The local value is kept,
    463         // flagged pending, and signalled for a re-push that heals the server.
    464         let cleared = CKRecord(recordType: "Game", recordID: recordID)
    465         cleared["title"] = "T" as CKRecordValue
    466         var staleRecords: [String] = []
    467         let merged = RecordSerializer.applyGameRecord(
    468             cleared,
    469             to: ctx,
    470             onContentKeyChange: { contentKeyChanges.append($0) },
    471             onStaleCredentials: { staleRecords.append($0) }
    472         )
    473         try ctx.save()
    474         #expect(merged === entity)
    475         #expect(GamePushCredentials.decode(merged.notification) == creds)
    476         #expect(contentKeyChanges == [gameID])
    477         #expect(staleRecords == [recordID.recordName])
    478         #expect(merged.hasPendingSave)
    479     }
    480 
    481     @Test("applyGameRecord preserves id and createdAt on second apply, updates title")
    482     @MainActor func applyGameRecordMergesOnServerRecordChanged() throws {
    483         let persistence = makeTestPersistence()
    484         let ctx = persistence.viewContext
    485         let gameID = UUID()
    486         let zone = RecordSerializer.zoneID(for: gameID)
    487         let recordName = RecordSerializer.recordName(forGameID: gameID)
    488         let recordID = CKRecord.ID(recordName: recordName, zoneID: zone)
    489 
    490         let (asset1, tmpURL1) = try makePuzzleAsset(source: "original source")
    491         defer { try? FileManager.default.removeItem(at: tmpURL1) }
    492 
    493         // First apply — creates the entity.
    494         let record1 = CKRecord(recordType: "Game", recordID: recordID)
    495         record1["title"] = "Original" as CKRecordValue
    496         record1["puzzleSource"] = asset1 as CKRecordValue
    497         let entity = RecordSerializer.applyGameRecord(record1, to: ctx)
    498         try ctx.save()
    499 
    500         let frozenID = entity.id
    501         let frozenCreatedAt = entity.createdAt
    502 
    503         // Second apply — simulates a server record change with an updated title.
    504         // puzzleSource is intentionally absent here to verify it isn't wiped.
    505         let record2 = CKRecord(recordType: "Game", recordID: recordID)
    506         record2["title"] = "Updated" as CKRecordValue
    507         let merged = RecordSerializer.applyGameRecord(record2, to: ctx)
    508         try ctx.save()
    509 
    510         #expect(merged === entity)               // same managed object
    511         #expect(merged.id == frozenID)           // id not overwritten
    512         #expect(merged.createdAt == frozenCreatedAt) // createdAt not overwritten
    513         #expect(merged.title == "Updated")       // mutable field updated
    514     }
    515 
    516     /// A valid XD whose title ("Test Puzzle") differs from any `record["title"]`
    517     /// the tests set, so the parse-derived title is observable.
    518     private static let validXDSource = """
    519     Title: Test Puzzle
    520     Author: Test
    521 
    522 
    523     ABC
    524     D#E
    525     FGH
    526 
    527 
    528     A1. Across 1 ~ ABC
    529     A4. Across 4 ~ DE
    530     A5. Across 5 ~ FGH
    531     D1. Down 1 ~ ADF
    532     D2. Down 2 ~ BG
    533     D3. Down 3 ~ CEH
    534     """
    535 
    536     @Test("applyGameRecord derives the title from the puzzle asset, overriding a stale record title")
    537     @MainActor func applyGameRecordDerivesTitleFromAsset() throws {
    538         let persistence = makeTestPersistence()
    539         let ctx = persistence.viewContext
    540         let gameID = UUID()
    541         let recordID = CKRecord.ID(
    542             recordName: RecordSerializer.recordName(forGameID: gameID),
    543             zoneID: RecordSerializer.zoneID(for: gameID)
    544         )
    545 
    546         // The record's title field carries a stale "Joining…" placeholder — the
    547         // exact value a participant's Game-record push can clobber the shared
    548         // record with — but the puzzleSource asset parses to "Test Puzzle".
    549         let (asset, tmpURL) = try makePuzzleAsset(source: Self.validXDSource)
    550         defer { try? FileManager.default.removeItem(at: tmpURL) }
    551         let record = CKRecord(recordType: "Game", recordID: recordID)
    552         record["title"] = "Joining\u{2026}" as CKRecordValue
    553         record["puzzleSource"] = asset as CKRecordValue
    554 
    555         let entity = RecordSerializer.applyGameRecord(record, to: ctx)
    556         try ctx.save()
    557 
    558         // The asset wins: the stale title self-heals to the puzzle's real title.
    559         #expect(entity.title == "Test Puzzle")
    560     }
    561 
    562     @Test("applyGameRecord skips an oversized puzzleSource asset and reports it via onDiagnostic")
    563     @MainActor func applyGameRecordReportsOversizedAsset() throws {
    564         let persistence = makeTestPersistence()
    565         let ctx = persistence.viewContext
    566         let gameID = UUID()
    567         let recordID = CKRecord.ID(
    568             recordName: RecordSerializer.recordName(forGameID: gameID),
    569             zoneID: RecordSerializer.zoneID(for: gameID)
    570         )
    571         let oversized = String(repeating: "x", count: XD.maxSourceBytes + 1)
    572         let (asset, tmpURL) = try makePuzzleAsset(source: oversized)
    573         defer { try? FileManager.default.removeItem(at: tmpURL) }
    574         let record = CKRecord(recordType: "Game", recordID: recordID)
    575         record["puzzleSource"] = asset as CKRecordValue
    576 
    577         var diagnostics: [String] = []
    578         let entity = RecordSerializer.applyGameRecord(
    579             record,
    580             to: ctx,
    581             onDiagnostic: { diagnostics.append($0) }
    582         )
    583 
    584         #expect((entity.puzzleSource ?? "").isEmpty)
    585         #expect(diagnostics.count == 1)
    586         #expect(diagnostics.first?.contains("exceeds") == true)
    587     }
    588 
    589     @Test("boundedAssetData reads a file at the limit and rejects one over it")
    590     func boundedAssetDataEnforcesLimit() throws {
    591         let url = FileManager.default.temporaryDirectory
    592             .appendingPathComponent(UUID().uuidString)
    593         defer { try? FileManager.default.removeItem(at: url) }
    594 
    595         try Data(count: 16).write(to: url)
    596         #expect(try RecordSerializer.boundedAssetData(at: url, limit: 16).count == 16)
    597         #expect(throws: RecordSerializer.AssetReadError.self) {
    598             _ = try RecordSerializer.boundedAssetData(at: url, limit: 15)
    599         }
    600     }
    601 
    602     @Test("boundedAssetData fails closed on a missing file")
    603     func boundedAssetDataFailsClosedOnMissingFile() {
    604         let url = FileManager.default.temporaryDirectory
    605             .appendingPathComponent(UUID().uuidString)
    606         #expect(throws: (any Error).self) {
    607             _ = try RecordSerializer.boundedAssetData(at: url, limit: 16)
    608         }
    609     }
    610 
    611     @Test("applyGameRecord reports a failed puzzleSource asset read via onDiagnostic")
    612     @MainActor func applyGameRecordReportsFailedAssetRead() throws {
    613         let persistence = makeTestPersistence()
    614         let ctx = persistence.viewContext
    615         let gameID = UUID()
    616         let recordID = CKRecord.ID(
    617             recordName: RecordSerializer.recordName(forGameID: gameID),
    618             zoneID: RecordSerializer.zoneID(for: gameID)
    619         )
    620         // Delete the backing file before applying, simulating a CKAsset whose
    621         // download staged file is gone by the time the record is applied.
    622         let (asset, tmpURL) = try makePuzzleAsset(source: Self.validXDSource)
    623         try FileManager.default.removeItem(at: tmpURL)
    624         let record = CKRecord(recordType: "Game", recordID: recordID)
    625         record["puzzleSource"] = asset as CKRecordValue
    626 
    627         var diagnostics: [String] = []
    628         let entity = RecordSerializer.applyGameRecord(
    629             record,
    630             to: ctx,
    631             onDiagnostic: { diagnostics.append($0) }
    632         )
    633 
    634         #expect((entity.puzzleSource ?? "").isEmpty)
    635         #expect(diagnostics.count == 1)
    636         #expect(diagnostics.first?.contains("read failed") == true)
    637     }
    638 
    639     @Test("populateGameRecord writes the title for an owner but not a participant")
    640     @MainActor func populateGameRecordGatesTitleOnOwnership() throws {
    641         let persistence = makeTestPersistence()
    642         let ctx = persistence.viewContext
    643 
    644         func makeGame(databaseScope: Int16) -> GameEntity {
    645             let entity = GameEntity(context: ctx)
    646             entity.id = UUID()
    647             entity.ckRecordName = "game-\(UUID().uuidString)"
    648             entity.title = "Joining\u{2026}"
    649             entity.ckShareRecordName = "share-marker"
    650             entity.puzzleSource = ""
    651             entity.databaseScope = databaseScope
    652             return entity
    653         }
    654 
    655         // Owner (databaseScope == 0): title and share marker are written.
    656         let ownerEntity = makeGame(databaseScope: 0)
    657         ownerEntity.syncVersion = GameSyncVersion.current
    658         let ownerRecord = CKRecord(recordType: "Game", recordID: CKRecord.ID(recordName: ownerEntity.ckRecordName!))
    659         RecordSerializer.populateGameRecord(ownerRecord, from: ownerEntity, includePuzzleSource: false)
    660         #expect(ownerRecord["title"] as? String == "Joining\u{2026}")
    661         #expect(ownerRecord["shareRecordName"] as? String == "share-marker")
    662         #expect(ownerRecord["syncVersion"] as? Int64 == GameSyncVersion.current)
    663 
    664         // Participant (databaseScope == 1): the transient placeholder title is
    665         // not written, so a cred-minting re-save can't clobber the owner's title.
    666         let participantEntity = makeGame(databaseScope: 1)
    667         let participantRecord = CKRecord(recordType: "Game", recordID: CKRecord.ID(recordName: participantEntity.ckRecordName!))
    668         RecordSerializer.populateGameRecord(participantRecord, from: participantEntity, includePuzzleSource: false)
    669         #expect(participantRecord["title"] == nil)
    670         #expect(participantRecord["shareRecordName"] == nil)
    671         #expect(participantRecord["syncVersion"] == nil)
    672     }
    673 
    674     @Test("Game records default missing sync versions to legacy and adopt explicit versions")
    675     @MainActor func applyGameRecordSyncVersions() {
    676         let persistence = makeTestPersistence()
    677         let ctx = persistence.viewContext
    678 
    679         let legacyID = UUID()
    680         let legacyRecord = CKRecord(
    681             recordType: "Game",
    682             recordID: CKRecord.ID(
    683                 recordName: RecordSerializer.recordName(forGameID: legacyID),
    684                 zoneID: RecordSerializer.zoneID(for: legacyID)
    685             )
    686         )
    687         let legacy = RecordSerializer.applyGameRecord(legacyRecord, to: ctx)
    688         #expect(legacy.syncVersion == GameSyncVersion.legacy)
    689 
    690         let futureID = UUID()
    691         let futureRecord = CKRecord(
    692             recordType: "Game",
    693             recordID: CKRecord.ID(
    694                 recordName: RecordSerializer.recordName(forGameID: futureID),
    695                 zoneID: RecordSerializer.zoneID(for: futureID)
    696             )
    697         )
    698         futureRecord["syncVersion"] = Int64(7) as CKRecordValue
    699         let future = RecordSerializer.applyGameRecord(futureRecord, to: ctx)
    700         #expect(future.syncVersion == 7)
    701     }
    702 
    703     @Test("applyGameRecord preserves local mutable fields when a save is pending")
    704     @MainActor func applyGameRecordPreservesLocalFieldsWhenSavePending() throws {
    705         let persistence = makeTestPersistence()
    706         let ctx = persistence.viewContext
    707         let gameID = UUID()
    708         let zone = RecordSerializer.zoneID(for: gameID)
    709         let recordName = RecordSerializer.recordName(forGameID: gameID)
    710         let recordID = CKRecord.ID(recordName: recordName, zoneID: zone)
    711 
    712         // Local entity reflects a just-set completion: cells are solved,
    713         // `markCompleted` wrote `completedAt` and `hasPendingSave` together,
    714         // and a Game-record push is queued but hasn't landed yet.
    715         let localCompletedAt = Date(timeIntervalSince1970: 1_700_000_500)
    716         let entity = GameEntity(context: ctx)
    717         entity.id = gameID
    718         entity.ckRecordName = recordName
    719         entity.ckZoneName = zone.zoneName
    720         entity.title = "Local Title"
    721         entity.completedAt = localCompletedAt
    722         entity.hasPendingSave = true
    723         entity.puzzleSource = ""
    724         entity.createdAt = Date(timeIntervalSince1970: 1_700_000_000)
    725         entity.updatedAt = Date(timeIntervalSince1970: 1_700_000_400)
    726 
    727         // Stale server snapshot (the push hasn't landed): no completedAt,
    728         // older title. Applying it without the pending-save guard would
    729         // clobber the local fields and the next outbound push would then
    730         // serialise the clobbered values, permanently losing them.
    731         let record = CKRecord(recordType: "Game", recordID: recordID)
    732         record["title"] = "Remote Stale Title" as CKRecordValue
    733 
    734         let merged = RecordSerializer.applyGameRecord(record, to: ctx)
    735         try ctx.save()
    736 
    737         #expect(merged === entity)
    738         #expect(merged.completedAt == localCompletedAt)
    739         #expect(merged.title == "Local Title")
    740         // The fresher etag is still adopted so the next push uses a current
    741         // change tag and doesn't oplock-fail.
    742         #expect(merged.ckSystemFields != nil)
    743     }
    744 
    745     @Test("applyGameRecord does not clear an existing completion with an incomplete snapshot")
    746     @MainActor func applyGameRecordKeepsExistingCompletionWhenIncomingIsNil() throws {
    747         let persistence = makeTestPersistence()
    748         let ctx = persistence.viewContext
    749         let gameID = UUID()
    750         let zone = RecordSerializer.zoneID(for: gameID)
    751         let recordName = RecordSerializer.recordName(forGameID: gameID)
    752         let recordID = CKRecord.ID(recordName: recordName, zoneID: zone)
    753         let localCompletedAt = Date(timeIntervalSince1970: 1_700_000_500)
    754 
    755         let entity = GameEntity(context: ctx)
    756         entity.id = gameID
    757         entity.ckRecordName = recordName
    758         entity.ckZoneName = zone.zoneName
    759         entity.title = "Completed"
    760         entity.completedAt = localCompletedAt
    761         entity.completedBy = "alice"
    762         entity.puzzleSource = ""
    763         entity.createdAt = Date(timeIntervalSince1970: 1_700_000_000)
    764         entity.updatedAt = Date(timeIntervalSince1970: 1_700_000_400)
    765         try ctx.save()
    766 
    767         let record = CKRecord(recordType: "Game", recordID: recordID)
    768         record["pushCredential"] = Data("fresh-notification".utf8) as CKRecordValue
    769 
    770         let merged = RecordSerializer.applyGameRecord(record, to: ctx)
    771         try ctx.save()
    772 
    773         #expect(merged === entity)
    774         #expect(merged.completedAt == localCompletedAt)
    775         #expect(merged.completedBy == "alice")
    776         #expect(merged.notification == "fresh-notification")
    777     }
    778 
    779     @Test("populateGameRecord preserves server completion when local participant is stale")
    780     @MainActor func populateGameRecordDoesNotClearServerCompletionFromStaleParticipant() throws {
    781         let gameID = UUID()
    782         let zone = RecordSerializer.zoneID(for: gameID, ownerName: "_owner")
    783         let recordID = CKRecord.ID(
    784             recordName: RecordSerializer.recordName(forGameID: gameID),
    785             zoneID: zone
    786         )
    787         let serverCompletedAt = Date(timeIntervalSince1970: 1_700_000_600)
    788         let record = CKRecord(recordType: "Game", recordID: recordID)
    789         record["completedAt"] = serverCompletedAt as CKRecordValue
    790         record["completedBy"] = "owner-author" as CKRecordValue
    791 
    792         let persistence = makeTestPersistence()
    793         let entity = GameEntity(context: persistence.viewContext)
    794         entity.id = gameID
    795         entity.ckRecordName = recordID.recordName
    796         entity.databaseScope = 1
    797         entity.notification = "new-notification"
    798 
    799         RecordSerializer.populateGameRecord(record, from: entity, includePuzzleSource: false)
    800 
    801         #expect(record["completedAt"] as? Date == serverCompletedAt)
    802         #expect(record["completedBy"] as? String == "owner-author")
    803         #expect((record["pushCredential"] as? Data).flatMap { String(data: $0, encoding: .utf8) } == "new-notification")
    804     }
    805 
    806     @Test("applyGameRecord does not lower an existing updatedAt")
    807     @MainActor func applyGameRecordPreservesFresherUpdatedAt() throws {
    808         let persistence = makeTestPersistence()
    809         let ctx = persistence.viewContext
    810         let gameID = UUID()
    811         let zone = RecordSerializer.zoneID(for: gameID)
    812         let recordName = RecordSerializer.recordName(forGameID: gameID)
    813         let recordID = CKRecord.ID(recordName: recordName, zoneID: zone)
    814 
    815         let entity = GameEntity(context: ctx)
    816         let newerUpdatedAt = Date(timeIntervalSince1970: 1_700_000_500)
    817         entity.id = gameID
    818         entity.ckRecordName = recordName
    819         entity.ckZoneName = zone.zoneName
    820         entity.title = "Local"
    821         entity.puzzleSource = ""
    822         entity.createdAt = Date(timeIntervalSince1970: 1_700_000_000)
    823         entity.updatedAt = newerUpdatedAt
    824 
    825         let record = CKRecord(recordType: "Game", recordID: recordID)
    826         record["title"] = "Remote" as CKRecordValue
    827 
    828         let merged = RecordSerializer.applyGameRecord(record, to: ctx)
    829         try ctx.save()
    830 
    831         #expect(merged === entity)
    832         #expect(merged.title == "Remote")
    833         #expect(merged.updatedAt == newerUpdatedAt)
    834     }
    835 
    836     // MARK: - System fields round-trip
    837 
    838     @Test("Encode and decode system fields preserves record type and zone")
    839     func systemFieldsRoundTrip() {
    840         let gameID = UUID()
    841         let zoneID = RecordSerializer.zoneID(for: gameID)
    842         let recordID = CKRecord.ID(recordName: "test-record", zoneID: zoneID)
    843         let original = CKRecord(recordType: "Cell", recordID: recordID)
    844 
    845         let encoded = RecordSerializer.encodeSystemFields(of: original)
    846         #expect(encoded != nil)
    847 
    848         let decoded = RecordSerializer.decodeRecord(from: encoded!)
    849         #expect(decoded != nil)
    850         #expect(decoded?.recordType == "Cell")
    851         #expect(decoded?.recordID.zoneID.zoneName == "game-\(gameID.uuidString)")
    852         #expect(decoded?.recordID.recordName == "test-record")
    853     }
    854 
    855     // MARK: - Decision records
    856 
    857     @Test("Decision record name uses decision-<kind>-<key> format")
    858     func decisionRecordNameFormat() {
    859         let name = RecordSerializer.decisionRecordName(kind: "block", key: "_bob")
    860         #expect(name == "decision-block-_bob")
    861     }
    862 
    863     @Test("parseDecisionRecordName round-trips, preserving dashes in the key")
    864     func decisionNameRoundTrip() {
    865         let name = RecordSerializer.decisionRecordName(kind: "block", key: "_b-o-b")
    866         let parsed = RecordSerializer.parseDecisionRecordName(name)
    867         #expect(parsed?.kind == "block")
    868         #expect(parsed?.key == "_b-o-b")
    869     }
    870 
    871     @Test("parseDecisionRecordName rejects non-decision and malformed names")
    872     func decisionNameRejectsOthers() {
    873         #expect(RecordSerializer.parseDecisionRecordName("ping-1234") == nil)
    874         #expect(RecordSerializer.parseDecisionRecordName("decision-block") == nil)
    875         #expect(RecordSerializer.parseDecisionRecordName("decision--k") == nil)
    876     }
    877 
    878     @Test("decisionRecord keeps identity in the name, not a key field")
    879     func decisionRecordFields() {
    880         let record = RecordSerializer.decisionRecord(
    881             kind: "block",
    882             key: "_bob",
    883             zone: RecordSerializer.accountZoneID
    884         )
    885         #expect(record.recordType == "Decision")
    886         // Identity (kind + key) lives in the record name.
    887         #expect(record.recordID.recordName == "decision-block-_bob")
    888         #expect(record.recordID.zoneID.zoneName == "account")
    889         #expect(record["kind"] as? String == "block")
    890         // `key` is not duplicated as a field; `payload` is unused for block.
    891         #expect(record["key"] == nil)
    892         #expect(record["payload"] == nil)
    893     }
    894 
    895     @Test("decisionRecord carries an optional payload when provided")
    896     func decisionRecordPayload() {
    897         let record = RecordSerializer.decisionRecord(
    898             kind: "snooze",
    899             key: "_bob",
    900             payload: "{\"until\":1}",
    901             zone: RecordSerializer.accountZoneID
    902         )
    903         #expect(record.recordID.recordName == "decision-snooze-_bob")
    904         #expect(record["payload"] as? String == "{\"until\":1}")
    905     }
    906 
    907     @Test("decisionRecord writes the version field only when provided")
    908     func decisionRecordVersion() {
    909         let unversioned = RecordSerializer.decisionRecord(
    910             kind: "account",
    911             key: "pushSecret",
    912             payload: "s",
    913             zone: RecordSerializer.accountZoneID
    914         )
    915         #expect(unversioned["version"] == nil)
    916 
    917         let versioned = RecordSerializer.decisionRecord(
    918             kind: "account",
    919             key: "pushSecret",
    920             payload: "s",
    921             zone: RecordSerializer.accountZoneID,
    922             version: 3
    923         )
    924         #expect(versioned["version"] as? Int64 == 3)
    925     }
    926 
    927     @Test("decisionVersion defaults to the base generation for a version-less record")
    928     func decisionVersionDefault() {
    929         let record = RecordSerializer.decisionRecord(
    930             kind: "account",
    931             key: "pushSecret",
    932             payload: "s",
    933             zone: RecordSerializer.accountZoneID
    934         )
    935         #expect(RecordSerializer.decisionVersion(record) == RecordSerializer.decisionBaseVersion)
    936     }
    937 
    938     @Test("parseAccountPushSecretDecision returns the secret and its generation")
    939     func parseAccountPushSecretDecisionReadsVersion() {
    940         let record = RecordSerializer.decisionRecord(
    941             kind: RecordSerializer.accountDecisionKind,
    942             key: RecordSerializer.accountPushSecretDecisionKey,
    943             payload: "the-secret",
    944             zone: RecordSerializer.accountZoneID,
    945             version: 5
    946         )
    947         let parsed = RecordSerializer.parseAccountPushSecretDecision(record, databaseScope: .private)
    948         #expect(parsed?.secret == "the-secret")
    949         #expect(parsed?.version == 5)
    950     }
    951 
    952     @Test("parseAccountPushSecretDecision rejects a copy from a friend inbox zone")
    953     func parseAccountPushSecretDecisionRejectsFriendZone() {
    954         // A friend holds .readWrite on our private friend-<pairKey> inbox (also
    955         // scope 0). A forged secret dropped there must never be adopted as our
    956         // own HMAC secret — only the account zone counts.
    957         let friendZone = CKRecordZone.ID(
    958             zoneName: FriendZone.zoneName(pairKey: "pair-abc"),
    959             ownerName: CKCurrentUserDefaultName
    960         )
    961         let record = RecordSerializer.decisionRecord(
    962             kind: RecordSerializer.accountDecisionKind,
    963             key: RecordSerializer.accountPushSecretDecisionKey,
    964             payload: "forged-secret",
    965             zone: friendZone,
    966             version: 99
    967         )
    968         #expect(RecordSerializer.parseAccountPushSecretDecision(record, databaseScope: .private) == nil)
    969     }
    970 
    971     @Test("parseAccountPushSecretDecision rejects an account-named zone in the shared scope")
    972     func parseAccountPushSecretDecisionRejectsSharedScope() {
    973         // A peer can own a zone that *names* itself like our account zone and
    974         // share it to us; it arrives via the shared DB (scope 1). The scope
    975         // gate rejects it even though the zone name matches.
    976         let record = RecordSerializer.decisionRecord(
    977             kind: RecordSerializer.accountDecisionKind,
    978             key: RecordSerializer.accountPushSecretDecisionKey,
    979             payload: "forged-secret",
    980             zone: RecordSerializer.accountZoneID,
    981             version: 99
    982         )
    983         #expect(RecordSerializer.parseAccountPushSecretDecision(record, databaseScope: .shared) == nil)
    984     }
    985 
    986     @Test("parseAccountPushSecretDecision treats a missing version as the base generation")
    987     func parseAccountPushSecretDecisionDefaultsVersion() {
    988         let record = RecordSerializer.decisionRecord(
    989             kind: RecordSerializer.accountDecisionKind,
    990             key: RecordSerializer.accountPushSecretDecisionKey,
    991             payload: "legacy-secret",
    992             zone: RecordSerializer.accountZoneID
    993         )
    994         let parsed = RecordSerializer.parseAccountPushSecretDecision(record, databaseScope: .private)
    995         #expect(parsed?.version == RecordSerializer.decisionBaseVersion)
    996     }
    997 
    998     @Test("applyDecisionRecord(.block) creates a blocked FriendEntity with derived pairKey")
    999     @MainActor func applyDecisionBlockCreatesTombstone() throws {
   1000         let persistence = makeTestPersistence()
   1001         let ctx = persistence.viewContext
   1002         let record = RecordSerializer.decisionRecord(
   1003             kind: "block",
   1004             key: "_bob",
   1005             zone: RecordSerializer.accountZoneID
   1006         )
   1007 
   1008         let wrote = RecordSerializer.applyDecisionRecord(
   1009             record,
   1010             to: ctx,
   1011             localAuthorID: "_alice"
   1012         )
   1013         #expect(wrote)
   1014 
   1015         let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity")
   1016         req.predicate = NSPredicate(format: "authorID == %@", "_bob")
   1017         let friend = try ctx.fetch(req).first
   1018         #expect(friend?.isBlocked == true)
   1019         #expect(friend?.pairKey == FriendZone.pairKey("_alice", "_bob"))
   1020     }
   1021 
   1022     @Test("applyDecisionRecord(.block) flips an existing active friend to blocked")
   1023     @MainActor func applyDecisionBlockMarksExistingFriend() throws {
   1024         let persistence = makeTestPersistence()
   1025         let ctx = persistence.viewContext
   1026 
   1027         let existing = FriendEntity(context: ctx)
   1028         existing.authorID = "_bob"
   1029         existing.pairKey = "k-existing"
   1030         existing.isBlocked = false
   1031         existing.createdAt = Date()
   1032         try ctx.save()
   1033 
   1034         let record = RecordSerializer.decisionRecord(
   1035             kind: "block",
   1036             key: "_bob",
   1037             zone: RecordSerializer.accountZoneID
   1038         )
   1039         RecordSerializer.applyDecisionRecord(record, to: ctx, localAuthorID: "_alice")
   1040 
   1041         let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity")
   1042         req.predicate = NSPredicate(format: "authorID == %@", "_bob")
   1043         let rows = try ctx.fetch(req)
   1044         // Upsert, not insert: the existing row flips rather than duplicating,
   1045         // and its original pairKey/zone are left intact.
   1046         #expect(rows.count == 1)
   1047         #expect(rows.first?.isBlocked == true)
   1048         #expect(rows.first?.pairKey == "k-existing")
   1049     }
   1050 
   1051     @Test("applyDecisionRecord(.block) is versioned: unblock clears, stale re-block ignored")
   1052     @MainActor func applyDecisionBlockUnblockVersioned() throws {
   1053         let persistence = makeTestPersistence()
   1054         let ctx = persistence.viewContext
   1055 
   1056         let existing = FriendEntity(context: ctx)
   1057         existing.authorID = "_bob"
   1058         existing.pairKey = "k"
   1059         existing.createdAt = Date()
   1060         try ctx.save()
   1061 
   1062         func blockDecision(_ payload: String, version: Int64) -> CKRecord {
   1063             RecordSerializer.decisionRecord(
   1064                 kind: RecordSerializer.blockDecisionKind,
   1065                 key: "_bob",
   1066                 payload: payload,
   1067                 zone: RecordSerializer.accountZoneID,
   1068                 version: version
   1069             )
   1070         }
   1071 
   1072         // Block at v1.
   1073         #expect(RecordSerializer.applyDecisionRecord(
   1074             blockDecision("1", version: 1), to: ctx, localAuthorID: "_alice"
   1075         ))
   1076         #expect(existing.isBlocked == true)
   1077         #expect(existing.blockVersion == 1)
   1078 
   1079         // Unblock at v2 supersedes it.
   1080         #expect(RecordSerializer.applyDecisionRecord(
   1081             blockDecision("0", version: 2), to: ctx, localAuthorID: "_alice"
   1082         ))
   1083         #expect(existing.isBlocked == false)
   1084         #expect(existing.blockVersion == 2)
   1085 
   1086         // A stale re-block at v1 is rejected — it can't resurrect the block.
   1087         #expect(!RecordSerializer.applyDecisionRecord(
   1088             blockDecision("1", version: 1), to: ctx, localAuthorID: "_alice"
   1089         ))
   1090         #expect(existing.isBlocked == false)
   1091         #expect(existing.blockVersion == 2)
   1092     }
   1093 
   1094     @Test("applyDecisionRecord(.block) rejects a block written into a friend zone")
   1095     @MainActor func applyDecisionBlockRejectsFriendZone() throws {
   1096         let persistence = makeTestPersistence()
   1097         let ctx = persistence.viewContext
   1098 
   1099         // A block is the user's own choice and only travels through the account
   1100         // zone in their private database. A friend (Mallory) holds `.readWrite`
   1101         // on the pair zone Alice owns, so she could drop `decision-block-_carol`
   1102         // there — even arriving at Alice's private scope (0), it must be
   1103         // rejected purely on zone, so it can't block a third party on her behalf.
   1104         let record = RecordSerializer.decisionRecord(
   1105             kind: RecordSerializer.blockDecisionKind,
   1106             key: "_carol",
   1107             zone: friendZoneID(local: "_alice", remote: "_mallory")
   1108         )
   1109         let wrote = RecordSerializer.applyDecisionRecord(
   1110             record, to: ctx, localAuthorID: "_alice", databaseScope: .private
   1111         )
   1112         #expect(!wrote)
   1113         let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity")
   1114         #expect(try ctx.count(for: req) == 0)
   1115     }
   1116 
   1117     @Test("applyDecisionRecord(.left) rejects a left decision written into a friend zone")
   1118     @MainActor func applyDecisionLeftRejectsFriendZone() throws {
   1119         let persistence = makeTestPersistence()
   1120         let ctx = persistence.viewContext
   1121 
   1122         // A `left` decision hard-deletes a participant game row. A friend who
   1123         // can name a live `gameID` must not be able to evict Alice's row by
   1124         // writing the decision into the pair zone he can reach — it is honored
   1125         // only from Alice's own account zone.
   1126         let gameID = UUID()
   1127         let entity = GameEntity(context: ctx)
   1128         entity.id = gameID
   1129         entity.title = "Shared"
   1130         entity.puzzleSource = ""
   1131         entity.databaseScope = 1
   1132         entity.createdAt = Date()
   1133         entity.updatedAt = Date()
   1134         try ctx.save()
   1135 
   1136         let record = RecordSerializer.decisionRecord(
   1137             kind: "left",
   1138             key: gameID.uuidString,
   1139             zone: friendZoneID(local: "_alice", remote: "_mallory")
   1140         )
   1141         let wrote = RecordSerializer.applyDecisionRecord(
   1142             record, to: ctx, localAuthorID: "_alice", databaseScope: .private
   1143         )
   1144         #expect(!wrote)
   1145         let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
   1146         req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
   1147         #expect(try ctx.count(for: req) == 1)
   1148     }
   1149 
   1150     @Test("applyDecisionRecord(.name) rejects a friend name seen at shared scope")
   1151     @MainActor func applyNameDecisionRejectsSharedScope() throws {
   1152         let persistence = makeTestPersistence()
   1153         let ctx = persistence.viewContext
   1154 
   1155         // A friend's name only arrives via the inbox we own (scope 0). The same
   1156         // record seen at scope 1 — the friend's own inbox — is not the channel
   1157         // they tell us their name through, so it is dropped.
   1158         let record = nameDecisionRecord(
   1159             subject: "_bob", name: "Brandon", version: 1,
   1160             zone: friendZoneID(local: "_alice", remote: "_bob")
   1161         )
   1162         let wrote = RecordSerializer.applyDecisionRecord(
   1163             record, to: ctx, localAuthorID: "_alice", databaseScope: .shared
   1164         )
   1165         #expect(!wrote)
   1166     }
   1167 
   1168     @Test("applyDecisionRecord ignores unknown kinds")
   1169     @MainActor func applyDecisionIgnoresUnknownKind() throws {
   1170         let persistence = makeTestPersistence()
   1171         let ctx = persistence.viewContext
   1172         let record = RecordSerializer.decisionRecord(
   1173             kind: "future",
   1174             key: "_bob",
   1175             zone: RecordSerializer.accountZoneID
   1176         )
   1177         let wrote = RecordSerializer.applyDecisionRecord(
   1178             record,
   1179             to: ctx,
   1180             localAuthorID: "_alice"
   1181         )
   1182         #expect(!wrote)
   1183         let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity")
   1184         #expect(try ctx.count(for: req) == 0)
   1185     }
   1186 
   1187     @Test("applyDecisionRecord(.encryptionKey) mirrors a peer key from its pair zone")
   1188     @MainActor func applyEncryptionKeyDecisionMirrorsPeerKey() async throws {
   1189         let url = FileManager.default.temporaryDirectory
   1190             .appendingPathComponent("friend-key-directory-\(UUID().uuidString).json")
   1191         defer { try? FileManager.default.removeItem(at: url) }
   1192 
   1193         try await FriendEncryptionKeyDirectory.$testingFileURL.withValue(url) {
   1194             let persistence = makeTestPersistence()
   1195             let ctx = persistence.viewContext
   1196             let payload = try #require(FriendEncryptionKeyPayload.fresh())
   1197             let record = RecordSerializer.decisionRecord(
   1198                 kind: RecordSerializer.encryptionKeyDecisionKind,
   1199                 key: "_bob",
   1200                 payload: payload.encodedString(),
   1201                 zone: friendZoneID(local: "_alice", remote: "_bob")
   1202             )
   1203 
   1204             let wrote = RecordSerializer.applyDecisionRecord(
   1205                 record,
   1206                 to: ctx,
   1207                 localAuthorID: "_alice"
   1208             )
   1209             #expect(!wrote)
   1210             #expect(FriendEncryptionKeyDirectory.payload(for: "_bob") == payload)
   1211             let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity")
   1212             #expect(try ctx.count(for: req) == 0)
   1213         }
   1214     }
   1215 
   1216     @Test("applyDecisionRecord(.encryptionKey) rejects a key seen at shared scope")
   1217     @MainActor func applyEncryptionKeyDecisionRejectsSharedScope() async throws {
   1218         let url = FileManager.default.temporaryDirectory
   1219             .appendingPathComponent("friend-key-directory-\(UUID().uuidString).json")
   1220         defer { try? FileManager.default.removeItem(at: url) }
   1221 
   1222         try await FriendEncryptionKeyDirectory.$testingFileURL.withValue(url) {
   1223             let persistence = makeTestPersistence()
   1224             let ctx = persistence.viewContext
   1225             let payload = try #require(FriendEncryptionKeyPayload.fresh())
   1226             // A peer owns a zone that *names* itself for this pair and shares it
   1227             // to us; it arrives via the shared DB (scope 1). The zone name
   1228             // matches, so only the scope gate rejects it — a forged key must not
   1229             // be mirrored into the directory the NSE reads.
   1230             let record = RecordSerializer.decisionRecord(
   1231                 kind: RecordSerializer.encryptionKeyDecisionKind,
   1232                 key: "_bob",
   1233                 payload: payload.encodedString(),
   1234                 zone: friendZoneID(local: "_alice", remote: "_bob")
   1235             )
   1236 
   1237             let wrote = RecordSerializer.applyDecisionRecord(
   1238                 record,
   1239                 to: ctx,
   1240                 localAuthorID: "_alice",
   1241                 databaseScope: .shared
   1242             )
   1243             #expect(!wrote)
   1244             #expect(FriendEncryptionKeyDirectory.payload(for: "_bob") == nil)
   1245         }
   1246     }
   1247 
   1248     // MARK: - Name decisions
   1249 
   1250     /// The pairwise friend zone for (`local`, `remote`) — the only zone a
   1251     /// name Decision for `remote` is honored from.
   1252     private func friendZoneID(local: String, remote: String) -> CKRecordZone.ID {
   1253         CKRecordZone.ID(
   1254             zoneName: FriendZone.zoneName(pairKey: FriendZone.pairKey(local, remote)),
   1255             ownerName: "_zone-owner"
   1256         )
   1257     }
   1258 
   1259     private func nameDecisionRecord(
   1260         subject: String,
   1261         name: String,
   1262         version: Int64,
   1263         zone: CKRecordZone.ID
   1264     ) -> CKRecord {
   1265         RecordSerializer.decisionRecord(
   1266             kind: RecordSerializer.nameDecisionKind,
   1267             key: subject,
   1268             payload: name,
   1269             zone: zone,
   1270             version: version
   1271         )
   1272     }
   1273 
   1274     @Test("applyDecisionRecord(.name) updates an existing friend from its pair zone")
   1275     @MainActor func applyNameDecisionUpdatesFriend() throws {
   1276         let persistence = makeTestPersistence()
   1277         let ctx = persistence.viewContext
   1278         let pairKey = FriendZone.pairKey("_alice", "_bob")
   1279         let existing = FriendEntity(context: ctx)
   1280         existing.authorID = "_bob"
   1281         existing.pairKey = pairKey
   1282         existing.createdAt = Date()
   1283         try ctx.save()
   1284 
   1285         let record = nameDecisionRecord(
   1286             subject: "_bob",
   1287             name: "Brandon",
   1288             version: 1,
   1289             zone: friendZoneID(local: "_alice", remote: "_bob")
   1290         )
   1291         let wrote = RecordSerializer.applyDecisionRecord(
   1292             record, to: ctx, localAuthorID: "_alice"
   1293         )
   1294         #expect(wrote)
   1295         #expect(existing.displayName == "Brandon")
   1296         #expect(existing.displayNameVersion == 1)
   1297     }
   1298 
   1299     @Test("applyDecisionRecord(.name) is last-writer-wins on version")
   1300     @MainActor func applyNameDecisionVersionGate() throws {
   1301         let persistence = makeTestPersistence()
   1302         let ctx = persistence.viewContext
   1303         let pairKey = FriendZone.pairKey("_alice", "_bob")
   1304         let existing = FriendEntity(context: ctx)
   1305         existing.authorID = "_bob"
   1306         existing.pairKey = pairKey
   1307         existing.createdAt = Date()
   1308         existing.displayName = "Brandon"
   1309         existing.displayNameVersion = 3
   1310         try ctx.save()
   1311 
   1312         let zone = friendZoneID(local: "_alice", remote: "_bob")
   1313         let stale = nameDecisionRecord(subject: "_bob", name: "Old", version: 2, zone: zone)
   1314         #expect(!RecordSerializer.applyDecisionRecord(stale, to: ctx, localAuthorID: "_alice"))
   1315         #expect(existing.displayName == "Brandon")
   1316 
   1317         let equal = nameDecisionRecord(subject: "_bob", name: "Bran", version: 3, zone: zone)
   1318         #expect(RecordSerializer.applyDecisionRecord(equal, to: ctx, localAuthorID: "_alice"))
   1319         #expect(existing.displayName == "Bran")
   1320 
   1321         let newer = nameDecisionRecord(subject: "_bob", name: "Brandon II", version: 4, zone: zone)
   1322         #expect(RecordSerializer.applyDecisionRecord(newer, to: ctx, localAuthorID: "_alice"))
   1323         #expect(existing.displayName == "Brandon II")
   1324         #expect(existing.displayNameVersion == 4)
   1325     }
   1326 
   1327     @Test("applyDecisionRecord(.name) resurrects a friendship from our inbox")
   1328     @MainActor func applyNameDecisionResurrectsFriend() throws {
   1329         let persistence = makeTestPersistence()
   1330         let ctx = persistence.viewContext
   1331         let zone = friendZoneID(local: "_alice", remote: "_bob")
   1332 
   1333         // A friend's name arrives via the inbox we own (private engine,
   1334         // scope 0).
   1335         let record = nameDecisionRecord(subject: "_bob", name: "Brandon", version: 2, zone: zone)
   1336         let wrote = RecordSerializer.applyDecisionRecord(
   1337             record, to: ctx, localAuthorID: "_alice", databaseScope: .private
   1338         )
   1339         #expect(wrote)
   1340 
   1341         let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity")
   1342         req.predicate = NSPredicate(format: "authorID == %@", "_bob")
   1343         let friend = try ctx.fetch(req).first
   1344         #expect(friend?.displayName == "Brandon")
   1345         #expect(friend?.pairKey == FriendZone.pairKey("_alice", "_bob"))
   1346         #expect(friend?.isBlocked == false)
   1347     }
   1348 
   1349     @Test("applyDecisionRecord(.name) rejects a record outside the pair's zone")
   1350     @MainActor func applyNameDecisionRejectsForeignZone() throws {
   1351         let persistence = makeTestPersistence()
   1352         let ctx = persistence.viewContext
   1353 
   1354         // A name for _carol arriving in the (_alice, _bob) zone: the zone
   1355         // hash doesn't match the (_alice, _carol) pair, so it must be dropped
   1356         // — a friend can't assert names for third parties.
   1357         let record = nameDecisionRecord(
   1358             subject: "_carol",
   1359             name: "Mallory",
   1360             version: 9,
   1361             zone: friendZoneID(local: "_alice", remote: "_bob")
   1362         )
   1363         let wrote = RecordSerializer.applyDecisionRecord(
   1364             record, to: ctx, localAuthorID: "_alice"
   1365         )
   1366         #expect(!wrote)
   1367         let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity")
   1368         #expect(try ctx.count(for: req) == 0)
   1369     }
   1370 
   1371     @Test("applyDecisionRecord(.name) ignores our own name and writes no row")
   1372     @MainActor func applyNameDecisionIgnoresSelf() throws {
   1373         let persistence = makeTestPersistence()
   1374         let ctx = persistence.viewContext
   1375 
   1376         let record = nameDecisionRecord(
   1377             subject: "_alice",
   1378             name: "Alice",
   1379             version: 5,
   1380             zone: RecordSerializer.accountZoneID
   1381         )
   1382         let wrote = RecordSerializer.applyDecisionRecord(
   1383             record, to: ctx, localAuthorID: "_alice"
   1384         )
   1385         #expect(!wrote)
   1386         let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity")
   1387         #expect(try ctx.count(for: req) == 0)
   1388     }
   1389 
   1390     @Test("applyDecisionRecord(.name) leaves a blocked friend untouched")
   1391     @MainActor func applyNameDecisionSkipsBlocked() throws {
   1392         let persistence = makeTestPersistence()
   1393         let ctx = persistence.viewContext
   1394         let pairKey = FriendZone.pairKey("_alice", "_bob")
   1395         let blocked = FriendEntity(context: ctx)
   1396         blocked.authorID = "_bob"
   1397         blocked.pairKey = pairKey
   1398         blocked.isBlocked = true
   1399         blocked.createdAt = Date()
   1400         try ctx.save()
   1401 
   1402         let record = nameDecisionRecord(
   1403             subject: "_bob",
   1404             name: "Brandon",
   1405             version: 1,
   1406             zone: friendZoneID(local: "_alice", remote: "_bob")
   1407         )
   1408         let wrote = RecordSerializer.applyDecisionRecord(
   1409             record, to: ctx, localAuthorID: "_alice"
   1410         )
   1411         #expect(!wrote)
   1412         #expect(blocked.displayName?.isEmpty != false)
   1413         #expect(blocked.isBlocked == true)
   1414     }
   1415 
   1416     // MARK: - Nickname decisions
   1417 
   1418     private func nicknameDecisionRecord(
   1419         subject: String,
   1420         nickname: String?,
   1421         version: Int64,
   1422         zone: CKRecordZone.ID = RecordSerializer.accountZoneID
   1423     ) -> CKRecord {
   1424         RecordSerializer.decisionRecord(
   1425             kind: RecordSerializer.nicknameDecisionKind,
   1426             key: subject,
   1427             payload: nickname,
   1428             zone: zone,
   1429             version: version
   1430         )
   1431     }
   1432 
   1433     @MainActor
   1434     private func makeFriend(
   1435         in ctx: NSManagedObjectContext,
   1436         authorID: String,
   1437         pairedWith localAuthorID: String
   1438     ) throws -> FriendEntity {
   1439         let pairKey = FriendZone.pairKey(localAuthorID, authorID)
   1440         let friend = FriendEntity(context: ctx)
   1441         friend.authorID = authorID
   1442         friend.pairKey = pairKey
   1443         friend.createdAt = Date()
   1444         try ctx.save()
   1445         return friend
   1446     }
   1447 
   1448     @Test("applyDecisionRecord(.nickname) sets the nickname on an existing friend")
   1449     @MainActor func applyNicknameDecisionUpdatesFriend() throws {
   1450         let persistence = makeTestPersistence()
   1451         let ctx = persistence.viewContext
   1452         let friend = try makeFriend(in: ctx, authorID: "_bob", pairedWith: "_alice")
   1453 
   1454         let record = nicknameDecisionRecord(subject: "_bob", nickname: "Bobby", version: 1)
   1455         let wrote = RecordSerializer.applyDecisionRecord(
   1456             record, to: ctx, localAuthorID: "_alice"
   1457         )
   1458         #expect(wrote)
   1459         #expect(friend.nickname == "Bobby")
   1460         #expect(friend.nicknameVersion == 1)
   1461         #expect(friend.resolvedDisplayName == "Bobby")
   1462     }
   1463 
   1464     @Test("applyDecisionRecord(.nickname) is last-writer-wins on version")
   1465     @MainActor func applyNicknameDecisionVersionGate() throws {
   1466         let persistence = makeTestPersistence()
   1467         let ctx = persistence.viewContext
   1468         let friend = try makeFriend(in: ctx, authorID: "_bob", pairedWith: "_alice")
   1469         friend.nickname = "Bobby"
   1470         friend.nicknameVersion = 3
   1471         try ctx.save()
   1472 
   1473         let stale = nicknameDecisionRecord(subject: "_bob", nickname: "Old", version: 2)
   1474         #expect(!RecordSerializer.applyDecisionRecord(stale, to: ctx, localAuthorID: "_alice"))
   1475         #expect(friend.nickname == "Bobby")
   1476 
   1477         let equal = nicknameDecisionRecord(subject: "_bob", nickname: "Rob", version: 3)
   1478         #expect(RecordSerializer.applyDecisionRecord(equal, to: ctx, localAuthorID: "_alice"))
   1479         #expect(friend.nickname == "Rob")
   1480 
   1481         let newer = nicknameDecisionRecord(subject: "_bob", nickname: "Robert", version: 4)
   1482         #expect(RecordSerializer.applyDecisionRecord(newer, to: ctx, localAuthorID: "_alice"))
   1483         #expect(friend.nickname == "Robert")
   1484         #expect(friend.nicknameVersion == 4)
   1485     }
   1486 
   1487     @Test("applyDecisionRecord(.nickname) clears the nickname on an empty payload")
   1488     @MainActor func applyNicknameDecisionClears() throws {
   1489         let persistence = makeTestPersistence()
   1490         let ctx = persistence.viewContext
   1491         let friend = try makeFriend(in: ctx, authorID: "_bob", pairedWith: "_alice")
   1492         friend.displayName = "Brandon"
   1493         friend.displayNameVersion = 1
   1494         friend.nickname = "Bobby"
   1495         friend.nicknameVersion = 1
   1496         try ctx.save()
   1497 
   1498         let record = nicknameDecisionRecord(subject: "_bob", nickname: nil, version: 2)
   1499         let wrote = RecordSerializer.applyDecisionRecord(
   1500             record, to: ctx, localAuthorID: "_alice"
   1501         )
   1502         #expect(wrote)
   1503         #expect(friend.nickname == nil)
   1504         #expect(friend.nicknameVersion == 2)
   1505         // Cleared nickname falls back to the friend's own synced name.
   1506         #expect(friend.resolvedDisplayName == "Brandon")
   1507     }
   1508 
   1509     @Test("applyDecisionRecord(.nickname) rejects a record outside the account zone")
   1510     @MainActor func applyNicknameDecisionRejectsFriendZone() throws {
   1511         let persistence = makeTestPersistence()
   1512         let ctx = persistence.viewContext
   1513         let friend = try makeFriend(in: ctx, authorID: "_bob", pairedWith: "_alice")
   1514 
   1515         // A "nickname" decision planted by the friend in the shared pairwise
   1516         // zone must not relabel anyone in this user's list.
   1517         let record = nicknameDecisionRecord(
   1518             subject: "_bob",
   1519             nickname: "Gotcha",
   1520             version: 9,
   1521             zone: friendZoneID(local: "_alice", remote: "_bob")
   1522         )
   1523         let wrote = RecordSerializer.applyDecisionRecord(
   1524             record, to: ctx, localAuthorID: "_alice"
   1525         )
   1526         #expect(!wrote)
   1527         #expect(friend.nickname?.isEmpty != false)
   1528     }
   1529 
   1530     @Test("applyDecisionRecord(.nickname) applies a record fetched with a concrete owner name")
   1531     @MainActor func applyNicknameDecisionConcreteOwnerName() throws {
   1532         let persistence = makeTestPersistence()
   1533         let ctx = persistence.viewContext
   1534         let friend = try makeFriend(in: ctx, authorID: "_bob", pairedWith: "_alice")
   1535 
   1536         // A Decision written with the `CKCurrentUserDefaultName` placeholder
   1537         // comes back from CloudKit with the concrete user-record ID as its
   1538         // zone owner. The apply path must still recognise it as the account
   1539         // zone (zone *name* + private scope), or no nickname ever syncs.
   1540         let fetchedZone = CKRecordZone.ID(
   1541             zoneName: RecordSerializer.accountZoneID.zoneName,
   1542             ownerName: "_alice"
   1543         )
   1544         let record = nicknameDecisionRecord(
   1545             subject: "_bob", nickname: "Bobby", version: 1, zone: fetchedZone
   1546         )
   1547         let wrote = RecordSerializer.applyDecisionRecord(
   1548             record, to: ctx, localAuthorID: "_alice", databaseScope: .private
   1549         )
   1550         #expect(wrote)
   1551         #expect(friend.nickname == "Bobby")
   1552         #expect(friend.nicknameVersion == 1)
   1553     }
   1554 
   1555     @Test("applyDecisionRecord(.nickname) rejects an account-named zone in the shared DB")
   1556     @MainActor func applyNicknameDecisionRejectsSharedAccountZone() throws {
   1557         let persistence = makeTestPersistence()
   1558         let ctx = persistence.viewContext
   1559         let friend = try makeFriend(in: ctx, authorID: "_bob", pairedWith: "_alice")
   1560 
   1561         // A friend cannot reach our private DB; spoofing the relabel means
   1562         // sharing a zone they named "account" into our *shared* DB. The
   1563         // private-scope gate must reject it even though the zone name matches.
   1564         let spoofZone = CKRecordZone.ID(
   1565             zoneName: RecordSerializer.accountZoneID.zoneName,
   1566             ownerName: "_bob"
   1567         )
   1568         let record = nicknameDecisionRecord(
   1569             subject: "_bob", nickname: "Gotcha", version: 9, zone: spoofZone
   1570         )
   1571         let wrote = RecordSerializer.applyDecisionRecord(
   1572             record, to: ctx, localAuthorID: "_alice", databaseScope: .shared
   1573         )
   1574         #expect(!wrote)
   1575         #expect(friend.nickname?.isEmpty != false)
   1576     }
   1577 
   1578     @Test("applyDecisionRecord(.nickname) writes no row for an unknown friend")
   1579     @MainActor func applyNicknameDecisionSkipsUnknownFriend() throws {
   1580         let persistence = makeTestPersistence()
   1581         let ctx = persistence.viewContext
   1582 
   1583         let record = nicknameDecisionRecord(subject: "_bob", nickname: "Bobby", version: 1)
   1584         let wrote = RecordSerializer.applyDecisionRecord(
   1585             record, to: ctx, localAuthorID: "_alice"
   1586         )
   1587         #expect(!wrote)
   1588         let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity")
   1589         #expect(try ctx.count(for: req) == 0)
   1590     }
   1591 
   1592     @Test("parseNameDecision reads subject, name, and version")
   1593     func parseNameDecisionFields() {
   1594         let record = nameDecisionRecord(
   1595             subject: "_bob",
   1596             name: "Brandon",
   1597             version: 7,
   1598             zone: RecordSerializer.accountZoneID
   1599         )
   1600         let parsed = RecordSerializer.parseNameDecision(record)
   1601         #expect(parsed?.authorID == "_bob")
   1602         #expect(parsed?.name == "Brandon")
   1603         #expect(parsed?.version == 7)
   1604         // Non-name decisions don't parse.
   1605         let block = RecordSerializer.decisionRecord(
   1606             kind: "block", key: "_bob", zone: RecordSerializer.accountZoneID
   1607         )
   1608         #expect(RecordSerializer.parseNameDecision(block) == nil)
   1609     }
   1610 
   1611     @Test("applyDecisionRecord(.left) hard-deletes the participant game row")
   1612     @MainActor func applyDecisionLeftDeletesParticipantGame() throws {
   1613         let persistence = makeTestPersistence()
   1614         let ctx = persistence.viewContext
   1615         let gameID = UUID()
   1616         let entity = GameEntity(context: ctx)
   1617         entity.id = gameID
   1618         entity.title = "Shared"
   1619         entity.puzzleSource = ""
   1620         entity.databaseScope = 1
   1621         entity.createdAt = Date()
   1622         entity.updatedAt = Date()
   1623         try ctx.save()
   1624 
   1625         let record = RecordSerializer.decisionRecord(
   1626             kind: "left",
   1627             key: gameID.uuidString,
   1628             zone: RecordSerializer.accountZoneID
   1629         )
   1630         let wrote = RecordSerializer.applyDecisionRecord(
   1631             record, to: ctx, localAuthorID: "_alice"
   1632         )
   1633         #expect(wrote)
   1634 
   1635         let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
   1636         req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
   1637         #expect(try ctx.count(for: req) == 0)
   1638     }
   1639 
   1640     @Test("applyDecisionRecord(.left) leaves an owned (scope 0) row intact")
   1641     @MainActor func applyDecisionLeftSkipsOwnedGame() throws {
   1642         let persistence = makeTestPersistence()
   1643         let ctx = persistence.viewContext
   1644         let gameID = UUID()
   1645         let entity = GameEntity(context: ctx)
   1646         entity.id = gameID
   1647         entity.title = "Owned"
   1648         entity.puzzleSource = ""
   1649         entity.databaseScope = 0
   1650         entity.createdAt = Date()
   1651         entity.updatedAt = Date()
   1652         try ctx.save()
   1653 
   1654         let record = RecordSerializer.decisionRecord(
   1655             kind: "left",
   1656             key: gameID.uuidString,
   1657             zone: RecordSerializer.accountZoneID
   1658         )
   1659         let wrote = RecordSerializer.applyDecisionRecord(
   1660             record, to: ctx, localAuthorID: "_alice"
   1661         )
   1662         // A `left` fact never applies to an owned copy — the participant who
   1663         // left can't be the owner of the same game id.
   1664         #expect(!wrote)
   1665         let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
   1666         req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
   1667         #expect(try ctx.count(for: req) == 1)
   1668     }
   1669 
   1670     @Test("applyDecisionRecord(.left) is a no-op when the row is already gone")
   1671     @MainActor func applyDecisionLeftIdempotent() {
   1672         let persistence = makeTestPersistence()
   1673         let ctx = persistence.viewContext
   1674         let record = RecordSerializer.decisionRecord(
   1675             kind: "left",
   1676             key: UUID().uuidString,
   1677             zone: RecordSerializer.accountZoneID
   1678         )
   1679         let wrote = RecordSerializer.applyDecisionRecord(
   1680             record, to: ctx, localAuthorID: "_alice"
   1681         )
   1682         #expect(!wrote)
   1683     }
   1684 
   1685     @Test("applyDecisionRecord(.left) rejects a non-UUID key")
   1686     @MainActor func applyDecisionLeftRejectsBadKey() {
   1687         let persistence = makeTestPersistence()
   1688         let ctx = persistence.viewContext
   1689         let record = RecordSerializer.decisionRecord(
   1690             kind: "left",
   1691             key: "not-a-uuid",
   1692             zone: RecordSerializer.accountZoneID
   1693         )
   1694         #expect(
   1695             !RecordSerializer.applyDecisionRecord(
   1696                 record, to: ctx, localAuthorID: "_alice"
   1697             )
   1698         )
   1699     }
   1700 }