crossmate

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

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.