DesignFlaws.md (103886B)
1 # Crossmate Audit 2 3 A phased audit of the Crossmate codebase. Each phase covers one conceptual 4 piece of the app and is reviewed in its own pass, since the whole codebase is 5 too large to review in one sitting. 6 7 **Focus for every phase:** correctness, security, performance, and code 8 quality/style. Findings that don't fit the phase they're discovered in should 9 still be recorded under that phase, with a note pointing at the phase they 10 actually belong to. 11 12 **Out of scope:** `Crossmake/` (separate offline puzzle-authoring SPM 13 package, not part of the app build). 14 15 **Status legend:** Not started / In progress / Done 16 17 --- 18 19 ## Phase 1 — CloudKit Sync Engine 20 21 **Status:** Done 22 23 The core of the app: raw `CKSyncEngine`, per-game zones, record 24 serialization/merging, and conflict handling. Highest complexity, highest 25 blast radius if wrong. 26 27 - `Crossmate/Sync/SyncEngine.swift` 28 - `Crossmate/Sync/CloudZones.swift` 29 - `Crossmate/Sync/CloudQuery.swift` 30 - `Crossmate/Sync/RecordApplier.swift` 31 - `Crossmate/Sync/RecordBuilder.swift` 32 - `Crossmate/Sync/RecordSerializer.swift` 33 - `Crossmate/Sync/GridStateMerger.swift` 34 - `Crossmate/Sync/Moves.swift` 35 - `Crossmate/Sync/MovesUpdater.swift` 36 - `Crossmate/Sync/PeerChangeLedger.swift` 37 - `Crossmate/Sync/PlayerSelectionPublisher.swift` 38 - `Crossmate/Sync/RecentChanges.swift` 39 - `Crossmate/Sync/SessionMonitor.swift` 40 - `Crossmate/Sync/Presence.swift` 41 - `Crossmate/Sync/AuthorIdentity.swift` 42 - `Crossmate/Sync/Archive.swift` 43 - `Crossmate/Sync/GameArchiver.swift` 44 - `Crossmate/Sync/CloudDiagnostics.swift` 45 - `Crossmate/Sync/SyncState+Helpers.swift` 46 - `Crossmate/Services/CloudService.swift` (sync composition root) 47 - `Tests/Unit/Sync/*`, `Tests/Unit/RecordSerializer*Tests.swift`, 48 `Tests/Unit/GridStateMergerTests.swift`, `Tests/Unit/MovesUpdaterTests.swift`, 49 `Tests/Unit/PeerChangeLedgerTests.swift`, `Tests/Unit/RecentChangesTests.swift`, 50 `Tests/Unit/SyncMonitorTests.swift` 51 52 ### Findings (consolidated from Sonnet 5 and Opus 4.8, reviewed 2026-07-01) 53 54 Overall this layer is in good shape: the LWW/etag guards, the 55 `serverRecordChanged` re-enqueue handling, the burst/drain routing, and the 56 zone-orphaning teardown are carefully reasoned and heavily documented. The 57 merge/ledger/publisher helpers are clean and well-tested. The items below are 58 the exceptions, ordered by practical priority. 59 60 **1. (Security — high) Account push address/secret `Decision`s are adopted 61 without verifying they came from the private `account` zone.** 62 `RecordSerializer.parseAccountPushAddressDecision` / 63 `parseAccountPushSecretDecision` (`RecordSerializer.swift:219`, `:245`) 64 validate only the record type/name/kind/payload. They do not check the record's 65 zone or database scope. `SyncEngine.handleFetchedRecordZoneChanges` calls them 66 for every fetched `Decision` record (`SyncEngine.swift:1514`, `:1517`) on both 67 the private and shared engines, then dispatches the surfaced values to 68 `onAccountPushAddress` / `onAccountPushSecret` (`SyncEngine.swift:1656`, 69 `:1666`). 70 71 The push secret is the HMAC key used by `deriveGameAddress`, and the comments 72 say it should live only in the private account zone. A writable non-account 73 zone can therefore carry a record named `decision-account-pushSecret`, type 74 `Decision`, `kind=account`, with attacker-chosen payload/version, and this 75 device will adopt it. At minimum this can poison push registration and cause a 76 push-delivery DoS. The sibling `nickname` decision already gates on 77 `record.recordID.zoneID.zoneName == accountZoneID.zoneName && databaseScope == 0` 78 (`RecordSerializer.swift:1153`); account push address/secret adoption should 79 use the same private-account-zone guard, either in the parsers or at their fetch 80 call site. The `serverRecordChanged` conflict path (`SyncEngine.swift:1889`, 81 `:1892`) is less exposed because the server record comes from the account-zone 82 write being resolved. 83 84 **2. (Correctness/data loss — high) `GameArchiver.promoteRevoked` deletes the 85 original game even when archive materialization fails.** 86 `GameArchiver.promoteRevoked` discards the result of 87 `Archive.materialize(payload, in:)` (`GameArchiver.swift:221`) and then 88 unconditionally deletes the original `GameEntity` (`:222-227`). 89 `Archive.materialize` returns `nil` if `payload.puzzleSource` is empty 90 (`Archive.swift:392`). A cloud archive payload can have an empty puzzle source 91 if the `CKAsset` read fails, because `Archive.payload(from:)` converts any 92 missing/unreadable asset to `""` (`Archive.swift:352-354`). 93 94 Failure scenario: a completed shared game is revoked; the archive record fetches 95 but the puzzle source asset is unavailable or unreadable; no replacement row is 96 created, yet the original row is deleted. Only delete the original after 97 materialization succeeds, or fall back to the local snapshot when the cloud 98 payload is incomplete. 99 100 **3. (Security/performance — medium) `puzzleSource` CKAssets are read without a 101 size cap.** 102 `RecordSerializer.applyGameRecord` reads the entire `puzzleSource` asset with 103 `String(contentsOf: fileURL, encoding: .utf8)` (`RecordSerializer.swift:870-901`). 104 Anyone with write access to a shared game zone can cause every syncing device 105 to load that asset fully into memory. Crossword sources are inherently small, 106 so enforce a conservative byte limit before decoding and reject or ignore 107 oversized assets. 108 109 **4. (Performance — medium) Sync actors use blocking Core Data work in async 110 paths.** 111 The sync layer frequently calls `context.performAndWait` from async actor 112 methods, including `SyncEngine.handleFetchedRecordZoneChanges` 113 (`SyncEngine.swift:1466`), `MovesUpdater.performFlush` 114 (`MovesUpdater.swift:158`), and `PlayerSelectionPublisher` 115 (`PlayerSelectionPublisher.swift:203`). This may be a deliberate serialization 116 tradeoff, but blocking a Swift concurrency worker during Core Data fetch/save 117 work can become a scalability problem during bursty sync across many games. 118 Consider a systematic pass to move hot paths to `await context.perform { ... }` 119 where ordering constraints allow it. Treat this as a performance audit item 120 rather than an immediate correctness bug. 121 122 **5. (Performance — low/medium) `RecordBuilder.buildRecord` creates a fresh 123 background context per outbound record.** 124 `RecordBuilder.buildRecord` creates a new background context at 125 `RecordBuilder.swift:48` and performs one lookup/build inside it. Because this 126 runs once per pending outbound record, draining a large CKSyncEngine batch 127 allocates one Core Data context per record. Hoisting context creation to the 128 batch caller would reduce overhead and share the row cache across a drain. 129 130 **6. (Resource — low) Archive temp files are left for OS cleanup.** 131 `Archive.asset(for:ext:)` writes a new file in `FileManager.default.temporaryDirectory` 132 for every archive asset (`Archive.swift:300-306`). `GameArchiver.write` can 133 re-save archives across retry/reconcile passes, and no path explicitly removes 134 these files after CloudKit has consumed them. This is usually bounded by OS temp 135 eviction, but a cleanup hook after successful `CKModifyRecordsOperation` would 136 avoid accumulating orphaned archive asset files. 137 138 **7. (Correctness/resource — low) Private-DB zone deletion does not share the 139 full zone-orphan cleanup path.** 140 When a private database zone deletion is fetched, `handleFetchedDatabaseChanges` 141 deletes the local `GameEntity` (`SyncEngine.swift:1346-1415`) but does not 142 remove queued `pendingRecordZoneChanges` or `pendingPings` for that zone. 143 `applyZoneOrphaning` does remove both (`SyncEngine.swift:2033-2054`). This 144 should self-heal when the queued send later fails with `.zoneNotFound`, but the 145 fetch-side deletion path could use the same helper to avoid a futile retry and 146 keep transient state consistent. 147 148 **8. (Performance — low) `GameArchiver.write` ensures the archive zone before 149 every save.** 150 `GameArchiver.write` calls `ensureZone(zoneID)` unconditionally 151 (`GameArchiver.swift:139-143`). This is idempotent and correct, but can add an 152 avoidable CloudKit round trip on repeated archive reconcile passes. Cache known 153 archive zones or tolerate `zoneAlreadyExists` after a create-once attempt. 154 155 **9. (Correctness/consistency — low) Equal-timestamp move-cell merges use an 156 implicit incoming-wins tie-break.** 157 `mergeIncomingMovesCells` replaces the local cell whenever 158 `existing.updatedAt <= incoming.updatedAt` (`RecordSerializer.swift:1014-1027`). 159 `GridStateMerger.shouldReplace` uses a deterministic tie-break on author and 160 device after timestamp equality (`GridStateMerger.swift:78-86`). This merge is 161 scoped to a single `(author, device)` moves row, so impact is likely low, but 162 aligning the tie-breaks would reduce surprise if the helper is reused. 163 164 **10. (Code quality — low) `applyPlayerRecord` drops the whole record when 165 `name` is absent.** 166 `RecordApplier.applyPlayerRecord` returns early if `record["name"]` is missing 167 (`RecordApplier.swift:231`), which also drops selection, presence, read cursor, 168 and time-log fields. Today `playerRecord` always writes `name`, so this is a 169 future partial-fetch sharp edge rather than a live bug. 170 171 **11. (Code quality — low) `MovesUpdater.enqueue(actingAuthorID:)` is a dead 172 parameter.** 173 `GameMutator` passes `actingAuthorID` into `MovesUpdater.enqueue` 174 (`GameMutator.swift:371`), but `MovesUpdater.enqueue` ignores the parameter 175 (`MovesUpdater.swift:77`). Journaling attribution is handled separately in 176 `GameMutator`, so either remove the parameter or wire it to behavior that needs 177 it. 178 179 **Non-findings and deferred checks:** 180 - `serverRecordChanged` re-enqueue handling appears correct: the 181 `recoverServerChangedSave` and `recoveredSaves`/`decisionWins` paths 182 explicitly re-add `.saveRecord` after adopting server system fields. 183 - `CloudQuery` desired-key lists are centralized in `RecordSerializer` and 184 cross-checked against written fields; the old duplicated-list footgun is not 185 present here. 186 - `GridStateMerger`'s primary merge logic is deterministic and covered by 187 tests. 188 - Record-name parsers reject malformed names rather than force-unwrapping 189 untrusted CloudKit record names. 190 - `CloudDiagnostics` does not appear to log tokens, emails, or full record 191 dumps. 192 - Re-check account push address/secret adoption and republish behavior in 193 Phase 4 alongside `AccountPushCoordinator`. 194 - Re-check non-monotonic `PlayerEntity.readThrough` adoption in Phase 2 when 195 durable read cursor semantics are reviewed. 196 - Re-check `RecordSerializer.applyDecisionRecord` friend/nickname/block/ 197 encryption-key cases, `Presence` trust boundaries, and `ShareController` 198 join confirmation in Phase 3. 199 200 --- 201 202 ## Phase 2 — Persistence & Data Model 203 204 **Status:** Done 205 206 Core Data model, the local durable store, journal/replay, and the domain 207 models layered on top. 208 209 - `Crossmate/Persistence/PersistenceController.swift` 210 - `Crossmate/Persistence/GameStore.swift` 211 - `Crossmate/Persistence/GameMutator.swift` 212 - `Crossmate/Persistence/Journal.swift` 213 - `Crossmate/Persistence/JournalReplay.swift` 214 - `Crossmate/Models/CrossmateModel.xcdatamodeld/` 215 - `Crossmate/Models/Game.swift`, `GameEntity+ContentKey.swift` 216 - `Crossmate/Models/Square.swift`, `CellMark.swift` 217 - `Crossmate/Models/PlayerRoster.swift`, `PlayerSession.swift`, 218 `PlayerSelection.swift`, `PlayerColor.swift`, `PlayerPreferences.swift` 219 - `Crossmate/Models/ParticipantSummaries.swift`, `GameCursorStore.swift`, 220 `GameViewedStore.swift`, `TimeLog.swift`, `TipStore.swift` 221 - `Tests/Unit/GameStore*Tests.swift`, `GameMutatorTests.swift`, 222 `JournalReplayTests.swift`, `JournalUploadTests.swift`, 223 `PersistenceRecoveryTests.swift`, `GameCursorStoreTests.swift`, 224 `GameViewedStoreTests.swift`, `TimeLogTests.swift`, `TipStoreTests.swift`, 225 `PlayerColorTests.swift`, `PlayerRosterTests.swift` 226 227 ### Findings (consolidated from Sonnet 5 and Opus 4.8, reviewed 2026-07-01) 228 229 No security findings this phase — the model layer doesn't cross a trust 230 boundary itself (author-ID/zone validation lives in `RecordSerializer`/ 231 `RecordApplier`, already covered in Phase 1). The main themes are a silent 232 failure path in the move journal (bounded in practice by the journal 233 context's merge policy), one clock-reconciliation edge case, and a few spots 234 where synchronous Core Data work runs on the main actor / render path despite 235 an `async` signature promising otherwise. Both passes agreed on the seven 236 items below; the severity of #1 is tempered on the second pass, and #4/#6 237 carry a note that the fix is a local index change, not a CloudKit schema 238 deploy. 239 240 **1. (Correctness — medium) `MovesJournal.persist` silently swallows Core Data 241 save failures, which can permanently truncate a finished game's uploaded 242 replay journal.** `Journal.swift:404-423` calls `ctx.save()` and, on failure, 243 only does `print("MovesJournal: failed to persist entry: \(error)")` plus 244 `ctx.rollback()` — no `eventLog`, unlike every other Core Data writer in this 245 phase (`GameStore.saveContext`, `PersistenceController.backfillCachedSummaryFields`). 246 `MovesJournal` isn't even constructed with an `eventLog` reference 247 (`GameStore.swift:566`: `MovesJournal(persistence: persistence)`). This 248 matters because the completion/replay-upload path deliberately reads the 249 *persisted* Core Data rows through a separate background context rather than 250 the in-memory `entries` list — see the doc comment on `flush()` 251 (`Journal.swift:394-402`): the Phase 2 upload "reconstructs the asset from 252 Core Data on a *separate* background context." A dropped `persist` (e.g. a 253 non-optional-relationship validation failure if `entity.game` resolves to nil 254 because the game row was concurrently deleted, or any other one-off Core Data 255 error) removes that touch from the uploaded Journal asset permanently, with 256 nothing beyond a console `print` to explain why a replay is missing a move. 257 Severity was reconsidered on the second pass: the `mergeByPropertyStoreTrump` 258 policy set in `MovesJournal.init` (`Journal.swift:163`) already neutralises 259 the one *known* failure (the 133020 merge conflict), and the residual 260 save-failure surface is narrow — the `game` relationship is optional, so a 261 concurrently-deleted game just nulls it rather than failing validation, and 262 `gameID` is always set. So the permanent-truncation scenario is real but 263 uncommon; the silent, un-logged swallow (inconsistent with every other writer 264 in the layer) is the core problem regardless of how often it fires. Fix: 265 thread `eventLog` into `MovesJournal` and log persist failures at `error` 266 level, mirroring the rest of the Persistence layer. 267 268 **2. (Correctness — medium) `TimeLog.open`'s crash-reconciliation can 269 fabricate solve time on a fast relaunch.** `TimeLog.swift:72-84`. When 270 `reconcileStale` fires (first open of a game since launch, healing a session 271 left dangling by a previous run's crash), the banked interval end is 272 `boundedEnd = min(beatAt + openGrace(4min), openStart + maxSessionCap)` — 273 never clamped to `now`. If the user relaunches well under 4 minutes after the 274 crash, `boundedEnd` lands *after* the actual relaunch instant, so the sealed 275 interval extends past `now` and the immediately-following live session 276 (`openStart = now`) starts inside that still-"future" interval; 277 `TimeLog.unionSeconds` then merges the overlap, crediting real elapsed time 278 plus up to ~4 minutes of dead/crashed time that was never played. The 279 existing test (`staleSessionReconciledOnReopen`, 280 `Tests/Unit/TimeLogTests.swift:172`) only exercises `now=200_000`, far past 281 `beatAt+openGrace=340`, so it never hits this boundary. Fix: also clamp 282 `boundedEnd` to `now`. 283 284 **3. (Performance — medium) `PlayerRoster.refresh()` blocks the main actor on 285 a synchronous Core Data fetch instead of yielding it.** `PlayerRoster.swift:256`. 286 `refresh()` is an `async @MainActor` method that creates a background context 287 and then calls `ctx.performAndWait { ... }` (not `await ctx.perform`). 288 `performAndWait` blocks the calling thread — the main thread here — until the 289 background context finishes fetching the `GameEntity`, all its 290 `PlayerEntity`/`MovesEntity` rows, and every nicknamed `FriendEntity`. 291 `refresh()` is triggered by `.playerRosterShouldRefresh`, posted from 292 `RecordApplier`/`SyncEngine` on roster-relevant inbound CloudKit changes, 293 which can land mid co-solve session — each such event stalls the UI for the 294 fetch's duration rather than freeing it, despite the `async` signature. This 295 is a sharper, UI-facing instance of Phase 1 finding #4 (blocking Core Data 296 work in async paths). Fix: `await ctx.perform { ... }`. Related render-path 297 instance (second pass): `PlayerRoster.solveTime` (`PlayerRoster.swift:107-130`) 298 fetches the `GameEntity` plus *every* `PlayerEntity` and JSON-decodes each 299 `timeLog` synchronously on each call, and it drives the live header clock. It 300 runs on `viewContext` (so a main-thread fetch is at least expected there), but 301 it's the same "synchronous Core Data on the render path" theme and belongs in 302 this cluster. 303 304 **4. (Performance — medium) `GameEntity` has zero `fetchIndex` entries** 305 despite `id`, `ckRecordName`, `databaseScope`, and `puzzleResourceID`/`title` 306 being hit with exact-match `NSPredicate`s constantly — 307 `"id == %@"` appears roughly 90 times in `GameStore.swift` alone (`loadGame`, 308 `deleteGame`, `markCompleted`, `setEngagement`/`setNotification`, 309 `ensurePushCredentials`, etc.), and `"ckRecordName == %@"` lookups against 310 `GameEntity` appear across `RecordApplier`, `SyncEngine`, `RecordBuilder`, 311 `RecordSerializer`, `MovesUpdater`, and `PlayerSelectionPublisher`. As the 312 local library grows, every one of these becomes a full table scan. Add 313 `fetchIndex`es on `id` and `ckRecordName` at minimum 314 (`CrossmateModel.xcdatamodel/contents:3-44`). Note (second pass): these are 315 Core Data `fetchIndex`es (local SQLite), not CloudKit schema — the app uses 316 raw `CKSyncEngine`, not `NSPersistentCloudKitContainer` — so adding them (here 317 and in #6) needs only a lightweight migration on next launch and *no* 318 Production CloudKit schema deploy. 319 320 **5. (Performance — low/medium) `MovesJournal` loads a game's entire journal 321 synchronously on the main actor the first time it's touched.** 322 `Journal.swift:361-364` (`ensureLoaded`) calls `load(_:)`, which runs an 323 unbounded `JournalEntity` fetch via `ctx.performAndWait` (`:376-385`) — and 324 `ensureLoaded` is called from `record`, `recordedEntries`, `canUndo`, 325 `canRedo`, `planUndo`, and `planRedo`. `canUndo`/`canRedo` are read by the 326 toolbar essentially as soon as a puzzle screen renders, so opening a 327 heavily-edited or long-undo-history game can briefly stall the UI on this 328 one-time-per-session fetch. Consider prefetching asynchronously before the 329 puzzle screen becomes interactive, or fetching lazily off-main and updating 330 state once ready. 331 332 **6. (Performance — low) `MovesEntity`/`PlayerEntity` also lack a 333 `ckRecordName` index.** Only composite indexes exist 334 (`byGameAndAuthorAndDevice`, `byGameAndAuthor`), but plain 335 `"ckRecordName == %@"` lookups against these entities happen in 336 `GameStore.ensureMovesEntity` (`GameStore.swift:2769`), `RecordApplier`, 337 `RecordSerializer`, `MovesUpdater`, and `PlayerSelectionPublisher`/ 338 `PlayerNamePublisher` — none benefit from the existing composite indexes. 339 340 **7. (Code quality — low) `CellEntity` carries four dead attributes.** 341 `ckRecordName`, `ckSystemFields`, `databaseScope`, and `lastSyncedAt` 342 (`contents:81-90`) are declared on `CellEntity` but never read or written 343 anywhere in the codebase (verified against every `CellEntity(context:` 344 construction site). Looks like leftover schema from an earlier 345 per-cell-record sync design, since replaced by the Moves-record model 346 (`CellEntity` is now purely a derived local cache). Safe to drop. 347 348 **Non-findings and deferred checks:** 349 - `GameEntity`'s `cells`/`journal`/`moves`/`peerChanges`/`players` 350 relationships are all `deletionRule="Cascade"`, matching 351 `GameStore.resetAllData`'s doc-comment assumption — confirmed directly 352 against the schema, not just the comment. 353 - `Game.swift`'s completion-cache bookkeeping (filled/wrong counts, gap-cell 354 exclusion, revealed-cell locking) matches `GameMutatorTests` exactly, 355 including author-preservation on a same-letter rewrite. 356 - `CellMark`'s code/decode round-trip is lossless and exhaustive; unknown 357 codes safely default to `.none`. 358 - `PlayerColor`'s companion/color assignment hashing is deterministic and 359 array-bounds-safe. 360 - The manual `Puzzle.Direction` `rawValue`/`init?(rawValue:)` extension in 361 `PlayerSelection.swift:13-30` (across=0, down=1) is consistent with the 362 independent manual encoding `Journal.swift:447` uses for the same field — 363 checked directly since a mismatch here would silently flip undo/redo 364 cursor direction between the Core Data and JSON-upload paths. 365 - No force-unwraps on untrusted/CloudKit-sourced data anywhere in this 366 file set. 367 - `GameCursorStore.swift`, `GameViewedStore.swift`, `ParticipantSummaries.swift`: 368 straightforward, no bugs found. 369 - `TipStore.markDismissed` re-inserts into an already-dismissed set on a 370 repeat dismiss, firing a redundant (harmless) `UserDefaults` write — 371 not worth a fix on its own. (The working-tree change to this file is 372 catalog-only — four new tips — and doesn't touch this path.) 373 - The Phase 1 deferred check on non-monotonic `PlayerEntity.readThrough` 374 adoption resolves as a non-finding: `RecordApplier.swift:285-301` adopts it 375 under last-writer-wins *outside* the `updatedAt` guard deliberately, with a 376 bounded, self-healing dip documented in place (a still-present device 377 re-asserts its lease on draining the inbound close). The separate *local* 378 read watermark `GameEntity.readThroughAt` (`GameStore.swift:1766-1792`) is 379 properly monotonic. 380 381 --- 382 383 ## Phase 3 — Friends, Sharing & Invites 384 385 **Status:** Done 386 387 Friend graph, `CKShare`-based collaboration, invite links, and the two 388 Workers that back link resolution and the realtime room. 389 390 - `Crossmate/Sync/FriendController.swift`, `FriendZone.swift` 391 - `Crossmate/Sync/ShareController.swift` 392 - `Crossmate/Services/InviteCoordinator.swift` 393 - `Crossmate/Services/ShareLinkRoute.swift`, `ShareLinkShortener.swift` 394 - `Crossmate/Models/FriendEntity+DisplayName.swift`, 395 `InviteEntity+DisplayName.swift` 396 - `Shared/NicknameDirectory.swift`, `FriendEncryptionKeyDirectory.swift` 397 - `Crossmate/Services/KeychainHelper.swift` 398 - `Crossmate/Views/Friends/*` 399 - `Workers/link-worker.js`, `Workers/room-worker.js` 400 - `Tests/Unit/Sync/FriendControllerNicknameReplayTests.swift`, 401 `FriendModelTests.swift`, `FriendZoneTests.swift`, `ShareRoutingTests.swift`, 402 `Tests/Unit/NicknameDirectoryTests.swift`, `ShareLinkRouteTests.swift`, 403 `ShareLinkShortenerTests.swift` 404 405 ### Findings (consolidated from Sonnet 5 and Opus 4.8, reviewed 2026-07-01) 406 407 The dominant theme is the same trust-boundary class Phase 1 flagged: several 408 inbound-`Decision` cases in `RecordSerializer.applyDecisionRecord` are adopted 409 without checking the record's zone or database scope. Phase 3 makes that gap 410 *concretely reachable*: a friendship gives the other party `.readWrite` on the 411 `friend-<pairKey>` inbox zone **you own** (private DB, `scope == 0`), so any 412 non-blocked friend can write arbitrary `Decision`/`Ping` records into a zone 413 your private `CKSyncEngine` fetches. The two properly-gated cases (`name`, 414 `nickname`) are the exception, not the rule — `block`, `left`, and 415 `encryptionKey` all under-check. The `ShareController` owner gating, the two 416 Workers' auth, and the link/token validation are all in good shape (see 417 non-findings). The carry-over items are addressed as findings #1, #3, #4 (2) 418 and non-findings (3). 419 420 **1. (Security — high) `block` and `left` `Decision`s are adopted from any 421 fetched zone, so a friend can tamper with your relationships and delete your 422 local shared games.** `RecordSerializer.applyDecisionRecord`'s `block` case 423 (`RecordSerializer.swift:1055-1074`) and `left` case (`:1075-1090`) apply with 424 no zone/scope gate at all — unlike the sibling `nickname` case, which requires 425 `record.recordID.zoneID.zoneName == accountZoneID.zoneName && databaseScope == 0` 426 (`:1153-1155`). Both are legitimately written only into the private `account` 427 zone (`FriendController.setBlocked` and `ShareController.leaveShare` both go 428 through `SyncEngine.enqueueDecision`, which targets `accountZoneID`, 429 `SyncEngine.swift:918-919`), but `applyDecisionRecord` runs for *every* fetched 430 `Decision` on both engines (`SyncEngine.swift:1520-1525`). Concrete exploit: Bob 431 is an accepted friend, so he holds `.readWrite` on Alice's inbox zone 432 `friend-<pairKey(Alice,Bob)>` (`FriendController.saveZoneWideShare`, 433 `FriendController.swift:609-623`). Bob saves a record named 434 `decision-block-<Carol>` (`kind=block`, `payload="1"`, a large `version`) into 435 that zone. Alice's private engine fetches her own inbox, the `block` case 436 adopts it, and Alice's `FriendEntity` for Carol is marked `isBlocked` at Bob's 437 version — silently blocking a third party (Alice can no longer invite Carol; 438 `FriendController.sendInvite` throws `.friendBlocked`, `:366`) and forcing a 439 version fight to undo it. The same write with `decision-left-<gameID>` hits the 440 `left` case, which hard-deletes Alice's local participant `GameEntity` for any 441 shared game she's in (`:1083-1090`) — a self-healing but disruptive 442 kick/DoS. (Bob cannot un-block *himself* this way: blocking downgrades him to 443 `.readOnly`, so he loses the inbox write access — the server-side enforcement 444 holds. The exposure is tampering with the victim's state toward *third 445 parties*, and `left`-driven local deletion.) Fix: gate both cases on the 446 private `account` zone + `scope == 0`, exactly as `nickname` does. 447 448 **2. (Security — medium) `applyFriendPing` accepts and starts syncing a share 449 whose zone name is never checked against the bootstrap payload's `pairKey`.** 450 `FriendController.applyFriendPing` validates the *acceptor* against the payload 451 (`FriendZone.canAcceptBootstrap` requires 452 `pairKey(localAuthorID, payload.ownerAuthorID) == payload.pairKey`, 453 `FriendZone.swift:92-96`), but after fetching the share metadata it trusts the 454 accepted zone wholesale: `outboxZoneID = metadata.share.recordID.zoneID` 455 (`FriendController.swift:196`) with no check that 456 `outboxZoneID.zoneName == FriendZone.zoneName(pairKey: payload.pairKey)`. A 457 co-player (bootstrap Pings ride a shared *game* zone, so the attacker must 458 already share a game) can mint a `CKShare` on a zone he owns but *names* 459 `friend-<pairKey(Alice,Carol)>`, then send a `.friend` Ping whose payload sets 460 `ownerAuthorID = Bob` and `pairKey = pairKey(Alice,Bob)` (so `canAcceptBootstrap` 461 passes). Alice accepts and now syncs, at `scope == 1`, a zone Bob controls whose 462 *name* impersonates the Alice–Carol pair. This is the enabler for finding #3, 463 and independently means Bob-controlled `Decision`/`Ping` records flow into 464 Alice's fetch under a forged pair identity. Fix: after fetching metadata, reject 465 the accept unless `outboxZoneID.zoneName == FriendZone.zoneName(pairKey: payload.pairKey)`. 466 467 **3. (Security — medium) `encryptionKey` `Decision` adoption omits the 468 scope-0 gate its sibling `name` case has, allowing a third party's 469 friend-channel key to be poisoned.** The `encryptionKey` case 470 (`RecordSerializer.swift:1172-1187`) verifies the zone *name* matches 471 `friend-<pairKey(localAuthorID, key)>` but — unlike the `name` case, which also 472 requires `databaseScope == 0` (`:1116`) precisely to reject a record seen in a 473 zone we merely joined — applies at any scope. Combined with finding #2, Bob (a 474 mutual co-player who therefore knows Carol's authorID) shares a zone named 475 `friend-<pairKey(Alice,Carol)>`, gets Alice to accept it (scope 1), and writes 476 `decision-encryptionKey-<Carol>` carrying an attacker-chosen 477 `FriendEncryptionKeyPayload`. Alice's fetch adopts it via 478 `FriendEncryptionKeyDirectory.upsert(payload, for: Carol)`. That payload holds 479 the `address` and `contentKey` used when Alice later sends Carol an invite push 480 (`AccountPushCoordinator.swift:473`) and when the NSE decrypts one 481 (`NotificationService.swift:62`), so a poisoned entry misroutes Alice's invite 482 push for Carol to an attacker-chosen address and/or breaks decryption — an 483 invite-push disclosure/DoS scoped to that pair. Fix: add `databaseScope == 0` 484 to the `encryptionKey` gate (fixing #2 also closes the practical path). 485 486 **4. (Security — high, fix owned by Phase 4) The friend inbox is the concrete 487 writable zone that makes Phase 1 finding #1 (account push address/secret 488 adoption) exploitable.** Phase 1 #1 noted that 489 `parseAccountPushAddressDecision` / `parseAccountPushSecretDecision` 490 (`RecordSerializer.swift:219`, `:245`) validate only record 491 type/name/kind/payload and are called for every fetched `Decision` 492 (`SyncEngine.swift:1514-1518`) but speculated about "a writable non-account 493 zone." Phase 3 confirms one exists and is trivially reachable: any accepted 494 friend holds `.readWrite` on your `friend-<pairKey>` inbox in the **private** 495 database (`scope == 0`). A friend can therefore save a record named 496 `decision-account-pushSecret` (`kind=account`, attacker payload, high `version`) 497 into that inbox; your private engine fetches it and surfaces it to 498 `onAccountPushSecret`. Because the push secret is the HMAC key from which every 499 per-game push address is derived (`RecordSerializer.deriveGameAddress`, 500 `:263-273`), adopting a foreign secret lets an attacker DoS push delivery 501 across all of the victim's games and, if the attacker knows the secret they 502 planted, predict/target the victim's per-game push addresses. This raises the 503 practical severity of Phase 1 #1. `applyDecisionRecord`'s own `account`-kind 504 handling isn't the issue (there is none); the gap is these two dedicated 505 parsers plus their unconditional call site. Phase 4 should gate both parsers 506 (or their fetch call site) on the private `account` zone + `scope == 0` when it 507 reviews `AccountPushCoordinator`. Recorded here because Phase 3 surfaces the 508 attack vector; the adoption/republish code is Phase 4. 509 510 **5. (Security/resource — low) An `.invite` Ping's `puzzleSource` is trusted 511 and materialised into a playable game before any shared-zone validation, with 512 no size cap.** `InviteCoordinator.applyInvitePings` stores 513 `payload.puzzleSource` from the friend-zone Ping onto the durable `InviteEntity` 514 (`InviteCoordinator.swift:217`), and `acceptInvite` feeds it to 515 `cloudService.acceptShare(prefetchedPuzzleSource:)` to build a local game before 516 the shared-zone fetch (`:307-313`). The inviter controls this string, and the 517 friend-zone `Ping.payload` is an unindexed record field bounded only by 518 CloudKit's ~1 MB record limit — the comment (`FriendZone.swift:128-134`) 519 assumes "a few KB." A malicious inviter can ship a ~1 MB blob to bloat the 520 invitee's `InviteEntity` and force an oversized XD parse on accept. This is the 521 friend-invite analogue of Phase 1 finding #3 (`puzzleSource` CKAsset without a 522 size cap); the XD-parse hardening itself is Phase 5. Fix: enforce a 523 conservative byte cap on `payload.puzzleSource` before storing/using it, and 524 treat oversize as "fetch from the shared zone instead." 525 526 **6. (Security/resource — low) `room-worker.js` allows unauthenticated room 527 creation and has no rate limiting.** `register` (`room-worker.js:81-109`) 528 creates a Durable Object and persists a secret for *any* well-formed body 529 (`isAcceptableSecret` only requires ≥32 decoded bytes, `:249-258`); the room ID 530 is an arbitrary path segment (`roomRouteFromPath`, `:242-245`) fed straight to 531 `idFromName`. Anyone can therefore mint unbounded rooms (storage/cost) with no 532 auth and no per-IP throttle on `register`/`socket`. Rooms self-expire on idle 533 TTL (`alarm` → `deleteAll`, `:191-196`), so the blast radius is bounded in time, 534 but a burst is uncapped. The TOFU secret model itself is sound — first-write 535 wins, `timingSafeEqual` on re-register, and the secret never travels on a 536 connect (`:93-104`, `:125-128`). Fix: consider a lightweight rate limit / proof 537 requirement on `register`, or accept the risk explicitly given the TTL bound. 538 (In-room `authorID`/`deviceID` are self-asserted query params signed by the 539 *shared* room secret, so any room member can connect as any authorID and the 540 relay does not bind frame authorship — an engagement/presence spoofing surface 541 that belongs to Phase 4's engagement review.) 542 543 **7. (Code quality — low) `FriendController.seedOwnNameDecision`'s `scope` 544 parameter is effectively fixed.** It is only ever called with `scope: 1` 545 (`FriendController.swift:206`), and `establish` doesn't seed a name at all 546 (deferred to the acceptor's `applyFriendPing`). Not a bug, but the parameter 547 implies a flexibility that doesn't exist; inline it or document the single 548 caller. 549 550 **8. (Security — medium) A `.decline` Ping is trusted to evict a share 551 participant without checking that the decliner was ever invited to the named 552 game.** `InviteCoordinator.applyDeclinePing` (`InviteCoordinator.swift:700- 553 725`) acts on any inbound `.decline` Ping addressed to the local user and 554 calls `ShareController.removeFriendParticipant(fromGameID: ping.gameID, 555 userRecordName: ping.authorID)` (`ShareController.swift:211-254`). Both 556 `ping.gameID` and `ping.authorID` are plain CKRecord fields 557 (`Ping.parseRecord`, `Presence.swift:77-108`) on a record written into the 558 *recipient's own inbox* zone — i.e. self-reported by whichever friend wrote 559 it, with no check that this particular friend has any connection to the 560 named game at all. `removeFriendParticipant` only checks that the local user 561 owns the named game (`databaseScope == 0`) and that the named 562 `userRecordName` currently holds a seat on *that* game's share; it never 563 confirms a matching outstanding invite exists for `(gameID, authorID)`, or 564 that the Ping's sender is the one who received that invite. Concretely: any 565 established friend — not necessarily a collaborator on the target game — who 566 can name a live `(gameID, authorID)` pair for one of the owner's *other* 567 shared games (e.g. a former collaborator on that other game, or anyone who 568 otherwise learned the pair) can forge a `.decline` Ping into the owner's 569 inbox and evict that unrelated participant from a game the forger was never 570 part of. The practical bar is knowing a live `(gameID, authorID)` pair, but 571 nothing ties the claimed decliner identity to the actual CloudKit writer, and 572 every accepted friend has permanent inbox write access (the same durable 573 property finding #1 relies on) to attempt it at will. Fix: before freeing the 574 seat, cross-check against a durable record of invites this owner actually 575 sent for that `(gameID, authorID)` pair rather than trusting the Ping's 576 self-reported fields. 577 578 **9. (Code quality — low) `ShareController` hardcodes the zone-wide share 579 record name instead of reusing the existing constant.** 580 `ShareController.zoneWideShareRecordName = "cloudkit.zoneshare"` 581 (`ShareController.swift:10`) duplicates the value of the system constant 582 `CKRecordNameZoneWideShare`, which `FriendController.swift:521,632` and 583 `PlayerRoster.swift:525` already use directly for the identical purpose. 584 Harmless today (the literal is correct), but needless duplication that could 585 silently drift. Replace with `CKRecordNameZoneWideShare`. 586 587 **Non-findings and deferred checks:** 588 - **Carry-over #2 resolved:** `nickname`'s account-zone+scope-0 gate 589 (`RecordSerializer.swift:1153-1155`) is *not* the lone exception — `name` 590 carries an equivalent pair-zone-name + `scope == 0` gate (`:1106-1116`), and 591 both are correct (the `name` gate even documents rejecting a scope-1 sighting 592 of the friend's own inbox). The exceptions that *under*-check are `block`, 593 `left` (finding #1) and `encryptionKey` (finding #3). 594 - **Carry-over #3 (`ShareController` join confirmation) resolved as a 595 non-finding:** `confirmSeatAfterJoin` / `hasLostSeat` 596 (`ShareController.swift:684-732`) is explicitly *cooperative* — CloudKit can't 597 enforce a participant cap, so an over-cap joiner leaves voluntarily and a 598 transient failure lets the join stand. It reads its own identity from 599 `container.userRecordID()` and the server share, adds no trust boundary, and a 600 non-cooperating client is the documented, accepted residual. Ticket-seat 601 consumption uses correct optimistic-lock retries (`:852-897`). 602 - `ShareController`'s owner-only gating is consistent and correct: 603 `prepareShareRecord`, `createShareLink`, `addFriendParticipant`, 604 `removeFriendParticipant`, and `existingShareLink` all guard 605 `entity.databaseScope == 0`; `leaveShare`/`confirmSeatAfterJoin` guard 606 `== 1`. `removeParticipant` refuses to drop the owner (`role != .owner`). 607 - The `sessionInvitedAuthorIDs` re-assert on every invite save correctly 608 guards the eventually-consistent-share window without ever *removing* a 609 participant a fetched share carries (`ShareController.swift:44-51`, 610 `:171-195`). 611 - `room-worker.js` connect auth is solid: HMAC-SHA256 over 612 `roomID|authorID|deviceID|timestamp|nonce`, ±120 s skew window, single-use 613 nonces with TTL pruning, and `timingSafeEqual` on the signature 614 (`:111-155`). No secret is logged or returned. 615 - `link-worker.js` is a safe stateless redirector: the token is constrained to 616 RFC 3986 unreserved chars (`TOKEN_PATTERN`, `:45`) so the target can only ever 617 be `https://www.icloud.com/share/<token>` (no open redirect), title decode is 618 charset-checked → strict-UTF-8 → control-stripped → capped → HTML-escaped at 619 the interpolation site (`:279-301`), and every other interpolated value 620 (`token`, `origin`, `shape`) is charset-validated upstream. `escapeHTML` omits 621 `'`, but every interpolation site is a double-quoted attribute or element text 622 where `'` is inert. Silhouette rendering is bounded to ≤35×35 623 (`parseShape`, `:232`). No ReDoS in the crawler/token patterns. 624 - `ShareLinkRoute` / `ShareLinkShortener` token validation mirrors the worker's 625 and rejects percent-encoded slash smuggling (`ShareLinkShortenerTests` line 626 156-165); `iCloudShareURL`'s force-unwrap is safe behind the charset check. 627 - Invite tokens are not a Crossmate secret to guess: joining is gated by the 628 underlying `CKShare` acceptance (Apple-enforced), not by link 629 unguessability; the short link carries only the iCloud share token, which is 630 as strong as CloudKit makes it. 631 - `KeychainHelper` uses `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` 632 (no iCloud keychain sync, sensible for device-local secrets), update-then-add 633 upsert, and surfaces non-`errSecItemNotFound` failures as thrown errors. 634 - `NicknameDirectory` / `FriendEncryptionKeyDirectory` are App-Group JSON 635 mirrors rebuilt from Core Data ground truth; empty-save removes the file, and 636 the `@TaskLocal` test override keeps parallel suites isolated. (The *source* 637 of a `FriendEncryptionKeyDirectory` entry is the trust gap in finding #3, not 638 the directory storage itself.) 639 - `FriendZone.pairKey` is a symmetric SHA-256 of the sorted author pair 640 (64-hex, deterministic, self-excluding), giving both devices the same zone 641 name without coordination; covered by `FriendZoneTests`. 642 - Re-check the `encryptionKey`/push-address/push-secret *adoption* side and the 643 in-room `authorID` self-assertion in Phase 4 alongside 644 `AccountPushCoordinator` and the engagement layer (findings #4, #6). 645 646 --- 647 648 ## Phase 4 — Push Notifications & Engagement 649 650 **Status:** Done 651 652 Push credential registration, badge/session pushes, the realtime engagement 653 (co-solving) layer, the notification service extension, and the push Worker. 654 655 - `Crossmate/Services/AccountPushCoordinator.swift`, `PushClient.swift`, 656 `PushRequestAuthenticator.swift`, `BadgeCoordinator.swift` 657 - `Crossmate/Sync/GamePushCredentials.swift` 658 - `Crossmate/Services/SessionPushPlanner.swift`, `SessionCoordinator.swift` 659 - `Crossmate/Services/EngagementHost.swift`, 660 `EngagementHostEnvironment.swift`, `EngagementLifecycle.swift` 661 - `Crossmate/Sync/EngagementCoordinator.swift` 662 - `Crossmate/Services/AnnouncementCenter.swift`, 663 `PlayerNamePublisher.swift`, `DriveMonitor.swift`, `InputMonitor.swift` 664 - `Shared/PushPayload.swift`, `PushPayloadCipher.swift`, 665 `NotificationState.swift`, `PuzzleNotificationText.swift` 666 - `Crossmate/Models/PuzzleNotificationText+GameEntity.swift` 667 - `NotificationService/NotificationService.swift` 668 - `Workers/push-worker.js` 669 - `Tests/Unit/AccountPushCoordinatorTests.swift`, 670 `AnnouncementCenterTests.swift`, `Tests/Unit/Sync/GamePushCredentialsTests.swift`, 671 `Tests/Unit/Sync/EngagementCoordinatorTests.swift`, 672 `SessionPushPlannerTests.swift`, `PlayerNamePublisherTests.swift`, 673 `NotificationStateTests.swift`, `PuzzleNotificationTextTests.swift`, 674 `PushPayloadTests.swift`, `PushPayloadCipherTests.swift`, 675 `NotificationNavigationBrokerTests.swift`, `OpenPuzzleBannerTests.swift`, 676 `PlayerSessionNavigationTests.swift` 677 678 ### Findings (consolidated from Sonnet 5 and Opus 4.8, reviewed 2026-07-01) 679 680 Both carry-over items from Phase 1/3 resolve *in* this phase, and both are 681 concrete, high-severity gaps rather than theoretical ones: the account 682 push-secret adoption path (carried since Phase 1) has no defense-in-depth 683 anywhere downstream of the ungated parsers, and the engagement relay's 684 self-asserted identity (carried since Phase 3) turns out to durably corrupt 685 shared grid content, not just spoof presence. Elsewhere the push/crypto core — 686 App Attest enrolment, HMAC request signing, AES-GCM payload sealing — is 687 sound and both passes independently verified it closely. The recurring 688 "blocking Core Data on an async main-actor path" theme from Phases 1/2 689 reappears at three new call sites, found independently by both passes. 690 691 **1. (Security — high) Account push address/secret `Decision`s are adopted 692 with no account-zone gate anywhere in the pipeline, and a friend can hijack 693 the HMAC push secret with an equal-version write.** This is the confirmed 694 resolution of Phase 1 finding #1 / Phase 3 finding #4; the fix is owned here. 695 `RecordSerializer.parseAccountPushAddressDecision` / `parseAccountPushSecretDecision` 696 (`RecordSerializer.swift:219-227`, `:245-255`) validate only record 697 type/name/kind/payload — never zone or database scope. 698 `SyncEngine.handleFetchedRecordZoneChanges`'s `Decision` case 699 (`SyncEngine.swift:1513-1519`) calls both parsers for every fetched Decision 700 on both engines; `scope` is already in scope two lines later (passed into 701 `applyDecisionRecord(..., databaseScope: scope)`) but is never checked before 702 appending to `effects.accountPushAddresses`/`accountPushSecrets`. The 703 `serverRecordChanged` conflict twin (`SyncEngine.swift:1888-1894`) has the 704 same gap, lower-exposure since it only fires on this device's own 705 account-zone save attempts. 706 707 Tracing the adoption side confirms there is no second line of defense. 708 `AccountPushCoordinator.adoptInboundPushAddress` (`AccountPushCoordinator.swift:126-130`) 709 unconditionally caches whatever address it's handed and reconciles. 710 `adoptInboundPushSecret` (`:140-149`) gates only on 711 `version > local || (version == local && secret != current)` — and because a 712 `Decision` with no `version` field maps to `decisionBaseVersion` (1), the 713 *same* value a fresh local mint uses (`RecordSerializer.swift:235`, 714 `AccountPushCoordinator.swift:350`), an attacker doesn't even need a high 715 version: a `decision-account-pushSecret` record with no version field and a 716 differing payload wins outright under the equal-version "server wins" branch. 717 Adoption caches the foreign secret and calls `reconcilePushRegistration`, 718 which re-derives every per-game push address via `deriveGameAddress` 719 (`RecordSerializer.swift:263`), rewrites local Player rows, and — per 720 `updatePushRegistration(republishPlayerRows: true)` 721 (`AccountPushCoordinator.swift:168-190`) — re-publishes the poisoned 722 derivation into every shared game's synced `Player.pushAddress` field via 723 `syncEngine.enqueuePlayer(gameID:authorID:reason:"pushAddress")` 724 (`:178-184`), visible to every co-player in each affected game, not just the 725 attacker. Combined with Phase 3's confirmed reachable vector (any accepted 726 friend holds `.readWrite` on your private-DB `friend-<pairKey>` inbox, which 727 your private engine fetches), this DoSes push delivery across the victim's 728 entire push surface and, since the attacker chose the secret, lets them 729 predict/target the victim's derived push address for any game. 730 731 Fix: gate both call sites (`SyncEngine.swift:1513-1519` and `:1888-1894`) on 732 `record.recordID.zoneID.zoneName == accountZoneID.zoneName && databaseScope == 0`, 733 mirroring the `nickname`/`name` gates already present in 734 `applyDecisionRecord` (`RecordSerializer.swift:1106-1116`, `:1153-1155`). The 735 call site is the right place — the parsers are also used on the local-save 736 reconciliation path where scope/zoneID aren't otherwise threaded through and 737 the gate would be redundant. Separately, tighten the equal-version branch so 738 a foreign inbox copy with a missing/matching version can never win. 739 740 **2. (Security — high) The realtime engagement channel trusts each frame's 741 self-asserted `authorID`/`deviceID`/`cellAuthorID`/`updatedAt`, letting a 742 room member durably corrupt and misattribute shared grid content — the 743 concrete client-side consequence of Phase 3 finding #6's spoofable room 744 identity.** `room-worker.js`'s `webSocketMessage` (`room-worker.js:157-170`) 745 is a blind byte relay: `peer.send(data)` forwards the raw frame to every 746 other connected socket with zero inspection or identity binding — so a 747 frame's `authorID`/`cellAuthorID`/`deviceID` fields are entirely 748 self-chosen, not even tied to the already-spoofable connect-time query-string 749 `authorID` Phase 3 flagged. `EngagementLifecycle.handleEngagementMessage`'s 750 `.cellEdit`/`.cellEditBatch` cases (`EngagementLifecycle.swift:346-376`) hand 751 the decoded edit straight to `store.applyRealtimeCellEdit(s)` with no 752 roster/identity check. `GameStore.applyRealtimeCellEdits` 753 (`GameStore.swift:884-953`) only rejects an empty `authorID`/`deviceID` or a 754 `deviceID` equal to the *local* device (`:897-902`) — unlike 755 `EngagementStore.set` (`EngagementStore.swift:48-65`), which at least 756 requires a cursor's `authorID` to resolve to a known roster player color 757 before accepting it. The edit's `letter`/`mark`/`updatedAt`/`cellAuthorID` 758 are written straight into the same local `MovesEntity` cache that legitimate 759 CloudKit-fetched Moves records for that identity populate, using the 760 attacker-supplied `updatedAt` unclamped against the local clock. 761 762 The escalation: when the real CloudKit Moves record for the impersonated 763 `(authorID, deviceID)` eventually syncs, `RecordSerializer.applyMovesRecord` 764 merges cell-by-cell against the existing cache via `mergeIncomingMovesCells` 765 (`RecordSerializer.swift:1014-1027`, the same tie-break flagged in Phase 1 766 finding #9), keeping whichever `updatedAt` is newer. A frame stamped with a 767 far-future `updatedAt` therefore wins that per-cell LWW race permanently — 768 the genuine author's real, later edits to that exact cell can never overwrite 769 it again through incremental sync, on any device that was live-connected to 770 the room when the frame arrived. Concrete exploit: Bob, an accepted 771 collaborator (so he legitimately holds the room secret), sends a 772 `cellEditBatch` claiming `authorID`/`cellAuthorID` = Carol (a real co-player) 773 with `updatedAt` far in the future; every live peer applies it, and Carol's 774 genuine future keystrokes at that cell never win again on those devices. 775 Mitigating factors: presence (`hasPeer`) is derived from `Player.readAt` 776 leases in Core Data, not the room (`EngagementLifecycle.swift:116-121`), so 777 the spoof can't fake presence; no push is emitted from an inbound frame; and 778 the corruption never re-uploads to CloudKit (outbound Moves are always built 779 from the local user's own identity), so blast radius is bounded to devices 780 live-connected at attack time — but for those devices it is durable, not 781 transient. 782 783 Fix: have `room-worker.js` stamp the connection's own HMAC-verified 784 `authorID`/`deviceID` onto (or strip from) relayed frames rather than passing 785 client-chosen identity through untouched; on the client, validate 786 `edit.authorID` against the known roster before writing into the durable 787 cache (mirroring `EngagementStore.set`'s gate); and/or clamp 788 `edit.updatedAt` to `≤ now + small skew` so a spoof can't win LWW durably — 789 ideally combined with keeping live-channel content in a short-lived overlay 790 that any subsequent real CloudKit fetch unconditionally supersedes, rather 791 than merging it into the persisted cache CloudKit's own merge also reads 792 from. 793 794 **3. (Security/Availability — medium) `push-worker.js` has no rate limiting 795 at either the authenticated `/publish` endpoint or the unauthenticated App 796 Attest bootstrap endpoints.** Every client-side send throttle 797 (`SessionCoordinator.nudgeCooldown` = 60s, 798 `AccountPushCoordinator.accountSeenCoalesceWindow` = 30s, etc.) is enforced 799 only in `PushClient`/`SessionCoordinator` — nothing in `handlePublish` 800 (`push-worker.js:414-497`) limits how often a given `credID`/address may be 801 published to. A malicious collaborator already has everything needed to 802 bypass the app entirely: `GamePushCredentials` (secret + `credID`) is synced 803 into the Game record's `notification` field for every participant 804 (`RecordSerializer.swift:413`, `:858-868`), and every other participant's 805 derived `pushAddress` is a plain field on the synced `Player` record 806 (`RecordSerializer.swift:560-563`, `:624-631`) — both readable directly off 807 CloudKit by any collaborator. A co-player can script raw, correctly-signed 808 `/publish` calls at unlimited frequency, sending real alert pushes (sound + 809 banner) to every co-player with none of the app's UI-layer cooldowns 810 applying — the caller is fully App-Attest-authenticated, so this can't be 811 dismissed as requiring attacker infrastructure. Separately, `/attest/challenge` 812 and `/attest/register` (`push-worker.js:143-162`, `:164-222`) necessarily run 813 *before* auth (they bootstrap the key) and take an attacker-chosen 814 `deviceID`; `pruneExpired` only sweeps the same `deviceID` prefix 815 (`:153`, `:313-323`), so an attacker rotating `deviceID` accumulates 816 `appattest-challenge:*` storage rows faster than they TTL out. Both are the 817 same class of gap Phase 3 flagged for `room-worker.js`'s open `register` 818 (finding #6); rooms/challenges self-expire so the blast radius is 819 time-bounded, but a burst is uncapped. Fix: add a per-`credID`/per-address 820 sliding-window rate limit on `/publish`, and a lightweight per-IP/per-device 821 throttle on the attest bootstrap endpoints. 822 823 **4. (Performance — medium) Three new call sites repeat the recurring Phase 824 1/2 "blocking Core Data on an async `@MainActor` path" pattern — found 825 independently by both review passes.** Each creates a background context and 826 calls `ctx.performAndWait { ... }` synchronously from an `@MainActor` chain 827 with no intervening `await`, blocking the main thread for the fetch's 828 duration despite the enclosing methods being `async`: `SessionCoordinator.pushPlan` 829 (`SessionCoordinator.swift:521-553`), invoked from `nudge`, 830 `publishJoinPush`, `publishSessionEndPush`, `publishCompletionPush`, and 831 `publishReplayPush` — all reachable from the active puzzle's foreground path, 832 and the fetch pulls the game plus all its `PlayerEntity` rows; 833 `AccountPushCoordinator.friendEncryptionKeyTargets` 834 (`AccountPushCoordinator.swift:232-251`), invoked from every 835 `reconcilePushRegistration` and `publishInvitePush`; and 836 `PlayerNamePublisher.friendZoneTargets` / `upsertPlayerRecord` 837 (`PlayerNamePublisher.swift:167`, `:192`), invoked from `fanOut`/ 838 `publishName(for:)` — the latter on every shared-puzzle open. Fix: convert to 839 `await ctx.perform { ... }` throughout. 840 841 **5. (Resource — low) Game push credentials are never expired or cleaned up 842 after a game is deleted or archived.** `GamePushCredentials` is deliberately 843 durable/no-expiry by design (`GamePushCredentials.swift:25`), unlike 844 `EngagementRoomCredentials`'s 10-minute TTL, but no client call anywhere 845 unregisters a game credential or its bound addresses from `push-worker.js` — 846 `PushClient.unregister(bindings:)` exists but is only driven by 847 `setAddresses`'s diff against the current live binding set, never by game 848 deletion. This slowly accumulates orphaned `gamecred:<uuid>` and 849 `addr:<credID>:*` Durable Object storage entries for every game a user ever 850 plays and later deletes/archives. 851 852 **6. (Correctness/privacy — low) A failed push `unregister` is silently 853 forgotten, leaving a stale address registered with the worker.** 854 `PushClient.reconcile` (`PushClient.swift:170-203`) awaits 855 `unregister(bindings:)`, which catches and swallows its own errors 856 (`:448-467`) rather than throwing, then unconditionally advances 857 `lastRegistered = signature` (`:196`). If the DELETE fails (transport blip, 858 worker 5xx), the removed bindings drop out of `lastRegistered.bindings`, so 859 the next reconcile computes an empty `toRemove` and never retries — the 860 stale address→token mapping persists on the worker, so after leaving a game 861 or switching accounts the device can keep receiving that game's pushes until 862 an APNs 410 or token rotation clears it. Fix: only advance `lastRegistered` 863 for bindings that actually unregistered, or re-drive removals until 864 confirmed — the register side already models this correctly by leaving 865 `lastRegistered` unchanged on failure. 866 867 **Non-findings and deferred checks:** 868 - `PushPayloadCipher` uses AES-GCM correctly (combined nonce‖ciphertext‖tag 869 box, random nonce, ≥32-byte key enforcement) and fails closed to `nil` on 870 any decode/verify failure — no plaintext leak on error 871 (`PushPayloadCipher.swift:26-52`). `NotificationService.swift:52-98` 872 correctly falls back to the generic cleartext body when `enc` is absent or 873 undecryptable, and never force-unwraps worker-sourced fields. 874 - `PushRequestAuthenticator`/`push-worker.js`'s App Attest scheme (challenge/ 875 attest/assertion, full CBOR/DER/cert-chain parsing, per-device 876 nonce+timestamp binding, `timingSafeEqual` throughout) is sound and matches 877 Apple's documented scheme; `GamePushSigner`'s HMAC game-participation 878 signature (`credID|bodyHash|timestamp|nonce`, with `bodyHash` covering 879 `credID`) is correctly bound so `credID` can't be swapped without breaking 880 App-Attest auth. Considerably more robust than `room-worker.js`'s 881 HMAC-only connect auth reviewed in Phase 3. 882 - `push-worker.js`'s game-credential registration and `/publish` *are* gated 883 behind full App Attest auth (checked before routing, 884 `push-worker.js:19-49`) — a meaningful contrast with `room-worker.js`'s 885 fully open `register` (Phase 3 finding #6); only the missing rate limit 886 (finding #3) carries equivalent risk forward. 887 - Account-scoped pushes (the `acct-<UUID>` address) are not spoofable 888 without the account zone: the address is random, not secret-derived, and 889 published only into the private account zone 890 (`push-worker.js:405-413`, `:529-563`). 891 - `EngagementHost`'s socket connect auth mirrors the Phase-3-approved 892 room-worker connect auth (HMAC over 893 `roomID|authorID|deviceID|timestamp|nonce`, secret never in the URL); 894 `EngagementCoordinator`'s connect/migrate/teardown state machine and 895 stale-connection sweep are clean. 896 - `applyGameRecord`'s adoption of the `engagement`/`notification` fields 897 (`RecordSerializer.swift:848-868`) is correctly scoped — these live on the 898 Game record itself, already resolved to the correct per-game zone/entity, 899 so "whoever can write this game's own zone" is the intended trust boundary 900 (the game's own CKShare participants), unlike the account-zone Decision 901 gap in finding #1. 902 - `AnnouncementCenter`, `BadgeCoordinator`/`NotificationState`'s 903 `seenAt`/`suppressedUntil` ledger, `PuzzleNotificationText`(+`GameEntity`), 904 `DriveMonitor`, `InputMonitor` are straightforward and correct; no bugs 905 found. No force-unwraps on untrusted CloudKit- or worker-sourced data 906 anywhere in the Phase 4 file set. 907 - `GamePushCredentialsTests` confirms the HMAC key-derivation contract 908 (base64url-decoded secret bytes, not UTF-8) matches the worker's 909 `hmacSHA256` exactly — no drift risk. 910 - `fromAuthorID` on a push is worker-forwarded and attacker-settable but is 911 display-only: the NSE only rewrites the body when the sealed payload 912 actually decrypts, so a spoofed `fromAuthorID` on an undecryptable push 913 yields the generic body, not a nickname leak. 914 - Carry over to **Phase 5**: `DriveMonitor.readSource`/`importFile` reads a 915 user-selected local/iCloud-Drive `.xd`/`.puz` file with no size cap before 916 `PUZToXDConverter.convert` — not a remote trust boundary like Phase 1 #3 / 917 Phase 3 #5, but worth folding into Phase 5's general XD/PUZ parse hardening 918 review. 919 - Carry over to **Phase 6**: finding #2's exploit rides the same LWW 920 tie-break flagged in Phase 1 finding #9; a `now`-clamp or monotonic guard 921 there would also harden the CloudKit ingest path, worth revisiting 922 alongside gameplay/grid state in Phase 6. 923 - Carry over to **Phase 7**: `NotificationNavigationBroker`/ 924 `PlayerSessionNavigation` live in the app-shell composition root, outside 925 this phase's file list; a shallow look shows they only route an 926 already-parsed `gameID` to in-app navigation with no obvious trust-boundary 927 issue, but they deserve a full pass in Phase 7. 928 929 --- 930 931 ## Phase 5 — Puzzle Import & Format Conversion 932 933 **Status:** Done 934 935 Bringing puzzles into the app: NYT auth/fetch, `.puz`/NYT-JSON → XD 936 conversion, and the XD format itself. 937 938 - `Crossmate/Services/NYTAuthService.swift`, `NYTPuzzleFetcher.swift`, 939 `NYTPuzzleUpgrader.swift`, `NYTToXDConverter.swift` 940 - `Crossmate/Services/PUZToXDConverter.swift`, `ImportService.swift` 941 - `Crossmate/Models/XD.swift`, `XDFileType.swift`, `XDMarkup.swift` 942 - `Crossmate/Models/PuzzleCatalog.swift`, `PuzzleSource.swift`, 943 `Puzzle.swift` 944 - `Puzzles/cm-starter/*`, `Puzzles/debug/*` 945 - `Tests/Unit/NYTAuthServiceTests.swift`, `NYTPuzzleUpgraderTests.swift`, 946 `NYTToXDConverterTests.swift`, `PUZToXDConverterTests.swift`, 947 `PuzzleCatalogTests.swift`, `XDAcceptTests.swift`, `XDMarkupTests.swift` 948 949 ### Findings (consolidated from Sonnet 5 and Opus 4.8, reviewed 2026-07-01) 950 951 This is the phase where the Phase 1 finding #3 / Phase 3 finding #5 carry-over 952 ("`puzzleSource` reaches the parser with no size cap") cashes out concretely — 953 and the result is sharper than either earlier phase assumed: the parser itself 954 has an unmemoized exponential algorithm that a **sub-300-byte** input can drive 955 into a multi-hour hang, so a byte-size cap alone would not have closed the 956 gap. Both passes found this independently; Sonnet 5 additionally built a 957 standalone harness from the real source and empirically measured it. The 958 second major finding — a `.puz` rebus-placeholder allocator that both 959 misassigns reserved characters and traps on `UInt8` overflow — was also found 960 independently by both passes and empirically confirmed. All file:line 961 references below were spot-checked directly against the current source. 962 963 **1. (Security/Performance — high, empirically confirmed) `XD.segment` is an 964 unmemoized exponential backtracking search, reachable from attacker-controlled 965 `puzzleSource` on the CloudKit sync path and, via invite acceptance, on the 966 main actor.** `XD.segment` (`XD.swift:705-748`) assigns free-length substrings 967 from a clue's `~` answer onto a word's still-unsolved cells via plain 968 recursion (`recurse(cellIndex:answerIndex:current:)`, `:706-742`). The 969 `maxResults` cap (`results.count < maxResults` at `:709`) only prunes *after* 970 a complete valid segmentation is found; when the crafted answer text has 971 **zero** valid segmentations against a word's already-crossing-fixed cells, 972 the full composition tree is exhausted before returning empty. For a run of 973 *E* adjacent unsolved cells constrained only by one trailing fixed-letter 974 cell, this is `O(2^E)`-ish combinatorial work. Sonnet 5 reproduced this on 975 real device hardware from the extracted source: a 2-column synthetic grid (one 976 down-word of unconstrained cells + one crossing-fixed cell) measured 2.13s at 977 14 free cells / slack 10 (~1.14M compositions) and **32.2s at 16 free cells / 978 slack 12** (~17.4M compositions, from a ~250-byte source file), extrapolating 979 to minutes-to-hours at 18-20 free cells — all comfortably under 1 KB. 980 981 Reachability has no existing mitigation: `RecordSerializer.applyGameRecord` 982 calls `XD.parse(source)` on the synced Game record's `puzzleSource` CKAsset 983 with no complexity guard (`RecordSerializer.swift:875`) — the same asset 984 Phase 1 #3 flagged for size only. Concretely for invites, the exact Phase 3 #5 985 vector completes the chain: `InviteCoordinator.acceptInvite`'s 986 inviter-controlled `payload.puzzleSource` flows through 987 `CloudService.acceptShare(prefetchedPuzzleSource:)` 988 (`CloudService.swift:55`, `:82`) into 989 `GameStore.constructJoinedGame(gameID:zoneID:source:)` 990 (`GameStore.swift:1202`), which calls `try XD.parse(source)` directly at 991 `:1209` — and `GameStore` is `@MainActor` (`GameStore.swift:504`), confirmed 992 by direct inspection, so this is a **synchronous main-thread hang**: a friend 993 sends one crafted invite and the victim's UI freezes solid the moment they tap 994 Accept (likely followed by an iOS watchdog kill). The same call also fires at 995 ordinary game-open/local `.xd` import. Recursion depth is bounded by word 996 length, so this is pure combinatorial-time DoS, not a stack overflow. Fix: 997 memoize `segment` on `(cellIndex, answerIndex)` — this turns it into standard 998 `O(word.count × answer.count)` word-break DP, since the failing subproblems 999 repeat — and/or cap total recursive-call count and free-cell run length ahead 1000 of a fail-closed error. This finding supersedes the "just add a size cap" 1001 framing from Phase 1 #3 / Phase 3 #5: a byte cap remains worth adding for 1002 memory/parse-cost reasons generally (see #5), but it does not bound this 1003 specific attack. 1004 1005 **2. (Correctness/Security — high, empirically confirmed) `PUZToXDConverter`'s 1006 rebus-placeholder allocator is keyed by cell index instead of by distinct 1007 rebus value, so it both misassigns reserved grammar characters at low counts 1008 and hard-crashes via `UInt8` overflow past ~207 rebus-covered cells.** The 1009 loop at `PUZToXDConverter.swift:78-87` walks every rebus-covered cell once 1010 (`for index in 0..<cellCount where isOpen(...)`) and assigns a fresh key from 1011 `nextRebusKey`, plain (non-`&+=`) `UInt8` arithmetic starting at 1012 `Character("1").asciiValue!`: `guard rebusKeys[index] == nil else { continue }` 1013 never fires because `rebusKeys` is keyed by cell **index** and each index is 1014 visited exactly once — the guard cannot dedupe by *value*, unlike the correct 1015 value-keyed `NYTToXDConverter.rebusLookup` (`NYTToXDConverter.swift:150-163`, 1016 whose header comment at `:22-23` explicitly documents having already hit and 1017 fixed this exact class of bug: "walking ASCII naïvely from `'1'` overflows 1018 into `=` by the 13th key, which produced an unparseable `==VALUE` entry"). So 1019 even a `.puz` with only a handful of *distinct* rebus values spread across 1020 enough cells burns through placeholders one-per-cell: the 13th assigned key is 1021 `=`, breaking the `Rebus: key=value` grammar into an unparseable entry; the 1022 16th is `@`, which silently re-parses as a *circled*-cell marker instead of a 1023 rebus key. At the 207th rebus-covered cell, `nextRebusKey` reaches 256 and 1024 `nextRebusKey += 1` **traps**, crashing the app outright. Sonnet 5 confirmed 1025 the crash empirically against a synthetic 256-cell 16×16 `.puz` with only 3 1026 distinct rebus values mapped across all cells (exit 133, fatal arithmetic-overflow 1027 trap), proving the bug is cell-count-keyed, not distinct-value-keyed as a 1028 size-based mitigation might assume. `PUZToXDConverterTests.swift` has no 1029 rebus/`GRBS`/`RTBL` coverage at all, which is exactly why this shipped 1030 unnoticed. Reachable via any locally-imported `.puz` (Files app, AirDrop, 1031 email) — a sufficiently rebus-heavy variety puzzle could trigger the 1032 `=`/`@` corruption by accident, no malice required. Fix: dedupe by rebus 1033 *value* into a `[String: Character]` map exactly mirroring 1034 `NYTToXDConverter.rebusLookup`, reusing its curated, reserved-character-avoiding 1035 `rebusPlaceholders` set (`NYTToXDConverter.swift:24-30`) instead of raw ASCII 1036 walking, and use `&+=` or a bounds check regardless. 1037 1038 **2b. (Correctness — medium; NOT found in this audit, discovered 2026-07-01 1039 while fixing #2 — TODO H2b) `PUZToXDConverter.parseRebus` reads the `GRBS` grid 1040 byte directly as the `RTBL` key, but `.puz` stores `GRBS = RTBL_key + 1`.** The 1041 loop at `PUZToXDConverter.swift:338-343` did `let key = Int(grid[gridIndex]); 1042 table[key]`, treating the 1-indexed grid byte as a 0-indexed table key. The 1043 `.puz` format reserves `GRBS` byte 0 for "no rebus" and stores `1 + n` for the 1044 square whose solution is `RTBL` key `n` — confirmed independently by the puz 1045 FileFormat wiki ("A value of 1+n indicates a rebus square … key n in the RTBL 1046 section"), puzpy (`self.table[i] = k + 1`), and other implementations. Effect on 1047 a *real* Across Lite rebus file: a single-value rebus (`RTBL` key 0, `GRBS` byte 1048 1) looked up the absent `table[1]` and silently dropped every rebus cell — the 1049 cell fell back to its raw single-letter solution byte; a multi-value rebus 1050 mis-mapped each cell to the *next* table entry and dropped the last. Silent 1051 corruption, never a crash, which is why it went unnoticed (and why the #2 1052 allocator crash was in practice only reachable from a hand-crafted file, since 1053 no real rebus `.puz` ever populated the rebus map). Fix applied: `table[key - 1]` 1054 with the grid byte guarded `> 0`; regression test `singleRebusValueDecodes`. 1055 1056 **3. (Security/Correctness — medium) `NYTToXDConverter.convert` doesn't 1057 validate that `dimensions.width`/`height` are positive before using them, 1058 crashing on a malformed NYT response; a sibling cell-index access is 1059 similarly unguarded.** `convert(jsonData:)` extracts `width`/`height` as plain 1060 `Int` from JSON (`NYTToXDConverter.swift:71-75`) and only checks 1061 `cells.count == width * height` (`:81-83`) before looping 1062 `for row in 0..<height { for col in 0..<width { ... } }` (`:173-175`). A 1063 response with `width: -1, height: -1` and a single-element `cells` array 1064 satisfies `(-1)*(-1) == 1 == cells.count` and passes every guard, then traps 1065 with `Fatal error: Range requires lowerBound <= upperBound` — confirmed by 1066 running the converter against a crafted minimal body. Separately, 1067 `let answerStr = cellIndices.compactMap { answers[$0] }` (`:241`) subscripts 1068 `answers` with `clue["cells"]` indices with no bounds check — an 1069 out-of-range/negative index is a fatal array access — while the sibling 1070 `acceptedAnswerVariants` correctly guards the same kind of access with 1071 `answers.indices.contains` (`:330`). NYT's API is comparatively trusted, but 1072 it's still externally-supplied structure parsed as untrusted input per this 1073 phase's brief; a corrupted proxy/cache, API regression, or on-device MITM 1074 crashes the fetch/upgrade flow. Fix: `guard width > 0, height > 0` alongside 1075 the existing cell-count check, and guard every `answers[$0]` access the same 1076 way `acceptedAnswerVariants` already does. 1077 1078 **4. (Security/privacy — medium) `NYTPuzzleFetcher` unconditionally logs a 1079 partial NYT session cookie and full request/response bodies via raw `print`, 1080 not gated by `#if DEBUG`.** `fetchPuzzle` prints 1081 `"Cookie header: NYT-S=\(cookie.prefix(40))..."` 1082 (`NYTPuzzleFetcher.swift:99`, confirmed) — 40 characters of the live session 1083 cookie that authenticates every NYT request — plus the request URL, full 1084 response bodies up to 1000-3000 chars, and the entire converted `.xd` source 1085 (`:97-133`, confirmed), on every single fetch, in release builds, since `print` 1086 is not stripped. `NYTAuthService.completeSignIn` logs only cookie *names* and 1087 *domains*, never values (confirmed) — the more careful pattern already exists 1088 in this file set and simply wasn't applied here. This is a real departure from 1089 the discipline Phases 1/3/4 verified for `CloudDiagnostics` and the push/crypto 1090 layers (never log tokens/secrets even truncated); a 40-char cookie prefix 1091 reaching Console.app/sysdiagnose/MDM log collection/screen-share is a genuine, 1092 if not catastrophic (NYT-S is an ordinary web session cookie, not a 1093 cryptographic key), credential-exposure surface. `fetchPuzzleList`, which 1094 carries the same logging, also has no callers anywhere in `Crossmate/` or 1095 `Tests/` — dead debug scaffolding left in the shipped file. Fix: delete all of 1096 these prints (or route a redacted line through the same event-logging pattern 1097 used elsewhere), and remove the dead `fetchPuzzleList` method or wire it up. 1098 1099 **5. (Performance — low) No length/complexity cap on clue text compounds two 1100 independently-quadratic-or-worse render/parse paths.** `XDMarkup.segments` 1101 (`XDMarkup.swift:61-86`) rescans from `i+2` to end-of-string on every 1102 unterminated markup open (`{` + valid marker with no matching close) and falls 1103 through to a single-character advance rather than skipping the failed scan — 1104 clue text with many repeated unterminated sequences (e.g. `{/{/{/{/...`) 1105 triggers a full rescan per occurrence. `Puzzle.init`'s `parseCrossReferences` 1106 (`Puzzle.swift:305-336`) runs a lazy-quantified regex over every clue's text at 1107 construction time, which — per finding #1's reachability — executes on the 1108 sync worker for attacker-influenced clue text; not exponential, but unbounded 1109 polynomial cost purely because clue length is unbounded. Both are lower 1110 severity than finding #1 (clue prose is normally short, and `XDMarkup` runs on 1111 render rather than the sync/accept hot path) but reinforce the same theme: add 1112 a conservative byte/length cap on `puzzleSource` and per-clue text before 1113 parsing, at every boundary identified in Phase 1 #3 / Phase 3 #5 / the Phase 4 1114 `DriveMonitor` carry-over (`ImportService.importGame`'s uncapped 1115 `String(contentsOf:)`/`Data(contentsOf:)` at `ImportService.swift:27,29` is the 1116 same gap). This bounds memory/parse cost generally even though, per finding 1117 #1, it does not alone bound the exponential case. 1118 1119 **6. (Consistency — low) Bundled/debug `.xd` sources are stamped `CmVer: 3` 1120 against a current parser version of 7.** Every file under `Puzzles/cm-starter/*` 1121 and `Puzzles/debug/*` carries `CmVer: 3` while `XD.currentCmVersion == 7` 1122 (`XD.swift:7`). All 13 bundled/debug puzzles were parsed end-to-end with the 1123 real parser (not just cross-checked against manifests) and load correctly 1124 regardless, and grid/`blockMask` consistency against each manifest was also 1125 verified directly — this is purely a stale version stamp. Worth confirming 1126 whether bundled-puzzle staleness/refresh logic (Phase 2) treats a `CmVer` 1127 behind the parser version as meaningful, or whether it should — otherwise 1128 just re-stamp the bundled sources to track the authoring tool's actual output. 1129 1130 **7. (Code quality — low) `PUZToXDConverter` indexes `Data` with absolute 1131 offsets that assume a zero-based buffer.** `data[0x2C]`, `asciiString(in:range:)`, 1132 and `Array(data[solutionStart..<fillStart])` (`PUZToXDConverter.swift:37-52`) 1133 use literal/absolute offsets into `Data`. Safe with the current caller 1134 (`Data(contentsOf:)` is zero-based) but would crash on any `Data` slice with a 1135 non-zero `startIndex`. Normalize with `data.startIndex + offset`. 1136 1137 **Non-findings and deferred checks:** 1138 - No force-unwraps in `XD.swift` on untrusted input: all regex captures are 1139 `Int`-guarded, grid/word walks stay bounds-checked through `isOpen`/`isBlock`, 1140 and `Numbering`/segment array accesses stay within grid bounds. The only 1141 algorithmic hazard is `segment` itself (finding #1), not a crash. 1142 - `XDMarkup` degrades safely (no crash) on an unterminated span or an invalid 1143 `{` marker — only the perf cost in finding #5. 1144 - `XD.Numbering.build` and `Puzzle.init`'s independent clue-numbering 1145 computation agree (checked directly; a mismatch would silently misnumber 1146 clues). 1147 - `NYTToXDConverter`'s *own* rebus placeholder set correctly avoids every 1148 reserved grid/header character and is tested 1149 (`manyRebusFillsAvoidReservedKeys`) — the exact hardening `PUZToXDConverter` 1150 lacks (finding #2); its header comment even documents having hit and fixed 1151 the identical bug class previously. 1152 - `NYTAuthService` credential handling is sound: cookie/email go through 1153 `KeychainHelper` (`kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`, no 1154 iCloud sync, proper upsert — consistent with Phase 3's review of the same 1155 helper); `cookieLoadResult`/keychain-load logging records only status and a 1156 `hasData` boolean, never cookie content (contrast with finding #4); a locked 1157 device correctly maps `errSecInteractionNotAllowed` to 1158 `.temporarilyUnavailable` rather than a false sign-out. 1159 - `NYTAuthService.extractAccountGraphQLConfiguration`'s HTML-scraping regex 1160 (`firstJSONStringValue`) is single-pass with mutually-exclusive alternation 1161 on first character — no catastrophic backtracking, and it reconstructs a 1162 well-formed JSON literal to decode escapes safely rather than hand-parsing 1163 them. 1164 - `NYTPuzzleUpgrader.structuralDivergence` correctly gates source replacement 1165 on identical dimensions + block layout + per-cell solution; both 1166 `.mismatched`/`.failed` paths preserve the player's existing moves, and 1167 `.plan` only fires for owner-side NYT puzzles. 1168 - `PUZToXDConverter.decodeString`'s CP1252→Latin1→UTF8 fallback chain 1169 terminates in a non-optional decode; the non-rebus parsing (dimensions, 1170 string table, `GEXT`/circled cells, clue-count reconciliation) validates 1171 every offset/length against `data.count` before slicing, and `width`/`height` 1172 are single bytes (≤255) so `cellCount` can't overflow. 1173 - `Puzzle.init`'s loops can't see a mismatched width/height, because `Puzzle` 1174 is only ever constructed from an already-successfully-parsed `XD`, whose 1175 `width`/`height` are derived from the same grid data its rows were built 1176 from. 1177 - `PuzzleCatalog` manifest loading is defensive (`try?` throughout, skips any 1178 bundle directory with an unreadable/undecodable manifest) and reads only 1179 app-bundle resources — not a trust boundary, no user input reaches it. 1180 - `ImportService.importGame` is a thin, straightforward extension dispatcher; 1181 its missing size cap is the same Phase 4 `DriveMonitor` carry-over folded 1182 into finding #5 above (and, per finding #1, a size cap wouldn't have stopped 1183 that specific attack anyway). 1184 - Nothing new carries forward to Phase 6/7 beyond what's already noted above — 1185 the XD/PUZ/NYT parsing surface is self-contained within this file set, and 1186 finding #1's exploit path terminates at `GameStore`/`XD.parse` reviewed here, 1187 even though its *reachability* runs through Phase 2/3/6 call sites. 1188 1189 --- 1190 1191 ## Phase 6 — Gameplay & Puzzle UI 1192 1193 **Status:** Done 1194 1195 The actual solving experience: the puzzle screen, custom keyboard, replay, 1196 and session state. 1197 1198 - `Crossmate/Views/Puzzle/*` 1199 - `Crossmate/Services/PuzzleSession.swift`, `ReplayLoader.swift` 1200 - `Crossmate/Models/ReplayControls.swift`, `CheckResult.swift` 1201 - `Crossmate/Services/GridSilhouette.swift` 1202 - `Tests/Unit/PuzzleSessionTests.swift`, `ReplayCacheTests.swift`, 1203 `ReplayControlsTests.swift`, `GridSilhouetteTests.swift`, 1204 `GameSummaryThumbnailTests.swift`, `PendingEditFlagTests.swift` 1205 1206 ### Findings (consolidated from Sonnet 5 and Opus 4.8, reviewed 2026-07-01) 1207 1208 This is one of the cleaner phases so far. `GridView`'s single-`Canvas` redesign 1209 is carefully reasoned about render cost, `PuzzleSession`'s grace-timer/ 1210 background-assertion state machine is cleanly modeled and well-tested, and 1211 `GridSilhouette`'s codec is defensive with no force-unwraps anywhere in the 1212 file set. The puzzle UI itself turns out to be a pure, passive consumer of 1213 already-merged grid state — it does not re-implement or second-guess any 1214 last-writer-wins tie-break, which resolves the Phase 4 carry-over as a 1215 non-finding for new gameplay-side logic (see below), though tracing the 1216 consumers confirms concretely how a Phase 4 finding #2 spoof would surface in 1217 this layer. The one substantive bug both passes independently zeroed in on 1218 different corners of is a replay-completeness gap in the app-shell 1219 composition root that can permanently cache an incomplete scrubber for a 1220 session; the rest is low-severity robustness/quality, including a recurring 1221 "redundant full-grid pass" pattern this audit has flagged in earlier phases. 1222 1223 **1. (Correctness — medium) The app-shell's "local-only" replay fast path 1224 infers a game has no other contributors from an unverified local Core Data 1225 snapshot, and can permanently cache an empty/incomplete replay for the rest 1226 of the session.** `CrossmateApp.swift:787-797`: 1227 ```swift 1228 let entries = store.localJournalEntries(for: gameID) 1229 let otherDevices = store.contributingDevices(for: gameID) 1230 .filter { $0.deviceID != localDeviceID } 1231 if otherDevices.isEmpty { 1232 return .ready(ReplayTimeline(merging: [entries])) 1233 } 1234 ``` 1235 `contributingDevices(for:)` (`GameStore.swift:2313-2324`) only reads this 1236 device's already-synced local `MovesEntity` rows — unlike 1237 `ReplayLoader.loadReplay` (`ReplayLoader.swift:45-120`), which derives 1238 `expectedDevices` from a live CKQuery and gates on every contributor's journal 1239 having landed. If this device's Moves catch-up sync for the game hasn't 1240 finished yet — the common case the first time a device opens an 1241 already-completed shared game (freshly accepted an invite to a finished 1242 puzzle, a reinstall, an iCloud restore, or a collaborator returning to a game 1243 finished while they were away) — `contributingDevices` sees zero remote rows 1244 even though real collaborators exist, so the shortcut wrongly takes the solo 1245 branch and builds `ReplayTimeline(merging: [entries])` from this device's own 1246 (often empty) local journal, bypassing `ReplayAssembler`'s strict-completeness 1247 gate entirely. That wrong result is cached as `.ready` by 1248 `ReplayAssembler.memoised` (`JournalReplay.swift:173-189`) into 1249 `ReplayLoader.replayTimelineCache` (`ReplayLoader.swift:23`); because 1250 `ReplayControls.load` is a no-op once `.ready` (`ReplayControls.swift:169-174`) 1251 and the memo short-circuits on every later call for that `gameID` 1252 (`JournalReplay.swift:180-183`), every subsequent reopen of the puzzle within 1253 the same app session keeps serving the wrong scrubber even after the real 1254 Moves records finish syncing in the background — only a full app relaunch 1255 clears the in-memory cache. Fix: gate the fast path on a hard, durable signal 1256 that the game was never shared (e.g. `!session.mutator.isShared` / 1257 `GameEntity.ckShareRecordName == nil`) rather than on the currently-synced 1258 local Moves cache; for anything shared, always defer to 1259 `services.replays.loadReplay`, which already handles the true solo-but-shared 1260 case correctly via its own `expectedDevices` check. 1261 1262 **2. (Correctness — low) A whitespace-only rebus commit writes a phantom 1263 "filled" cell that silently blocks completion.** 1264 `PlayerSession.committedRebusValue` (`PlayerSession.swift:488-493`) returns 1265 the buffer unchanged whenever it contains no non-whitespace character — 1266 `guard buffer.rangeOfCharacter(from: .whitespacesAndNewlines.inverted) != nil 1267 else { return buffer }` — so a whitespace-only buffer commits as a 1268 non-empty, visually-blank cell instead of clearing it. The reachable path is 1269 the rebus modal's system `TextField` (`PuzzleView.swift:623-637`), which, 1270 unlike the custom keyboard, permits a space; the blank-looking cell then reads 1271 as filled to `PuzzleScoreboard.filledCellCount` and `Game.completionState`, so 1272 a fully-filled grid resolves to `filledWithErrors` with no visibly wrong cell 1273 for the player to find. This lives in `PlayerSession.swift`, a Phase 2 file, 1274 but is recorded here since the rebus-modal gameplay path is what surfaces it. 1275 Fix: treat a whitespace-only committed value as empty, mirroring the 1276 empty-buffer case. 1277 1278 **3. (Code quality/robustness — low) Three render-path dictionaries built with 1279 the trapping `Dictionary(uniqueKeysWithValues:)` sit alongside a fourth that 1280 deliberately defends against duplicate keys.** `GridView.body`'s 1281 `authorTintByID` (`GridView.swift:42-46`), `PuzzleScoreboard.scores` 1282 (`PuzzleScoreboard.swift:124`), and `SuccessPanel.contributions` 1283 (`SuccessPanel.swift:178`) all build a `[String: T]` from 1284 `roster.entries`/`entries` with `Dictionary(uniqueKeysWithValues:)`, which 1285 traps on a duplicate `authorID`. `RecentChangeBorders.resolve`, in the same 1286 `GridView.swift` file, already hedges the equivalent construction with 1287 `Dictionary(..., uniquingKeysWith: { first, _ in first })` 1288 (`GridView.swift:687-690`), showing the author has considered duplicates 1289 possible. Today this is safe by construction — 1290 `ParticipantSummaries.remoteParticipants` de-duplicates via a `Set` before the 1291 local entry is prepended (`ParticipantSummaries.swift:59-66`, 1292 `PlayerRoster.swift:393`) — but the three trapping sites should adopt the same 1293 `uniquingKeysWith` form for defense-in-depth, since a future change to roster 1294 assembly would otherwise turn a benign data glitch into a hard crash while a 1295 puzzle is on screen. 1296 1297 **4. (Code quality — low) `GridSilhouette.decode` accepts over-length / 1298 trailing-garbage payloads instead of requiring an exact bit count.** `decode` 1299 checks only `bits.count == storedCount` after `unpackBits` truncates to 1300 `storedCount` (`GridSilhouette.swift:106-108`); it never rejects a payload 1301 carrying *more* base64url bytes than the dimensions require, so two distinct 1302 segments can decode to the same grid and a segment with appended junk still 1303 decodes successfully. Bounded (≤35×35) and low-impact (only paints a 1304 link-tap placeholder), but a strict `bytes.count == (storedCount + 7) / 8` 1305 check would make the codec total and match the encoder's output exactly. 1306 1307 **5. (Performance/code quality — low) The live scoreboard does repeated 1308 full-grid passes on every keystroke, and duplicates the same 1309 author-normalization/scoring logic across two files.** 1310 `PuzzleScoreboard.swift`'s `fillableCellCount` (`:39-43`), `filledCellCount` 1311 (`:45-56`), `revealedSquareCount` (`:58-69`), and `scores` (`:111-175`) each 1312 independently loop over every cell — four full-grid passes recomputed on each 1313 render, one of which (`fillableCellCount`) doesn't depend on mutable state at 1314 all — and `SuccessPanel.swift`'s `revealedSquareCount` (`:27-40`) and 1315 `contributions` (`:161-232`) repeat the pattern; `SuccessPanel`'s 1316 `normalizedAuthorID` (`:252-257`) is a near-verbatim duplicate of 1317 `PuzzleScoreboard.normalizedAuthorID` (`:177-182`), with the roster-fallback/ 1318 extra-contributor bucketing logic duplicated almost line-for-line between the 1319 two files. Puzzles are bounded to 35×35, so this is genuinely negligible in 1320 practice (no Core Data involved, unlike the recurring blocking-fetch theme 1321 from Phases 1/2/4) — recorded for the redundant-work/duplication pattern, not 1322 as a performance emergency. Worth consolidating into one shared per-grid-stats 1323 helper reused by both views. 1324 1325 **Non-findings and deferred checks:** 1326 - **Phase 4 finding #2 / Phase 1 finding #9 carry-over resolved:** grepped 1327 every `updatedAt`/`cellAuthorID`/`letterAuthorID` use across 1328 `Views/Puzzle/*` and confirmed the gameplay UI adds no independent 1329 clamping, trust check, or tie-break logic of its own — it is a passive 1330 renderer of `session.game.squares[r][c]` and replayed `ReplayFrame.cells`. 1331 The only `updatedAt` comparison in this file set 1332 (`GridView.swift:540`, `RemoteCursorTints.remoteTrackTints`) is cosmetic 1333 cursor-track tint precedence, not cell content. `Square.enqueuedAt`, 1334 applied in `GameStore.restore` (`GameStore.swift:2467-2520`, Phase 2, 1335 covered by this phase's `PendingEditFlagTests.swift`), protects a 1336 locally-buffered keystroke from being clobbered by an inbound merge, but 1337 that is a separate, correctly-functioning mechanism from the Phase 4 #2 1338 spoof path. Tracing the consumers does confirm the concrete UI-visible 1339 blast radius of that already-recorded issue, though: a poisoned 1340 `cellAuthorID` from a spoofed realtime frame would misattribute the 1341 author tint in `GridView` (`GridView.swift:78`, `:91`) and miscount 1342 per-player credit in `PuzzleScoreboard.scores` / 1343 `SuccessPanel.contributions` (`PuzzleScoreboard.swift:118`, 1344 `SuccessPanel.swift:143`, `:173`) — useful color for Phase 4's 1345 already-owned fix, not a new gameplay-side bug. 1346 - `PuzzleSession.swift`'s grace-timer/background-assertion state machine 1347 (`scheduleEndPush`/`cancelPendingEndPush`/`fireEndPush`) is correctly 1348 serialized with no interleaving `await` between guard and mutation, and its 1349 `UIBackgroundTaskIdentifier`/idle bookkeeping matches `PuzzleSessionTests`'s 1350 interleaving cases (including expedited-expiration and supersede paths) 1351 exactly. 1352 - Replay data is a trust boundary but is display-only and crash-safe: a 1353 malicious co-player's uploaded journal can misrender/misattribute a 1354 *finished* game's replay, but never writes back to the live `Game` or to 1355 CloudKit, and every consumer of a replayed cursor position is bounds-checked 1356 (`Puzzle.swift:366-431`, `GridView.swift:74`, `SuccessPanel.swift:240`, 1357 `ReplayControls`'s scrub/playback state machine). 1358 - `ReplayLoader`'s own CloudKit paths are non-blocking 1359 (`store.cachedRemoteJournals`/`cacheRemoteJournals` correctly use 1360 `await ctx.perform`, `GameStore.swift:1459-1525`). Its one synchronous 1361 main-actor read, `store.localReplaySource` → `movesJournal.recordedEntries` 1362 (`ReplayLoader.swift:57`), is **Phase 2 finding #5** reaffirmed from a new 1363 call site (fires once on the finish-banner path), not a new finding. 1364 - `PlayerRoster.refresh`'s `ctx.performAndWait` (`PlayerRoster.swift:256`) is 1365 also reached from this phase's UI (colour change in 1366 `PuzzleToolbarModifier.swift:218`, `PuzzleLifecycleModifier`) — **Phase 2 1367 finding #3** reaffirmed from a new call site, not a new finding. 1368 - No force-unwraps (`try!`, `as!`, `.first!`/`.last!`, bare postfix `!`) 1369 anywhere in `Views/Puzzle/*`, `PuzzleSession.swift`, `ReplayLoader.swift`, 1370 `ReplayControls.swift`, `CheckResult.swift`, or `GridSilhouette.swift`. 1371 `HardwareKeyboardInputView`'s HID-usage mapping safely rejects any 1372 unrecognized `UIKeyCommand.input` via `guard`/`default: return nil` 1373 (`HardwareKeyboardInputView.swift:120-171`), and custom `Layout` 1374 implementations (`KeyboardRow`, `PuzzleGridLayerLayout`/ 1375 `PuzzleGridGeometry`) guard degenerate (zero-size/zero-count) input. 1376 - `PuzzleCommands`/`PuzzleActionTarget` equality is deliberately keyed on 1377 `session === && isEnabled` to stop a UIKit menu-builder reentrancy deadlock 1378 (`PuzzleCommands.swift:26-28`) — correct as documented, not a staleness bug. 1379 - `CheckResult` is a trivial two-case enum with no correctness surface; 1380 cross-reference hatching (`CellPatterns`/`CrossRefLines`) is pure geometry 1381 with `max(2, …)` step floors, no divide-by-zero. 1382 - `GameSummaryThumbnailTests.swift`, `ReplayCacheTests.swift`, and 1383 `PendingEditFlagTests.swift` exercise Phase 2 source (`GameStore.swift`'s 1384 `GameSummary`, `cacheRemoteJournals`/`cachedRemoteJournals`, and 1385 `restore`'s pending-edit flag) rather than any Phase 6 file directly; each 1386 was checked against the corresponding Phase 2 source and found accurate. 1387 - Carry over to **Phase 7**: `PuzzleView`'s `.task` beat sets 1388 `session.recentChanges = loadRecentChanges()` after a 750ms hold 1389 (`PuzzleView.swift:229-233`); `loadRecentChanges` is injected from 1390 `AppServices` (Phase 7) — worth confirming it isn't a synchronous 1391 main-thread Core Data fetch when Phase 7 reviews the composition root, 1392 alongside the existing Phase 4 carry-over note on 1393 `NotificationNavigationBroker`/`PlayerSessionNavigation`. 1394 1395 --- 1396 1397 ## Phase 7 — App Shell & General UI 1398 1399 **Status:** Done 1400 1401 Everything else: app-wide composition/DI, general browsing/list/settings 1402 UI, and cross-cutting diagnostics. 1403 1404 - `Crossmate/Services/AppServices.swift`, `DebuggingMonitors.swift` 1405 - `Crossmate/Views/Browse/*`, `Views/GameList/*`, `Views/Settings/*`, 1406 `Views/Components/*` 1407 - `Crossmate/CrossmateApp.swift` (app-shell composition root; not listed 1408 under any earlier phase, reviewed here for the first time) 1409 - `Tests/Unit/LogScrubberTests.swift` 1410 1411 ### Findings (consolidated from Sonnet 5 and Opus 4.8, reviewed 2026-07-01) 1412 1413 Both passes independently confirmed the two outstanding carry-overs 1414 (`NotificationNavigationBroker`/"`PlayerSessionNavigation`" from Phase 4, 1415 `loadRecentChanges` from Phase 6) as non-findings, and both independently 1416 found the recurring "blocking Core Data on an async `@MainActor` path" 1417 pattern at new composition-root call sites in `AppServices.swift`. The two 1418 passes disagreed sharply on one item — Sonnet 5 flagged `RecordEditorView` 1419 as a high-severity finding, Opus 4.8 assessed the same view as a non-finding 1420 ("a power-user CloudKit-Console substitute, not a cross-trust-boundary 1421 surface"). This was resolved by direct verification of the source below: 1422 Sonnet 5's read is correct and Opus 4.8's is not — the view's `scope: .shared` 1423 option targets `container.sharedCloudDatabase`, i.e. zones *other users* 1424 have shared with the signed-in user, so it is a cross-trust-boundary surface 1425 by design, not merely an owner-facing tool. 1426 1427 **1. (Security — high, verified) A raw CloudKit record browser/editor/deleter 1428 is reachable by any user via a client-side toggle with no other gate, turning 1429 every trust-boundary gap this audit has flagged in Phases 1/3/4 into a 1430 one-tap, no-code exploit.** `RecordEditorView.swift` lets the user pick 1431 `scope: .private` or `.shared`, list every zone in that scope 1432 (`listZonesForEdit`, `CloudDiagnostics.swift:151-162`), list every record of a 1433 chosen type in a zone (`queryRecordsForEdit`, `:169-190`), fetch/edit/save any 1434 record by name (`fetchRecordForEdit`/`saveRecordForEdit`, `:122-145`), and 1435 delete any record via swipe (`deleteRecordForEdit`, `:195-204`) — all backed 1436 by nothing but the CloudKit API itself, confirmed directly against the 1437 source. The only gate is `SettingsView.swift:9`'s 1438 `@AppStorage("debugMode") private var debugMode = false`, flipped by tapping 1439 the About footer text four times (`SettingsView.swift:127-133`, 1440 `easterEggTaps >= 4 { debugMode.toggle() }`) — a runtime UserDefaults flag any 1441 user can flip, not a `#if DEBUG` compile gate, developer entitlement, or 1442 server-side check. Once toggled, "Record Editor" appears as an ordinary 1443 `NavigationLink` in Settings → Debugging (`SettingsView.swift:94-96`). 1444 1445 Critically, `scope: .shared` routes to `container.sharedCloudDatabase` 1446 (`CloudDiagnostics.swift:126-128`, `:139-141`, `:152-154`, `:176-177`, 1447 `:199-201`) — CloudKit's shared database, which surfaces every zone *another 1448 user* has shared with the signed-in account. This is exactly the class of 1449 writable-but-not-yours zone this audit has repeatedly identified as the 1450 concrete attack surface: any accepted friend's `friend-<pairKey>` inbox that 1451 the app itself doesn't expose as browsable (Phase 1 finding #1, Phase 3 1452 findings #1/#3/#4), and any shared game's zone. With this view, a user needs 1453 no reverse-engineered client and no custom CloudKit code to carry out those 1454 findings by hand: pick "Shared", "List zones", pick a `friend-<pairKey>` 1455 zone, type "Decision" as the record type, "List records in zone", tap a 1456 `decision-account-pushSecret` or `decision-block-<X>` record (or none — 1457 create one blind by record name), add/edit a String field, "Save changes". 1458 The swipe-to-delete action equally lets any participant with zone 1459 `.readWrite` (which CKShare zone-sharing grants at the zone level, not 1460 per-record) delete a co-player's `Game`/`Player`/`MovesEntity` records 1461 outright, bypassing every app-level ownership check. Fix: compile this view 1462 out of release builds (`#if DEBUG`) rather than gating it behind a 1463 user-flippable preference; if a Production-facing inspection tool is still 1464 wanted for support purposes, make it read-only and restrict it to the 1465 signed-in user's own private-database zones. 1466 1467 **2. (Performance — medium) `AppServices` blocks the main actor with 1468 `ctx.performAndWait` at three call sites — the same recurring pattern flagged 1469 in Phases 1, 2, 4, and 6, now at the composition-root layer that wires those 1470 calls together.** `AppServices.start(appDelegate:)` creates a background 1471 context and calls `nicknameCtx.performAndWait { FriendEntity 1472 .rebuildNicknameDirectory(...); GameEntity.rebuildContentKeyDirectory(...) }` 1473 (`AppServices.swift:678-685`) synchronously on the `@MainActor`-isolated 1474 `start()`, during cold launch; `rebuildContentKeyDirectory` 1475 (`GameEntity+ContentKey.swift:13-25`) fetches every `GameEntity` with a 1476 non-nil `notification` field — effectively every game that has ever minted 1477 push credentials, not just the small friends table the neighboring comment 1478 describes. Separately, `reconcilePendingJournalUploads` — called from every 1479 `runFreshenGameList` (foreground, appear, manual pull-to-refresh, and 1480 remote-push-driven freshens) — does two more `ctx.performAndWait` blocks on a 1481 fresh background context (`AppServices.swift:1268-1271`, a `completedAt != 1482 nil AND journalUploaded == NO` fetch against `GameEntity`, which per Phase 2 1483 finding #4 has no `fetchIndex`, so it's a full-table scan; and `:1288-1295`, 1484 the conditional mark-done save). All three block the calling thread — the 1485 main actor, since `AppServices` is `@MainActor` with no intervening `await` — 1486 despite the enclosing methods being `async`. The inconsistency is visible in 1487 the same file: `logPlayerLeaseSnapshot`, `hasPresentPeer`, `soonestPeerLease`, 1488 and `presentPeers` (`AppServices.swift:2225-2373`) all correctly use 1489 `context.perform` wrapped in `withCheckedContinuation` instead of 1490 `performAndWait` — the non-blocking pattern is already established elsewhere 1491 in this exact file. Fix: convert all three sites to the same 1492 `context.perform`/`withCheckedContinuation` (or `await ctx.perform`) pattern. 1493 1494 **3. (Correctness — low) `GameListView`'s "Leave Puzzle" failure is captured 1495 into a dead `leaveError` state that is never displayed, unlike every sibling 1496 destructive action.** `leaveShare(game:)` catches a thrown error from 1497 `shareController.leaveShare(gameID:)` into `@State private var leaveError: 1498 Error?` (`GameListView.swift:61`, `:777-785`), and grepping the file confirms 1499 those are the *only* two references to `leaveError` — no `.alert` or 1500 `Announcement` ever reads it. Every other destructive action in the same file 1501 (resign, delete, decline) posts an `Announcement` on failure via 1502 `Self.destructiveActionErrorID`, and the equivalent in-puzzle leave flow 1503 (`PuzzleView.swift:62,609` → `PuzzleModifiers.swift:364-374`) wires its own 1504 `leaveError` to a `"Couldn't Leave"` alert — so the pattern is known and used 1505 correctly one screen over. A network blip or CloudKit error during a Game 1506 List "Leave" swipe fails completely silently: the row stays in the list with 1507 no feedback. Fix: post an `Announcement` (or add the missing `.alert`) 1508 mirroring the in-puzzle leave flow. 1509 1510 **4. (Security — low) `NYTLoginView`'s sign-in-redirect detector uses a 1511 substring hostname check instead of an exact/suffix match.** 1512 `NYTWebView.Coordinator.webView(_:decidePolicyFor:)` gates cookie extraction 1513 on `url.host?.contains("nytimes.com") == true` 1514 (`NYTLoginView.swift:71`, confirmed) rather than an exact match or 1515 `hasSuffix(".nytimes.com")` — the standard hostname-validation footgun (it 1516 would also match `nytimes.com.attacker.tld`). Exploitability is narrow: the 1517 `WKWebView`'s only navigation entry point is the hardcoded 1518 `NYTAuthService.loginURL` (an `nytimes.com` URL) with no address bar, so 1519 reaching a look-alike host would require an actual open redirect on NYT's own 1520 login flow, and the extracted cookie store only ever contains cookies this 1521 WebView session itself set. Still a one-line fix worth making since the 1522 substring check offers no real benefit over an exact/suffix match. 1523 1524 **5. (Performance/code quality — low) The "recent changes" read does a 1525 redundant, unbounded diagnostic Core Data fetch on every puzzle-open and 1526 foreground-resume beat.** The injected `loadRecentChanges` closure 1527 (`CrossmateApp.swift:805-817`, driving `PuzzleView`'s `.task` beat, 1528 `PuzzleView.swift:232`) and `recaptureRecentChanges` 1529 (`CrossmateApp.swift:1052-1062`, driving background→foreground resume) each 1530 call both `store.recentlyChangedCells(...)` (bounded, `changedAt > since`, 1531 `GameStore.swift:2209-2224`) and `store.recentChangesDiagnosticSummary(...)` 1532 (`GameStore.swift:2227-2233`) — the latter runs a **second, unbounded** fetch 1533 of every `PeerChangeEntity` row for the game (predicate is `gameID == %@` 1534 only, no time filter) purely to build one diagnostic log line. Both run on 1535 `GameStore.context`, which is `persistence.viewContext` 1536 (`GameStore.swift:508`) — a main-thread context, so this is the ordinary 1537 "synchronous Core Data on the render path" class already accepted elsewhere 1538 (Phase 2 finding #3, Phase 6's `solveTime` non-finding), not a cross-queue 1539 blocking stall; **this resolves the Phase 6 carry-over** on `loadRecentChanges` 1540 as a non-finding on the threading question specifically. The genuinely new 1541 issue is the redundant unbounded diagnostic fetch doubling the ledger read on 1542 every open/resume. Fix: gate the diagnostic-summary fetch behind an actual 1543 diagnostics flag, or derive both the summary and the cell map from a single 1544 fetch. 1545 1546 **6. (Performance — low) `DiagnosticsView` rebuilds and rewrites the entire 1547 event-log dump on every log event while the screen is open.** 1548 `diagnosticDump` (`DiagnosticsView.swift:251-273`) string-joins the whole 1549 `eventLog.entries` buffer (up to 30,000 entries, `DebuggingMonitors.swift:279`) 1550 as a computed property; `.onChange(of: diagnosticDump)` 1551 (`DiagnosticsView.swift:142`) forces SwiftUI to recompute that full O(n) join 1552 on every body evaluation just to diff it, and each change triggers 1553 `refreshDiagnosticShareFile` → `writeDiagnosticShareFile` (`:155-169`), which 1554 rewrites the entire multi-MB dump to `temporaryDirectory` atomically. During 1555 an active co-solve the log churns at ~5 events/sec (documented at 1556 `DebuggingMonitors.swift:271-273`), so simply having the Diagnostics screen 1557 open triggers several full rebuilds + full-file disk rewrites per second. 1558 Bounded to a rarely-open debug screen. Fix: rebuild the share file lazily, 1559 only when the `ShareLink` is actually invoked. 1560 1561 **7. (Code quality — low) `GameListView` wraps its entire body in a 1562 `GeometryReader` solely to read container height.** `GameListView.body` opens 1563 with `GeometryReader { geometry in ... }` (`GameListView.swift:74`) only to 1564 pass `geometry.size` into `usesRoomierType(for:)` 1565 (`size.height >= 760 && dynamicTypeSize <= .medium`, `:787-789`) — this 1566 contradicts the project's documented preference for `containerRelativeFrame`/ 1567 size-class environment values over `GeometryReader`. Functionally harmless 1568 here (single read, no layout feedback loop), but worth swapping for a 1569 vertical-size-class or `containerRelativeFrame` check. 1570 1571 **Non-findings and deferred checks:** 1572 - **Phase 4 carry-over resolved:** `NotificationNavigationBroker` 1573 (`CrossmateApp.swift:309-376`) and its siblings `CloudShareAcceptanceBroker` 1574 (`:379-406`) and `ShareLinkBroker` (`:416-441`) are `@MainActor` singletons 1575 with an identical buffer-until-handler-set pattern to survive the 1576 cold-launch race before `RootView.task` wires up handlers. 1577 `NotificationNavigationBroker` only routes an already-parsed `gameID` (from 1578 `AppDelegate.gameID(from:)`, which validates a UUID string or a 1579 `game-<uuid>` zone-name pattern and returns `nil` on anything malformed) 1580 into in-app navigation — no trust-boundary issue. "`PlayerSessionNavigation`" 1581 turned out not to be a composition-root type at all: it is only the name of 1582 `Tests/Unit/PlayerSessionNavigationTests.swift`, which exercises 1583 `PlayerSession`'s clue-navigation methods (Phase 2/6 territory) — the 1584 original Phase 4 note conflated a test filename with a type. That test 1585 file's `rebusCommitPreservesAllWhitespace` case incidentally locks in, as 1586 expected behavior, the exact whitespace-only-rebus bug Phase 6 finding #2 1587 already flagged (confirmatory, not new). 1588 - **Phase 6 carry-over resolved:** see finding #5 — `loadRecentChanges` is a 1589 synchronous main-thread fetch on `viewContext`, the same accepted class as 1590 Phase 2 finding #3, not the background-context-`performAndWait` anti-pattern 1591 this audit has been flagging; the redundant unbounded diagnostic fetch it 1592 also does is recorded as its own item above. 1593 - `DebuggingMonitors.swift` (`PerformanceMonitor`, `EventLog`, `SyncMonitor`, 1594 `LogScrubber`) is careful and well-tested: `LogScrubber.scrub` (truncates 1595 UUIDs, `_`+32-hex/bare-32-hex tokens, and iCloud share-link tokens) runs on 1596 the full formatted message at `EventLog.append` (`:320-321`), so 1597 `SyncMonitor.recordError`'s `NSError.userInfo` dump (`:415-418`) is scrubbed 1598 too, not just the separately-stored description — verified directly against 1599 `LogScrubberTests`' cases. One honest caveat: base64/base64url secrets (the 1600 account push secret, content/encryption keys) are not hex and would pass 1601 through unscrubbed, but nothing in this phase's file set (or elsewhere, 1602 outside the already-recorded Phase 5 `NYTPuzzleFetcher` cookie finding) logs 1603 those — headroom, not a live leak. 1604 - No force-unwraps (`try!`, `as!`, `.first!`/`.last!`, bare postfix `!`) on 1605 untrusted/CloudKit-sourced or user-controlled data anywhere in scope 1606 (confirmed by grep across the full file list); the only `!` sites are on 1607 hardcoded constants (`TimeZone(identifier: "America/New_York")!`, two 1608 hardcoded `URL(string:)!` links in `AboutView.swift`) or modulo-bounded 1609 weekday-symbol lookups in `NYTBrowseView.swift`. 1610 - The inviter-controlled `invite.gridSilhouette`, decoded via 1611 `GridSilhouette.decode` on the Game List and accept path 1612 (`GameListView.swift:602`, `:696`), is a real CloudKit trust boundary but 1613 was already verified crash-safe and bounded (≤35×35) in Phase 6 non-finding 1614 #4; a `nil` decode here just renders no thumbnail. 1615 - The invite-accept path (`GameListView.accept`, `:693-717`) only forwards the 1616 inviter-controlled `shareURL`/`pingRecordName` to 1617 `InviteCoordinator.acceptInvite` (Phase 3), which owns validation. The 1618 account-control push path (`AppServices.handleAccountControlPush`, 1619 `:1714-1774`) adopts `readAt`/`gameID` only from `accountJoined`/ 1620 `accountSeen` pushes targeting the random, account-private `acct-<UUID>` 1621 address (Phase 4 non-finding: not spoofable without the account zone) and 1622 self-guards on `senderDeviceID == localDeviceID` — sibling-device-scoped, 1623 not a co-player boundary. 1624 - `GameShareSheet.describe` (`GameShareItem.swift:355-361`) copies a full 1625 unscrubbed `nsError.userInfo` dump to the pasteboard on "Copy Error", 1626 bypassing `LogScrubber` unlike the shareable diagnostics dump — owner-facing 1627 and transient, not raised as a numbered finding, but worth aligning with the 1628 scrubber if this ad-hoc error surface is ever meant to be shared further. 1629 - `PerformanceMonitor`, `EventLog`/`EventLogStore` (bounded 30k entries + 24h 1630 retention, debounced off-main persist), `AnnouncementCenter`-driven banners, 1631 `NewGameSheet`/`ImportedBrowseView` (thin dispatchers into Phase 5's 1632 import/XD path), and the remaining `Views/Components/*` are straightforward 1633 with no bugs found. `FriendAvatarView`'s ring-reveal `Task` is properly 1634 cancelled on view changes. `ImportedBrowseView`'s uncapped 1635 `DriveMonitor.readSource`/`importFile` is the already-recorded Phase 4→5 1636 carry-over, not a new finding. 1637 - Nothing new carries forward — Phase 7 is the last phase in the plan, and 1638 every app-shell issue found here terminates in a layer already reviewed 1639 (Phase 2 indexing, Phase 4 push scope, Phase 5 import path, Phase 6 1640 replay/recent-changes). 1641 1642 --- 1643 1644 ## Process 1645 1646 Work through phases in order above (roughly risk/complexity order). For each 1647 phase: 1648 1649 1. Mark it "In progress". 1650 2. Review the listed files (and anything they pull in that isn't listed 1651 elsewhere). 1652 3. Record findings inline in this file under the phase, or in a linked doc if 1653 long. 1654 4. Mark it "Done" and move to the next phase.