commit c79ed4b81c9003a9eb97455360a6d8ead651e685
parent 7a8818904fad958c04b730a74cd46ac63b6c93d1
Author: Michael Camilleri <[email protected]>
Date: Thu, 23 Jul 2026 20:44:37 +0900
Keep Chronicles out of live game synchronisation
Opening a materialised Chronicle could start the live-game lifecycle,
writing clock, presence and cursor Player records into the compact
archive zone.
This commit marks archived projections explicitly and prevents them from
starting grid sync, engagement or other live session work while
retaining their participant attribution and replay. A segregated
one-shot migration cancels pending writes and removes stray Player
records already stored in the Chronicle zone without touching the
archived roster.
Co-Authored-By: Codex GPT 5.6 Sol <[email protected]>
Diffstat:
7 files changed, 121 insertions(+), 3 deletions(-)
diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift
@@ -802,12 +802,17 @@ private extension UIApplication {
/// Loads a game when navigated to.
private struct PuzzleDisplayView: View {
private var syncedID: UUID? {
- preferences.isICloudSyncEnabled && session != nil ? gameID : nil
+ guard preferences.isICloudSyncEnabled,
+ let mutator = session?.mutator,
+ !mutator.isArchived
+ else { return nil }
+ return gameID
}
private var syncedScope: CKDatabase.Scope? {
guard preferences.isICloudSyncEnabled,
- let mutator = session?.mutator
+ let mutator = session?.mutator,
+ !mutator.isArchived
else { return nil }
return mutator.isOwned ? .private : .shared
}
@@ -833,6 +838,7 @@ private struct PuzzleDisplayView: View {
shareController: shareController,
roster: roster,
onComplete: { notifyPeers in
+ guard !session.mutator.isArchived else { return }
do {
let changed = try notifyPeers
? store.markCompleted(id: gameID)
@@ -939,7 +945,6 @@ private struct PuzzleDisplayView: View {
roster = nil
loadError = nil
loadingMessage = "Loading puzzle…"
- noteSessionPhase(scenePhase)
Task { await services.badge.dismissDeliveredNotifications(for: gameID) }
do {
@@ -970,6 +975,7 @@ private struct PuzzleDisplayView: View {
guard !Task.isCancelled else { return }
roster = newRoster
session = newSession
+ noteSessionPhase(scenePhase)
openPuzzleFollowUpTask = Task { @MainActor in
await finishOpeningPuzzle(
session: newSession,
@@ -1006,6 +1012,7 @@ private struct PuzzleDisplayView: View {
Task { await activateSharing(for: session) }
}
.onChange(of: scenePhase) { _, newPhase in
+ guard session?.mutator.isArchived == false else { return }
noteSessionPhase(newPhase)
// Only act on settled transitions. `.inactive` is transient (lock
// animation, app switcher, Control Center, banners), so a write
@@ -1049,6 +1056,7 @@ private struct PuzzleDisplayView: View {
.onDisappear {
openPuzzleFollowUpTask?.cancel()
openPuzzleFollowUpTask = nil
+ guard session?.mutator.isArchived == false else { return }
let selectionPublisher = services.playerSelectionPublisher
let movesUpdater = services.movesUpdater
let id = gameID
@@ -1095,6 +1103,13 @@ private struct PuzzleDisplayView: View {
for announcement in OpenPuzzleBanner.announcements(for: openState) {
services.announcements.post(announcement)
}
+ if loadedSession.mutator.isArchived {
+ services.syncMonitor.note(
+ "PuzzleDisplay[\(gameID.uuidString.prefix(8))]: loaded Chronicle roster " +
+ "(live lifecycle disabled)"
+ )
+ return
+ }
if isShared && preferences.isICloudSyncEnabled {
services.syncMonitor.note(
"PuzzleDisplay[\(gameID.uuidString.prefix(8))]: loaded shared roster"
@@ -1114,6 +1129,7 @@ private struct PuzzleDisplayView: View {
/// the begin/end/grace choreography (active-puzzle ID, deferred play and
/// pause pushes, catch-up banner).
private func noteSessionPhase(_ phase: ScenePhase) {
+ guard session?.mutator.isArchived == false else { return }
switch phase {
case .active:
services.sessions.notePuzzleActive(gameID: gameID)
diff --git a/Crossmate/Persistence/GameMutator.swift b/Crossmate/Persistence/GameMutator.swift
@@ -48,6 +48,11 @@ final class GameMutator {
}
private let hasArchivedPlayerAttribution: Bool
+ /// `true` for the local read-only projection of a Chronicle. It retains
+ /// participant display and replay data, but must never enter live-game
+ /// clock, presence, engagement, cursor, or CloudKit write paths.
+ let isArchived: Bool
+
/// Set to `true` when the owner has revoked the current user's access to
/// a shared game. Revocation makes the game read-only: every mutating
/// entry point below is a no-op (via `isEditable`) and `PuzzleView` shows
@@ -92,6 +97,7 @@ final class GameMutator {
isOwned: Bool = true,
isShared: Bool = false,
showsPlayerAttribution: Bool? = nil,
+ isArchived: Bool = false,
isAccessRevoked: Bool = false,
isSyncSupported: Bool = true,
syncVersion: Int64 = GameSyncVersion.current,
@@ -108,6 +114,7 @@ final class GameMutator {
self.isOwned = isOwned
self.isShared = isShared
self.hasArchivedPlayerAttribution = showsPlayerAttribution ?? isShared
+ self.isArchived = isArchived
self.isAccessRevoked = isAccessRevoked
self.isSyncSupported = isSyncSupported
self.usesLogicalTicks = GameSyncVersion.normalized(syncVersion) >= GameSyncVersion.logicalTicks
diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift
@@ -3048,6 +3048,7 @@ final class GameStore {
showsPlayerAttribution: entity.ckShareRecordName != nil
|| entity.databaseScope == 1
|| isArchivedSharedGame(entity),
+ isArchived: isMaterializedArchive(entity),
isAccessRevoked: entity.isAccessRevoked,
isSyncSupported: GameSyncVersion.supports(entity.syncVersion),
syncVersion: entity.syncVersion,
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -1351,6 +1351,17 @@ final class AppServices {
syncMonitor.note("iCloud sync disabled — engine startup skipped")
return
}
+
+ // TEMPORARY v1.1 MIGRATION: early Chronicle builds opened their local
+ // projections as live games and consequently wrote Player records into
+ // the private Chronicle zone. The permanent lifecycle gate lives with
+ // PuzzleDisplayView; this one-shot repair can later be removed as one
+ // block together with `purgeChroniclePlayers_v1` and its
+ // NotificationState flag.
+ Task { [syncEngine] in
+ await syncEngine.purgeChroniclePlayers_v1()
+ }
+
// One-shot migration of pre-existing single-zone friendships to the
// two-mailbox model. Runs only once the engine is up (it enqueues zone
// creates, a share, and a bootstrap ping) and only with sync enabled.
diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift
@@ -1048,6 +1048,54 @@ actor SyncEngine {
}
}
+ /// TEMPORARY v1.1 MIGRATION: removes `Player` records accidentally written
+ /// into the private Chronicle zone by early Chronicle builds. Remove this
+ /// method together with its AppServices startup call and NotificationState
+ /// flag after those builds have aged out.
+ func purgeChroniclePlayers_v1() async {
+ guard NotificationState.chroniclePlayerPurgeNeeded() else { return }
+
+ // Cancel unsent writes first; otherwise a queued clock/presence update
+ // could recreate a record immediately after the server-side deletion.
+ if let privateEngine {
+ let pendingSaves = privateEngine.state.pendingRecordZoneChanges.filter { change in
+ guard case .saveRecord(let recordID) = change else { return false }
+ return Self.isChroniclePlayerRecordID(recordID)
+ }
+ if !pendingSaves.isEmpty {
+ privateEngine.state.remove(pendingRecordZoneChanges: pendingSaves)
+ }
+ }
+
+ do {
+ let records = try await queryRecords(
+ type: "Player",
+ database: container.privateCloudDatabase,
+ zoneID: Archive.zoneID,
+ predicate: NSPredicate(value: true),
+ desiredKeys: []
+ )
+ try await deleteRecords(
+ withIDs: records.map(\.recordID),
+ in: container.privateCloudDatabase
+ )
+ NotificationState.markChroniclePlayersPurged()
+ await trace(
+ "Chronicle Player purge: cancelled pending saves and deleted " +
+ "\(records.count) record(s)"
+ )
+ } catch {
+ await trace("Chronicle Player purge failed: \(describe(error))")
+ }
+ }
+
+ /// Pure classifier shared with tests so the migration cannot remove a
+ /// Player record from a live game zone.
+ nonisolated static func isChroniclePlayerRecordID(_ recordID: CKRecord.ID) -> Bool {
+ recordID.zoneID == Archive.zoneID
+ && RecordSerializer.parsePlayerRecordName(recordID.recordName) != nil
+ }
+
private func purgeLegacyPlayPings(
in zoneIDs: [CKRecordZone.ID],
database: CKDatabase
diff --git a/Shared/NotificationState.swift b/Shared/NotificationState.swift
@@ -106,6 +106,7 @@ enum NotificationState {
private static let staleHailPurgeKey = "migration.staleHailPurge.v1"
private static let debugPreviewFriendPurgeKey = "migration.debugPreviewFriendPurge.v1"
private static let legacyPlayPingPurgeKey = "migration.legacyPlayPingPurge.v1"
+ private static let chroniclePlayerPurgeKey = "migration.chroniclePlayerPurge.v1"
private static let friendMailboxMigrationKey = "migration.friendMailboxes.v1"
/// True if the one-shot cleanup of legacy `.opened`/`.closed` lease pings
@@ -172,6 +173,17 @@ enum NotificationState {
defaults?.set(true, forKey: legacyPlayPingPurgeKey)
}
+ /// True until this device has successfully removed the `Player` records
+ /// written into the Chronicle zone by the first v1.1 builds.
+ static func chroniclePlayerPurgeNeeded() -> Bool {
+ defaults?.bool(forKey: chroniclePlayerPurgeKey) == false
+ }
+
+ /// Records that the Chronicle-zone Player cleanup completed successfully.
+ static func markChroniclePlayersPurged() {
+ defaults?.set(true, forKey: chroniclePlayerPurgeKey)
+ }
+
/// True if the one-shot migration of pre-mailbox friendships (a single
/// elected-owner shared zone) to the two-mailbox model has not yet run on
/// this device.
diff --git a/Tests/Unit/ArchiveTests.swift b/Tests/Unit/ArchiveTests.swift
@@ -98,6 +98,28 @@ struct ArchiveTests {
private let aliceKey = JournalDeviceKey(authorID: "alice", deviceID: "deviceA")
private let bobKey = JournalDeviceKey(authorID: "bob", deviceID: "deviceB")
+ @Test("Chronicle Player cleanup is confined to Player IDs in the archive zone")
+ func chroniclePlayerCleanupScope() {
+ let gameID = UUID()
+ let playerName = "player-\(gameID.uuidString)-alice"
+ let archivePlayer = CKRecord.ID(recordName: playerName, zoneID: Archive.zoneID)
+ let livePlayer = CKRecord.ID(
+ recordName: playerName,
+ zoneID: CKRecordZone.ID(
+ zoneName: gameID.uuidString,
+ ownerName: CKCurrentUserDefaultName
+ )
+ )
+ let chronicle = CKRecord.ID(
+ recordName: "chronicle-\(gameID.uuidString)",
+ zoneID: Archive.zoneID
+ )
+
+ #expect(SyncEngine.isChroniclePlayerRecordID(archivePlayer))
+ #expect(!SyncEngine.isChroniclePlayerRecordID(livePlayer))
+ #expect(!SyncEngine.isChroniclePlayerRecordID(chronicle))
+ }
+
private func sampleSnapshot(originalGameID: UUID) -> Archive.Snapshot {
Archive.Snapshot(
originalGameID: originalGameID,
@@ -665,6 +687,7 @@ struct ArchiveTests {
)
let (game, mutator) = try store.loadGame(id: entity.id!)
#expect(!mutator.isShared)
+ #expect(mutator.isArchived)
#expect(mutator.showsPlayerAttribution)
#expect(game.squares[0][0].letterAuthorID == "alice")
#expect(game.squares[0][1].letterAuthorID == "bob")