commit 8b863f6f90450e743d3d2798952bdba95893136e
parent d6dd57f293fb8b545143caeb51e2818ae9be0992
Author: Michael Camilleri <[email protected]>
Date: Tue, 9 Jun 2026 00:09:40 +0900
Allow the account push secret to be rotated
This commit makes a Decision record updatable rather than write-once, so
the account-wide push secret can be replaced. Each Decision now carries
a monotonic version field and rebuilds from its cached CloudKit system
fields, so a re-send arrives with the current change tag instead of
colliding as a fresh create. A higher version wins; an equal version
converges on whoever reached the server first. A version-less record
written by the old code reads as the base generation, matching a fresh
mint, so legacy and newly-minted secrets converge and only a deliberate
rotation supersedes them.
The send path resolves a server-record-changed conflict on version: a
deliberate, newer write adopts the server's tag and keeps the change
pending so the retry overwrites, exactly as the Game and Moves recovery
already does, while an equal or older version settles and adopts the
winner's value as before. Inbound adoption is gated on the same version
so a late stale copy cannot undo a rotation. AppServices mints the first
generation on demand and exposes rotateAccountPushSecret, which
generates a fresh secret at the next generation, publishes it, and
reconciles every derived per-game address.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat:
6 files changed, 313 insertions(+), 62 deletions(-)
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -39,6 +39,10 @@ final class AppServices {
private static let engagementReconnectInterval: Duration = .seconds(30)
private static let accountPushAddressDefaultsPrefix = "push.accountAddress."
private static let accountPushSecretDefaultsPrefix = "push.accountSecret."
+ /// Generation of the locally-held push secret. Bumped on a deliberate
+ /// rotation and tracked so a stale inbound copy can't supersede the current
+ /// value (see `RecordSerializer.decisionVersion`).
+ private static let accountPushSecretVersionDefaultsPrefix = "push.accountSecretVersion."
private static let accountJoinedPushKind = "accountJoined"
private static let accountSeenPushKind = "accountSeen"
@@ -512,14 +516,22 @@ final class AppServices {
await self.reconcilePushRegistration()
}
- await syncEngine.setOnAccountPushSecret { [weak self] secret in
+ await syncEngine.setOnAccountPushSecret { [weak self] secret, version in
guard let self,
let authorID = self.identity.currentID,
!authorID.isEmpty else { return }
- // Adopting a sibling's secret (or a rotation) changes every derived
- // address; reconcile re-derives, rewrites the local Player rows, and
- // re-registers the new set with the worker.
- self.cacheAccountPushSecret(secret, authorID: authorID)
+ // Gate adoption on the generation: take a strictly newer secret (a
+ // rotation), or an equal-generation one that differs (the mint-race
+ // loser converging on the server's value). Ignore an older copy so a
+ // late-arriving stale Decision can't undo a rotation. Adopting
+ // changes every derived address, so reconcile re-derives, rewrites
+ // the local Player rows, and re-registers the new set with the worker.
+ let local = self.accountPushSecretVersion(authorID: authorID)
+ let current = UserDefaults.standard.string(
+ forKey: self.accountPushSecretDefaultsKey(authorID: authorID)
+ )
+ guard version > local || (version == local && secret != current) else { return }
+ self.cacheAccountPushSecret(secret, version: version, authorID: authorID)
await self.reconcilePushRegistration()
}
@@ -828,32 +840,79 @@ final class AppServices {
/// Mints (if needed) the account-wide push secret — the HMAC key every
/// per-game push address is derived from. Converges across the account's own
/// devices through a `Decision` exactly like the account address; never sent
- /// to peers or the worker, so only the account's devices can derive.
+ /// to peers or the worker, so only the account's devices can derive. A fresh
+ /// mint starts at `decisionBaseVersion`; a deliberate replacement goes
+ /// through `rotateAccountPushSecret`, which bumps the generation so it
+ /// supersedes the converged value.
private func ensureAccountPushSecret(authorID: String) -> String {
let key = accountPushSecretDefaultsKey(authorID: authorID)
if let existing = UserDefaults.standard.string(forKey: key), !existing.isEmpty {
return existing
}
+ let secret = Self.generatePushSecret()
+ let version = RecordSerializer.decisionBaseVersion
+ cacheAccountPushSecret(secret, version: version, authorID: authorID)
+ publishAccountPushSecretDecision(secret, version: version)
+ return secret
+ }
+
+ /// Rotates the account-wide push secret to a fresh value at the next
+ /// generation. The bumped version lets the new secret overwrite the existing
+ /// `Decision` (otherwise an equal-generation write converges back onto the
+ /// server's value); every one of the account's devices adopts it inbound and
+ /// re-derives its per-game addresses. Safe to call when nothing is minted yet
+ /// — it simply mints the first generation.
+ func rotateAccountPushSecret() {
+ guard let authorID = identity.currentID, !authorID.isEmpty else { return }
+ let secret = Self.generatePushSecret()
+ let version = accountPushSecretVersion(authorID: authorID) + 1
+ cacheAccountPushSecret(secret, version: version, authorID: authorID)
+ publishAccountPushSecretDecision(secret, version: version)
+ Task { @MainActor [weak self] in
+ await self?.reconcilePushRegistration()
+ }
+ }
+
+ private static func generatePushSecret() -> String {
var bytes = [UInt8](repeating: 0, count: 32)
_ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
- let secret = Data(bytes).base64EncodedString()
+ return Data(bytes).base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
- UserDefaults.standard.set(secret, forKey: key)
- publishAccountPushSecretDecision(secret)
- return secret
}
- private func cacheAccountPushSecret(_ secret: String, authorID: String) {
+ private func cacheAccountPushSecret(_ secret: String, version: Int64, authorID: String) {
guard !secret.isEmpty else { return }
UserDefaults.standard.set(secret, forKey: accountPushSecretDefaultsKey(authorID: authorID))
+ UserDefaults.standard.set(version, forKey: accountPushSecretVersionDefaultsKey(authorID: authorID))
}
private func accountPushSecretDefaultsKey(authorID: String) -> String {
Self.accountPushSecretDefaultsPrefix + authorID
}
+ private func accountPushSecretVersionDefaultsKey(authorID: String) -> String {
+ Self.accountPushSecretVersionDefaultsPrefix + authorID
+ }
+
+ /// Generation of the locally-held push secret. Defaults to
+ /// `decisionBaseVersion` when a secret exists without a stored version (a
+ /// value cached by the pre-rotation build) and to one below that when no
+ /// secret is cached at all, so a first mint or any inbound copy supersedes it.
+ private func accountPushSecretVersion(authorID: String) -> Int64 {
+ let defaults = UserDefaults.standard
+ if let stored = defaults.object(
+ forKey: accountPushSecretVersionDefaultsKey(authorID: authorID)
+ ) as? NSNumber {
+ return stored.int64Value
+ }
+ let secret = defaults.string(forKey: accountPushSecretDefaultsKey(authorID: authorID))
+ return (secret ?? "").isEmpty
+ ? RecordSerializer.decisionBaseVersion - 1
+ : RecordSerializer.decisionBaseVersion
+ }
+
/// True once both the account push secret and address are cached for this
/// account — i.e. `ensureAccountPushSecret`/`ensureAccountPushAddress` would
/// hit their early returns rather than mint. Used at startup to decide
@@ -865,12 +924,13 @@ final class AppServices {
return !(secret ?? "").isEmpty && !(address ?? "").isEmpty
}
- private func publishAccountPushSecretDecision(_ secret: String) {
+ private func publishAccountPushSecretDecision(_ secret: String, version: Int64) {
Task.detached { [syncEngine] in
await syncEngine.enqueueDecision(
kind: RecordSerializer.accountDecisionKind,
key: RecordSerializer.accountPushSecretDecisionKey,
- payload: secret
+ payload: secret,
+ version: version
)
}
}
diff --git a/Crossmate/Sync/RecordApplier.swift b/Crossmate/Sync/RecordApplier.swift
@@ -33,10 +33,11 @@ struct BatchEffects {
var journalsSynced = Set<UUID>()
/// Account-level push address decisions seen in the private account zone.
var accountPushAddresses: [String] = []
- /// Account-level push *secret* decisions seen in the private account zone.
- /// Drive re-derivation of every per-game push address (see
- /// `RecordSerializer.deriveGameAddress`).
- var accountPushSecrets: [String] = []
+ /// Account-level push *secret* decisions seen in the private account zone,
+ /// each with its generation. Drive re-derivation of every per-game push
+ /// address (see `RecordSerializer.deriveGameAddress`); the version gates
+ /// adoption so a stale inbound copy can't undo a rotation.
+ var accountPushSecrets: [(secret: String, version: Int64)] = []
}
extension SyncEngine {
diff --git a/Crossmate/Sync/RecordBuilder.swift b/Crossmate/Sync/RecordBuilder.swift
@@ -10,7 +10,9 @@ extension SyncEngine {
nonisolated func buildRecord(
for recordID: CKRecord.ID,
pings: [String: PingPayload],
- decisions: [String: String]
+ decisions: [String: String],
+ decisionVersions: [String: Int64],
+ decisionSystemFields: [String: Data]
) -> CKRecord? {
let name = recordID.recordName
let zoneID = recordID.zoneID
@@ -37,7 +39,9 @@ extension SyncEngine {
kind: kind,
key: key,
payload: decisions[name],
- zone: zoneID
+ zone: zoneID,
+ systemFields: decisionSystemFields[name],
+ version: decisionVersions[name]
)
}
let ctx = persistence.container.newBackgroundContext()
diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift
@@ -126,14 +126,32 @@ enum RecordSerializer {
return address
}
- static func parseAccountPushSecretDecision(_ record: CKRecord) -> String? {
+ /// Default generation for a `version`-less Decision — any record written by
+ /// the pre-rotation code. Matched to the value a fresh mint uses so legacy
+ /// and freshly-minted secrets share a generation and converge via the
+ /// equal-version "server wins" rule, while a deliberate rotation (2+)
+ /// supersedes them. Mapping to 0 instead would let the first post-update
+ /// mint clobber an already-converged legacy secret.
+ static let decisionBaseVersion: Int64 = 1
+
+ /// The monotonic generation of a Decision. Higher wins: an inbound or
+ /// conflicting record at a higher version supersedes the local value; equal
+ /// versions converge on whoever reached the server first. Absent (legacy)
+ /// records report `decisionBaseVersion`.
+ static func decisionVersion(_ record: CKRecord) -> Int64 {
+ (record["version"] as? Int64) ?? decisionBaseVersion
+ }
+
+ static func parseAccountPushSecretDecision(
+ _ record: CKRecord
+ ) -> (secret: String, version: Int64)? {
guard record.recordType == "Decision",
record.recordID.recordName == accountPushSecretDecisionName,
(record["kind"] as? String) == accountDecisionKind,
let secret = record["payload"] as? String,
!secret.isEmpty
else { return nil }
- return secret
+ return (secret, decisionVersion(record))
}
/// Derives this account's push address for one game as
@@ -327,23 +345,30 @@ enum RecordSerializer {
/// record name, which keeps every write an idempotent upsert; `key` is not
/// duplicated as a field. `payload` is the generic, kind-specific extra
/// slot — empty for `block`, where presence alone is the fact — mirroring
- /// `Ping.payload`. Write-once and immutable, so there is no Core Data
- /// equivalent and no system-fields archive; a re-send of an existing
- /// decision is a benign conflict the send path drops.
+ /// `Ping.payload`. `systemFields` is the archived server record (with its
+ /// change tag): pass it so a re-send carries the current tag and CloudKit
+ /// accepts the update (e.g. rotating the push secret) instead of rejecting
+ /// it as a colliding create. Decisions are therefore upsertable, not
+ /// write-once; a payload-less write clears any value a restored record held.
static func decisionRecord(
kind: String,
key: String,
payload: String? = nil,
- zone: CKRecordZone.ID
+ zone: CKRecordZone.ID,
+ systemFields: Data? = nil,
+ version: Int64? = nil
) -> CKRecord {
let name = decisionRecordName(kind: kind, key: key)
- let recordID = CKRecord.ID(recordName: name, zoneID: zone)
- let record = CKRecord(recordType: "Decision", recordID: recordID)
+ let record = restoreOrCreate(
+ recordType: "Decision",
+ recordName: name,
+ zone: zone,
+ systemFields: systemFields
+ )
record["kind"] = kind as CKRecordValue
- if let payload {
- record["payload"] = payload as CKRecordValue
- }
+ record["payload"] = payload.map { $0 as CKRecordValue }
record["createdAt"] = Date() as CKRecordValue
+ record["version"] = version.map { $0 as CKRecordValue }
return record
}
diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift
@@ -73,6 +73,27 @@ actor SyncEngine {
private static let pendingDecisionPayloadsDefaultsKey =
"SyncEngine.pendingDecisionPayloads"
+ /// Intended generation for a versioned `Decision` pending send, keyed by
+ /// record name. Mirrors `pendingDecisionPayloads`' lifecycle and durability:
+ /// the version must survive an app kill so a rebuilt rotation re-asserts at
+ /// the right generation rather than a stale one. Only the push secret is
+ /// versioned today; unversioned decisions (block/left/pushAddress) have no
+ /// entry and stay write-once on conflict.
+ private var pendingDecisionVersions: [String: Int64] = [:]
+
+ private static let pendingDecisionVersionsDefaultsKey =
+ "SyncEngine.pendingDecisionVersions"
+
+ /// Server system fields adopted while recovering a *versioned* Decision that
+ /// lost the change-tag race but won on version — stashed so the next
+ /// `buildRecord` carries the server's tag and the overwrite is accepted
+ /// rather than re-colliding. In-memory only: the version in
+ /// `pendingDecisionVersions` carries correctness across a relaunch (a
+ /// tagless re-send simply re-hits the conflict and re-recovers), so this is
+ /// a round-trip optimization, not durable state. Cleared once the record
+ /// saves or settles.
+ private var decisionSystemFields: [String: Data] = [:]
+
struct PingPayload {
let gameID: UUID
let authorID: String
@@ -128,7 +149,7 @@ actor SyncEngine {
/// future and later close it with a lower current-time value.
var onIncomingReadCursor: (@MainActor @Sendable ([(UUID, Date, Data?)]) async -> Void)?
var onAccountPushAddress: (@MainActor @Sendable (String) async -> Void)?
- var onAccountPushSecret: (@MainActor @Sendable (String) async -> Void)?
+ var onAccountPushSecret: (@MainActor @Sendable (String, Int64) async -> Void)?
private var localAuthorIDProvider: (@MainActor @Sendable () -> String?)?
private var tracer: (@MainActor @Sendable (String) -> Void)?
/// Fires when a delegate event reports a successful round-trip with the
@@ -218,7 +239,7 @@ actor SyncEngine {
onAccountPushAddress = cb
}
- func setOnAccountPushSecret(_ cb: @MainActor @Sendable @escaping (String) async -> Void) {
+ func setOnAccountPushSecret(_ cb: @MainActor @Sendable @escaping (String, Int64) async -> Void) {
onAccountPushSecret = cb
}
@@ -250,6 +271,7 @@ actor SyncEngine {
// serialized state below; restore the matching Decision payloads so a
// pending decision rebuilds with its body instead of as a poison record.
restorePendingDecisionPayloads()
+ restorePendingDecisionVersions()
let bgCtx = persistence.container.newBackgroundContext()
@@ -830,7 +852,12 @@ actor SyncEngine {
/// body to UserDefaults so CKSyncEngine's persisted pending save survives an
/// app kill without rebuilding as a payload-less record. Idempotent — a
/// re-send of an existing decision is a benign conflict the send path drops.
- func enqueueDecision(kind: String, key: String, payload: String? = nil) {
+ func enqueueDecision(
+ kind: String,
+ key: String,
+ payload: String? = nil,
+ version: Int64? = nil
+ ) {
guard let engine = privateEngine else { return }
let zoneID = RecordSerializer.accountZoneID
// CKSyncEngine dedupes redundant saveZone requests, so it's safe to
@@ -841,6 +868,10 @@ actor SyncEngine {
pendingDecisionPayloads[name] = payload
persistPendingDecisionPayloads()
}
+ if let version {
+ pendingDecisionVersions[name] = version
+ persistPendingDecisionVersions()
+ }
let recordID = CKRecord.ID(recordName: name, zoneID: zoneID)
engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)])
sendChangesDetached(on: engine)
@@ -880,6 +911,28 @@ actor SyncEngine {
) as? [String: String]) ?? [:]
}
+ /// Mirrors `pendingDecisionVersions` to durable storage, alongside
+ /// `persistPendingDecisionPayloads`. NSNumber/Int64 is plist-representable.
+ private func persistPendingDecisionVersions() {
+ if pendingDecisionVersions.isEmpty {
+ UserDefaults.standard.removeObject(
+ forKey: Self.pendingDecisionVersionsDefaultsKey
+ )
+ } else {
+ UserDefaults.standard.set(
+ pendingDecisionVersions.mapValues { NSNumber(value: $0) },
+ forKey: Self.pendingDecisionVersionsDefaultsKey
+ )
+ }
+ }
+
+ private func restorePendingDecisionVersions() {
+ let raw = (UserDefaults.standard.dictionary(
+ forKey: Self.pendingDecisionVersionsDefaultsKey
+ ) as? [String: NSNumber]) ?? [:]
+ pendingDecisionVersions = raw.mapValues { $0.int64Value }
+ }
+
/// Consume-deletes a single Ping the local account has handled (shown,
/// suppressed, or a duplicate). The deletion syncs through the game zone so
/// this user's other devices withdraw any notification they showed for it.
@@ -1082,6 +1135,9 @@ actor SyncEngine {
pendingPings = [:]
pendingDecisionPayloads = [:]
persistPendingDecisionPayloads()
+ pendingDecisionVersions = [:]
+ persistPendingDecisionVersions()
+ decisionSystemFields = [:]
pingPushCheckpoints = [:]
seenPingRecords = [:]
liveQueryCheckpoints = [:]
@@ -1306,8 +1362,8 @@ actor SyncEngine {
if let address = RecordSerializer.parseAccountPushAddressDecision(record) {
effects.accountPushAddresses.append(address)
}
- if let secret = RecordSerializer.parseAccountPushSecretDecision(record) {
- effects.accountPushSecrets.append(secret)
+ if let parsed = RecordSerializer.parseAccountPushSecretDecision(record) {
+ effects.accountPushSecrets.append(parsed)
}
let wrote = RecordSerializer.applyDecisionRecord(
record,
@@ -1410,8 +1466,8 @@ actor SyncEngine {
// simultaneous-mint race the loser adopts the winner's secret here on
// the next fetch — no send-failure-recovery shortcut needed.
if let onAccountPushSecret {
- for secret in effects.accountPushSecrets {
- await onAccountPushSecret(secret)
+ for entry in effects.accountPushSecrets {
+ await onAccountPushSecret(entry.secret, entry.version)
}
}
for id in effects.removed {
@@ -1517,20 +1573,31 @@ actor SyncEngine {
} else if name.hasPrefix("decision-") {
pendingDecisionPayloads.removeValue(forKey: name)
persistPendingDecisionPayloads()
+ pendingDecisionVersions.removeValue(forKey: name)
+ persistPendingDecisionVersions()
+ decisionSystemFields.removeValue(forKey: name)
}
}
+ // Snapshot the intended versions on-actor so the off-actor conflict
+ // resolution can tell a deliberate, newer write from a stale one.
+ let pendingVersionsSnapshot = pendingDecisionVersions
let ctx = persistence.container.newBackgroundContext()
ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
let (failureMessages, orphanedZones, resolvedDecisions, settledJournals,
- resolvedAccountAddresses, resolvedAccountSecrets):
+ resolvedAccountAddresses, resolvedAccountSecrets, decisionWins):
([String], Set<CKRecordZone.ID>, Set<CKRecord.ID>, Set<CKRecord.ID>,
- [String], [String]) = ctx.performAndWait {
+ [String], [(secret: String, version: Int64)],
+ [(name: String, systemFields: Data)]) = ctx.performAndWait {
var messages: [String] = []
var orphaned = Set<CKRecordZone.ID>()
var settledDecisions = Set<CKRecord.ID>()
var settledJournals = Set<CKRecord.ID>()
var accountAddresses: [String] = []
- var accountSecrets: [String] = []
+ var accountSecrets: [(secret: String, version: Int64)] = []
+ // Versioned decisions that lost the change-tag race but win on
+ // version: (record name, server system fields to adopt for the
+ // overwrite retry).
+ var decisionWins: [(name: String, systemFields: Data)] = []
for record in event.savedRecords {
self.writeBackSystemFields(record: record, in: ctx)
let savedName = record.recordID.recordName
@@ -1561,26 +1628,45 @@ actor SyncEngine {
} else if name.hasPrefix("decision-"),
err.domain == CKErrorDomain,
err.code == CKError.serverRecordChanged.rawValue {
- // The durable fact already exists server-side (a re-block
- // or an echo of our own write). Decisions are write-once,
- // so this is success — drop the pending change so the
- // immutable record doesn't retry-loop forever.
- settledDecisions.insert(failure.record.recordID)
- // We lost the write-once race; adopt the winner's payload
- // straight off the conflict error instead of waiting for a
- // later fetch to deliver it. For the push secret that closes
- // the window in which this device would keep deriving
- // divergent per-game addresses from its own minted secret.
- if let serverRecord = err.userInfo[CKRecordChangedErrorServerRecordKey] as? CKRecord {
- if let address = RecordSerializer.parseAccountPushAddressDecision(serverRecord) {
- accountAddresses.append(address)
- }
- if let secret = RecordSerializer.parseAccountPushSecretDecision(serverRecord) {
- accountSecrets.append(secret)
+ let serverRecord = err.userInfo[CKRecordChangedErrorServerRecordKey] as? CKRecord
+ let intended = pendingVersionsSnapshot[name]
+ let serverVersion = serverRecord.map(RecordSerializer.decisionVersion)
+ if let intended, let serverRecord, let serverVersion,
+ intended > serverVersion,
+ let serverFields = RecordSerializer.encodeSystemFields(of: serverRecord) {
+ // A deliberate, newer write (e.g. a rotated push secret)
+ // that lost only the change-tag race. Adopt the server's
+ // tag so the next build overwrites instead of
+ // re-colliding, and *keep* the pending change: a
+ // serverRecordChanged failure stays pending, so the retry
+ // rides the normal send loop — same shape as
+ // recoverServerChangedSave for Game/Moves.
+ decisionWins.append((name, serverFields))
+ settled = true
+ messages.append(
+ "send: decision \(name) lost tag race but wins on version " +
+ "(\(intended) > \(serverVersion)) — overwriting"
+ )
+ } else {
+ // Write-once (block/left/pushAddress) or an equal/older
+ // version: the durable fact already on the server wins.
+ // Drop the pending change so the record doesn't
+ // retry-loop, and adopt the winner's payload straight off
+ // the conflict instead of waiting for a later fetch — for
+ // the push secret that closes the window in which this
+ // device would keep deriving divergent per-game addresses.
+ settledDecisions.insert(failure.record.recordID)
+ if let serverRecord {
+ if let address = RecordSerializer.parseAccountPushAddressDecision(serverRecord) {
+ accountAddresses.append(address)
+ }
+ if let parsed = RecordSerializer.parseAccountPushSecretDecision(serverRecord) {
+ accountSecrets.append(parsed)
+ }
}
+ settled = true
+ messages.append("send: decision \(name) already present — settled")
}
- settled = true
- messages.append("send: decision \(name) already present — settled")
} else if name.hasPrefix("journal-"),
err.domain == CKErrorDomain,
err.code == CKError.serverRecordChanged.rawValue,
@@ -1623,7 +1709,7 @@ actor SyncEngine {
}
}
return (messages, orphaned, settledDecisions, settledJournals,
- accountAddresses, accountSecrets)
+ accountAddresses, accountSecrets, decisionWins)
}
if !orphanedZones.isEmpty {
await applyZoneOrphaning(orphanedZones, isPrivate: isPrivate)
@@ -1634,8 +1720,22 @@ actor SyncEngine {
)
for recordID in resolvedDecisions {
pendingDecisionPayloads.removeValue(forKey: recordID.recordName)
+ pendingDecisionVersions.removeValue(forKey: recordID.recordName)
+ decisionSystemFields.removeValue(forKey: recordID.recordName)
}
persistPendingDecisionPayloads()
+ persistPendingDecisionVersions()
+ }
+ // Adopt the server's tag for each versioned decision we're overwriting,
+ // then nudge the send loop so the retry (now carrying the tag) goes out
+ // promptly rather than on CKSyncEngine's own cadence.
+ if !decisionWins.isEmpty {
+ for win in decisionWins {
+ decisionSystemFields[win.name] = win.systemFields
+ }
+ if let privateEngine {
+ sendChangesDetached(on: privateEngine)
+ }
}
if let onAccountPushAddress {
for address in resolvedAccountAddresses {
@@ -1643,8 +1743,8 @@ actor SyncEngine {
}
}
if let onAccountPushSecret {
- for secret in resolvedAccountSecrets {
- await onAccountPushSecret(secret)
+ for entry in resolvedAccountSecrets {
+ await onAccountPushSecret(entry.secret, entry.version)
}
}
if !settledJournals.isEmpty {
@@ -1897,12 +1997,16 @@ extension SyncEngine: CKSyncEngineDelegate {
await traceForeignPlayerWrites(in: pending)
let pingSnapshot = pendingPings
let decisionSnapshot = pendingDecisionPayloads
+ let decisionVersionSnapshot = pendingDecisionVersions
+ let decisionSystemFieldsSnapshot = decisionSystemFields
return await CKSyncEngine.RecordZoneChangeBatch(pendingChanges: pending) { [weak self] recordID in
guard let self else { return nil }
if let record = self.buildRecord(
for: recordID,
pings: pingSnapshot,
- decisions: decisionSnapshot
+ decisions: decisionSnapshot,
+ decisionVersions: decisionVersionSnapshot,
+ decisionSystemFields: decisionSystemFieldsSnapshot
) {
return record
}
diff --git a/Tests/Unit/RecordSerializerTests.swift b/Tests/Unit/RecordSerializerTests.swift
@@ -533,6 +533,63 @@ struct RecordSerializerTests {
#expect(record["payload"] as? String == "{\"until\":1}")
}
+ @Test("decisionRecord writes the version field only when provided")
+ func decisionRecordVersion() {
+ let unversioned = RecordSerializer.decisionRecord(
+ kind: "account",
+ key: "pushSecret",
+ payload: "s",
+ zone: RecordSerializer.accountZoneID
+ )
+ #expect(unversioned["version"] == nil)
+
+ let versioned = RecordSerializer.decisionRecord(
+ kind: "account",
+ key: "pushSecret",
+ payload: "s",
+ zone: RecordSerializer.accountZoneID,
+ version: 3
+ )
+ #expect(versioned["version"] as? Int64 == 3)
+ }
+
+ @Test("decisionVersion defaults to the base generation for a version-less record")
+ func decisionVersionDefault() {
+ let record = RecordSerializer.decisionRecord(
+ kind: "account",
+ key: "pushSecret",
+ payload: "s",
+ zone: RecordSerializer.accountZoneID
+ )
+ #expect(RecordSerializer.decisionVersion(record) == RecordSerializer.decisionBaseVersion)
+ }
+
+ @Test("parseAccountPushSecretDecision returns the secret and its generation")
+ func parseAccountPushSecretDecisionReadsVersion() {
+ let record = RecordSerializer.decisionRecord(
+ kind: RecordSerializer.accountDecisionKind,
+ key: RecordSerializer.accountPushSecretDecisionKey,
+ payload: "the-secret",
+ zone: RecordSerializer.accountZoneID,
+ version: 5
+ )
+ let parsed = RecordSerializer.parseAccountPushSecretDecision(record)
+ #expect(parsed?.secret == "the-secret")
+ #expect(parsed?.version == 5)
+ }
+
+ @Test("parseAccountPushSecretDecision treats a missing version as the base generation")
+ func parseAccountPushSecretDecisionDefaultsVersion() {
+ let record = RecordSerializer.decisionRecord(
+ kind: RecordSerializer.accountDecisionKind,
+ key: RecordSerializer.accountPushSecretDecisionKey,
+ payload: "legacy-secret",
+ zone: RecordSerializer.accountZoneID
+ )
+ let parsed = RecordSerializer.parseAccountPushSecretDecision(record)
+ #expect(parsed?.version == RecordSerializer.decisionBaseVersion)
+ }
+
@Test("applyDecisionRecord(.block) creates a blocked FriendEntity with derived pairKey")
@MainActor func applyDecisionBlockCreatesTombstone() throws {
let persistence = makeTestPersistence()