commit aec26a1b155a86cf1b268ff9d840ac187006085c
parent 4ea5c9f22df8c81341e0f7d2051ea6dbcba55624
Author: Michael Camilleri <[email protected]>
Date: Fri, 3 Jul 2026 12:47:08 +0900
Add notes about flaws
Diffstat:
| A | Notes/DesignFlaws.md | | | 1654 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
1 file changed, 1654 insertions(+), 0 deletions(-)
diff --git a/Notes/DesignFlaws.md b/Notes/DesignFlaws.md
@@ -0,0 +1,1654 @@
+# Crossmate Audit
+
+A phased audit of the Crossmate codebase. Each phase covers one conceptual
+piece of the app and is reviewed in its own pass, since the whole codebase is
+too large to review in one sitting.
+
+**Focus for every phase:** correctness, security, performance, and code
+quality/style. Findings that don't fit the phase they're discovered in should
+still be recorded under that phase, with a note pointing at the phase they
+actually belong to.
+
+**Out of scope:** `Crossmake/` (separate offline puzzle-authoring SPM
+package, not part of the app build).
+
+**Status legend:** Not started / In progress / Done
+
+---
+
+## Phase 1 — CloudKit Sync Engine
+
+**Status:** Done
+
+The core of the app: raw `CKSyncEngine`, per-game zones, record
+serialization/merging, and conflict handling. Highest complexity, highest
+blast radius if wrong.
+
+- `Crossmate/Sync/SyncEngine.swift`
+- `Crossmate/Sync/CloudZones.swift`
+- `Crossmate/Sync/CloudQuery.swift`
+- `Crossmate/Sync/RecordApplier.swift`
+- `Crossmate/Sync/RecordBuilder.swift`
+- `Crossmate/Sync/RecordSerializer.swift`
+- `Crossmate/Sync/GridStateMerger.swift`
+- `Crossmate/Sync/Moves.swift`
+- `Crossmate/Sync/MovesUpdater.swift`
+- `Crossmate/Sync/PeerChangeLedger.swift`
+- `Crossmate/Sync/PlayerSelectionPublisher.swift`
+- `Crossmate/Sync/RecentChanges.swift`
+- `Crossmate/Sync/SessionMonitor.swift`
+- `Crossmate/Sync/Presence.swift`
+- `Crossmate/Sync/AuthorIdentity.swift`
+- `Crossmate/Sync/Archive.swift`
+- `Crossmate/Sync/GameArchiver.swift`
+- `Crossmate/Sync/CloudDiagnostics.swift`
+- `Crossmate/Sync/SyncState+Helpers.swift`
+- `Crossmate/Services/CloudService.swift` (sync composition root)
+- `Tests/Unit/Sync/*`, `Tests/Unit/RecordSerializer*Tests.swift`,
+ `Tests/Unit/GridStateMergerTests.swift`, `Tests/Unit/MovesUpdaterTests.swift`,
+ `Tests/Unit/PeerChangeLedgerTests.swift`, `Tests/Unit/RecentChangesTests.swift`,
+ `Tests/Unit/SyncMonitorTests.swift`
+
+### Findings (consolidated from Sonnet 5 and Opus 4.8, reviewed 2026-07-01)
+
+Overall this layer is in good shape: the LWW/etag guards, the
+`serverRecordChanged` re-enqueue handling, the burst/drain routing, and the
+zone-orphaning teardown are carefully reasoned and heavily documented. The
+merge/ledger/publisher helpers are clean and well-tested. The items below are
+the exceptions, ordered by practical priority.
+
+**1. (Security — high) Account push address/secret `Decision`s are adopted
+without verifying they came from the private `account` zone.**
+`RecordSerializer.parseAccountPushAddressDecision` /
+`parseAccountPushSecretDecision` (`RecordSerializer.swift:219`, `:245`)
+validate only the record type/name/kind/payload. They do not check the record's
+zone or database scope. `SyncEngine.handleFetchedRecordZoneChanges` calls them
+for every fetched `Decision` record (`SyncEngine.swift:1514`, `:1517`) on both
+the private and shared engines, then dispatches the surfaced values to
+`onAccountPushAddress` / `onAccountPushSecret` (`SyncEngine.swift:1656`,
+`:1666`).
+
+The push secret is the HMAC key used by `deriveGameAddress`, and the comments
+say it should live only in the private account zone. A writable non-account
+zone can therefore carry a record named `decision-account-pushSecret`, type
+`Decision`, `kind=account`, with attacker-chosen payload/version, and this
+device will adopt it. At minimum this can poison push registration and cause a
+push-delivery DoS. The sibling `nickname` decision already gates on
+`record.recordID.zoneID.zoneName == accountZoneID.zoneName && databaseScope == 0`
+(`RecordSerializer.swift:1153`); account push address/secret adoption should
+use the same private-account-zone guard, either in the parsers or at their fetch
+call site. The `serverRecordChanged` conflict path (`SyncEngine.swift:1889`,
+`:1892`) is less exposed because the server record comes from the account-zone
+write being resolved.
+
+**2. (Correctness/data loss — high) `GameArchiver.promoteRevoked` deletes the
+original game even when archive materialization fails.**
+`GameArchiver.promoteRevoked` discards the result of
+`Archive.materialize(payload, in:)` (`GameArchiver.swift:221`) and then
+unconditionally deletes the original `GameEntity` (`:222-227`).
+`Archive.materialize` returns `nil` if `payload.puzzleSource` is empty
+(`Archive.swift:392`). A cloud archive payload can have an empty puzzle source
+if the `CKAsset` read fails, because `Archive.payload(from:)` converts any
+missing/unreadable asset to `""` (`Archive.swift:352-354`).
+
+Failure scenario: a completed shared game is revoked; the archive record fetches
+but the puzzle source asset is unavailable or unreadable; no replacement row is
+created, yet the original row is deleted. Only delete the original after
+materialization succeeds, or fall back to the local snapshot when the cloud
+payload is incomplete.
+
+**3. (Security/performance — medium) `puzzleSource` CKAssets are read without a
+size cap.**
+`RecordSerializer.applyGameRecord` reads the entire `puzzleSource` asset with
+`String(contentsOf: fileURL, encoding: .utf8)` (`RecordSerializer.swift:870-901`).
+Anyone with write access to a shared game zone can cause every syncing device
+to load that asset fully into memory. Crossword sources are inherently small,
+so enforce a conservative byte limit before decoding and reject or ignore
+oversized assets.
+
+**4. (Performance — medium) Sync actors use blocking Core Data work in async
+paths.**
+The sync layer frequently calls `context.performAndWait` from async actor
+methods, including `SyncEngine.handleFetchedRecordZoneChanges`
+(`SyncEngine.swift:1466`), `MovesUpdater.performFlush`
+(`MovesUpdater.swift:158`), and `PlayerSelectionPublisher`
+(`PlayerSelectionPublisher.swift:203`). This may be a deliberate serialization
+tradeoff, but blocking a Swift concurrency worker during Core Data fetch/save
+work can become a scalability problem during bursty sync across many games.
+Consider a systematic pass to move hot paths to `await context.perform { ... }`
+where ordering constraints allow it. Treat this as a performance audit item
+rather than an immediate correctness bug.
+
+**5. (Performance — low/medium) `RecordBuilder.buildRecord` creates a fresh
+background context per outbound record.**
+`RecordBuilder.buildRecord` creates a new background context at
+`RecordBuilder.swift:48` and performs one lookup/build inside it. Because this
+runs once per pending outbound record, draining a large CKSyncEngine batch
+allocates one Core Data context per record. Hoisting context creation to the
+batch caller would reduce overhead and share the row cache across a drain.
+
+**6. (Resource — low) Archive temp files are left for OS cleanup.**
+`Archive.asset(for:ext:)` writes a new file in `FileManager.default.temporaryDirectory`
+for every archive asset (`Archive.swift:300-306`). `GameArchiver.write` can
+re-save archives across retry/reconcile passes, and no path explicitly removes
+these files after CloudKit has consumed them. This is usually bounded by OS temp
+eviction, but a cleanup hook after successful `CKModifyRecordsOperation` would
+avoid accumulating orphaned archive asset files.
+
+**7. (Correctness/resource — low) Private-DB zone deletion does not share the
+full zone-orphan cleanup path.**
+When a private database zone deletion is fetched, `handleFetchedDatabaseChanges`
+deletes the local `GameEntity` (`SyncEngine.swift:1346-1415`) but does not
+remove queued `pendingRecordZoneChanges` or `pendingPings` for that zone.
+`applyZoneOrphaning` does remove both (`SyncEngine.swift:2033-2054`). This
+should self-heal when the queued send later fails with `.zoneNotFound`, but the
+fetch-side deletion path could use the same helper to avoid a futile retry and
+keep transient state consistent.
+
+**8. (Performance — low) `GameArchiver.write` ensures the archive zone before
+every save.**
+`GameArchiver.write` calls `ensureZone(zoneID)` unconditionally
+(`GameArchiver.swift:139-143`). This is idempotent and correct, but can add an
+avoidable CloudKit round trip on repeated archive reconcile passes. Cache known
+archive zones or tolerate `zoneAlreadyExists` after a create-once attempt.
+
+**9. (Correctness/consistency — low) Equal-timestamp move-cell merges use an
+implicit incoming-wins tie-break.**
+`mergeIncomingMovesCells` replaces the local cell whenever
+`existing.updatedAt <= incoming.updatedAt` (`RecordSerializer.swift:1014-1027`).
+`GridStateMerger.shouldReplace` uses a deterministic tie-break on author and
+device after timestamp equality (`GridStateMerger.swift:78-86`). This merge is
+scoped to a single `(author, device)` moves row, so impact is likely low, but
+aligning the tie-breaks would reduce surprise if the helper is reused.
+
+**10. (Code quality — low) `applyPlayerRecord` drops the whole record when
+`name` is absent.**
+`RecordApplier.applyPlayerRecord` returns early if `record["name"]` is missing
+(`RecordApplier.swift:231`), which also drops selection, presence, read cursor,
+and time-log fields. Today `playerRecord` always writes `name`, so this is a
+future partial-fetch sharp edge rather than a live bug.
+
+**11. (Code quality — low) `MovesUpdater.enqueue(actingAuthorID:)` is a dead
+parameter.**
+`GameMutator` passes `actingAuthorID` into `MovesUpdater.enqueue`
+(`GameMutator.swift:371`), but `MovesUpdater.enqueue` ignores the parameter
+(`MovesUpdater.swift:77`). Journaling attribution is handled separately in
+`GameMutator`, so either remove the parameter or wire it to behavior that needs
+it.
+
+**Non-findings and deferred checks:**
+- `serverRecordChanged` re-enqueue handling appears correct: the
+ `recoverServerChangedSave` and `recoveredSaves`/`decisionWins` paths
+ explicitly re-add `.saveRecord` after adopting server system fields.
+- `CloudQuery` desired-key lists are centralized in `RecordSerializer` and
+ cross-checked against written fields; the old duplicated-list footgun is not
+ present here.
+- `GridStateMerger`'s primary merge logic is deterministic and covered by
+ tests.
+- Record-name parsers reject malformed names rather than force-unwrapping
+ untrusted CloudKit record names.
+- `CloudDiagnostics` does not appear to log tokens, emails, or full record
+ dumps.
+- Re-check account push address/secret adoption and republish behavior in
+ Phase 4 alongside `AccountPushCoordinator`.
+- Re-check non-monotonic `PlayerEntity.readThrough` adoption in Phase 2 when
+ durable read cursor semantics are reviewed.
+- Re-check `RecordSerializer.applyDecisionRecord` friend/nickname/block/
+ encryption-key cases, `Presence` trust boundaries, and `ShareController`
+ join confirmation in Phase 3.
+
+---
+
+## Phase 2 — Persistence & Data Model
+
+**Status:** Done
+
+Core Data model, the local durable store, journal/replay, and the domain
+models layered on top.
+
+- `Crossmate/Persistence/PersistenceController.swift`
+- `Crossmate/Persistence/GameStore.swift`
+- `Crossmate/Persistence/GameMutator.swift`
+- `Crossmate/Persistence/Journal.swift`
+- `Crossmate/Persistence/JournalReplay.swift`
+- `Crossmate/Models/CrossmateModel.xcdatamodeld/`
+- `Crossmate/Models/Game.swift`, `GameEntity+ContentKey.swift`
+- `Crossmate/Models/Square.swift`, `CellMark.swift`
+- `Crossmate/Models/PlayerRoster.swift`, `PlayerSession.swift`,
+ `PlayerSelection.swift`, `PlayerColor.swift`, `PlayerPreferences.swift`
+- `Crossmate/Models/ParticipantSummaries.swift`, `GameCursorStore.swift`,
+ `GameViewedStore.swift`, `TimeLog.swift`, `TipStore.swift`
+- `Tests/Unit/GameStore*Tests.swift`, `GameMutatorTests.swift`,
+ `JournalReplayTests.swift`, `JournalUploadTests.swift`,
+ `PersistenceRecoveryTests.swift`, `GameCursorStoreTests.swift`,
+ `GameViewedStoreTests.swift`, `TimeLogTests.swift`, `TipStoreTests.swift`,
+ `PlayerColorTests.swift`, `PlayerRosterTests.swift`
+
+### Findings (consolidated from Sonnet 5 and Opus 4.8, reviewed 2026-07-01)
+
+No security findings this phase — the model layer doesn't cross a trust
+boundary itself (author-ID/zone validation lives in `RecordSerializer`/
+`RecordApplier`, already covered in Phase 1). The main themes are a silent
+failure path in the move journal (bounded in practice by the journal
+context's merge policy), one clock-reconciliation edge case, and a few spots
+where synchronous Core Data work runs on the main actor / render path despite
+an `async` signature promising otherwise. Both passes agreed on the seven
+items below; the severity of #1 is tempered on the second pass, and #4/#6
+carry a note that the fix is a local index change, not a CloudKit schema
+deploy.
+
+**1. (Correctness — medium) `MovesJournal.persist` silently swallows Core Data
+save failures, which can permanently truncate a finished game's uploaded
+replay journal.** `Journal.swift:404-423` calls `ctx.save()` and, on failure,
+only does `print("MovesJournal: failed to persist entry: \(error)")` plus
+`ctx.rollback()` — no `eventLog`, unlike every other Core Data writer in this
+phase (`GameStore.saveContext`, `PersistenceController.backfillCachedSummaryFields`).
+`MovesJournal` isn't even constructed with an `eventLog` reference
+(`GameStore.swift:566`: `MovesJournal(persistence: persistence)`). This
+matters because the completion/replay-upload path deliberately reads the
+*persisted* Core Data rows through a separate background context rather than
+the in-memory `entries` list — see the doc comment on `flush()`
+(`Journal.swift:394-402`): the Phase 2 upload "reconstructs the asset from
+Core Data on a *separate* background context." A dropped `persist` (e.g. a
+non-optional-relationship validation failure if `entity.game` resolves to nil
+because the game row was concurrently deleted, or any other one-off Core Data
+error) removes that touch from the uploaded Journal asset permanently, with
+nothing beyond a console `print` to explain why a replay is missing a move.
+Severity was reconsidered on the second pass: the `mergeByPropertyStoreTrump`
+policy set in `MovesJournal.init` (`Journal.swift:163`) already neutralises
+the one *known* failure (the 133020 merge conflict), and the residual
+save-failure surface is narrow — the `game` relationship is optional, so a
+concurrently-deleted game just nulls it rather than failing validation, and
+`gameID` is always set. So the permanent-truncation scenario is real but
+uncommon; the silent, un-logged swallow (inconsistent with every other writer
+in the layer) is the core problem regardless of how often it fires. Fix:
+thread `eventLog` into `MovesJournal` and log persist failures at `error`
+level, mirroring the rest of the Persistence layer.
+
+**2. (Correctness — medium) `TimeLog.open`'s crash-reconciliation can
+fabricate solve time on a fast relaunch.** `TimeLog.swift:72-84`. When
+`reconcileStale` fires (first open of a game since launch, healing a session
+left dangling by a previous run's crash), the banked interval end is
+`boundedEnd = min(beatAt + openGrace(4min), openStart + maxSessionCap)` —
+never clamped to `now`. If the user relaunches well under 4 minutes after the
+crash, `boundedEnd` lands *after* the actual relaunch instant, so the sealed
+interval extends past `now` and the immediately-following live session
+(`openStart = now`) starts inside that still-"future" interval;
+`TimeLog.unionSeconds` then merges the overlap, crediting real elapsed time
+plus up to ~4 minutes of dead/crashed time that was never played. The
+existing test (`staleSessionReconciledOnReopen`,
+`Tests/Unit/TimeLogTests.swift:172`) only exercises `now=200_000`, far past
+`beatAt+openGrace=340`, so it never hits this boundary. Fix: also clamp
+`boundedEnd` to `now`.
+
+**3. (Performance — medium) `PlayerRoster.refresh()` blocks the main actor on
+a synchronous Core Data fetch instead of yielding it.** `PlayerRoster.swift:256`.
+`refresh()` is an `async @MainActor` method that creates a background context
+and then calls `ctx.performAndWait { ... }` (not `await ctx.perform`).
+`performAndWait` blocks the calling thread — the main thread here — until the
+background context finishes fetching the `GameEntity`, all its
+`PlayerEntity`/`MovesEntity` rows, and every nicknamed `FriendEntity`.
+`refresh()` is triggered by `.playerRosterShouldRefresh`, posted from
+`RecordApplier`/`SyncEngine` on roster-relevant inbound CloudKit changes,
+which can land mid co-solve session — each such event stalls the UI for the
+fetch's duration rather than freeing it, despite the `async` signature. This
+is a sharper, UI-facing instance of Phase 1 finding #4 (blocking Core Data
+work in async paths). Fix: `await ctx.perform { ... }`. Related render-path
+instance (second pass): `PlayerRoster.solveTime` (`PlayerRoster.swift:107-130`)
+fetches the `GameEntity` plus *every* `PlayerEntity` and JSON-decodes each
+`timeLog` synchronously on each call, and it drives the live header clock. It
+runs on `viewContext` (so a main-thread fetch is at least expected there), but
+it's the same "synchronous Core Data on the render path" theme and belongs in
+this cluster.
+
+**4. (Performance — medium) `GameEntity` has zero `fetchIndex` entries**
+despite `id`, `ckRecordName`, `databaseScope`, and `puzzleResourceID`/`title`
+being hit with exact-match `NSPredicate`s constantly —
+`"id == %@"` appears roughly 90 times in `GameStore.swift` alone (`loadGame`,
+`deleteGame`, `markCompleted`, `setEngagement`/`setNotification`,
+`ensurePushCredentials`, etc.), and `"ckRecordName == %@"` lookups against
+`GameEntity` appear across `RecordApplier`, `SyncEngine`, `RecordBuilder`,
+`RecordSerializer`, `MovesUpdater`, and `PlayerSelectionPublisher`. As the
+local library grows, every one of these becomes a full table scan. Add
+`fetchIndex`es on `id` and `ckRecordName` at minimum
+(`CrossmateModel.xcdatamodel/contents:3-44`). Note (second pass): these are
+Core Data `fetchIndex`es (local SQLite), not CloudKit schema — the app uses
+raw `CKSyncEngine`, not `NSPersistentCloudKitContainer` — so adding them (here
+and in #6) needs only a lightweight migration on next launch and *no*
+Production CloudKit schema deploy.
+
+**5. (Performance — low/medium) `MovesJournal` loads a game's entire journal
+synchronously on the main actor the first time it's touched.**
+`Journal.swift:361-364` (`ensureLoaded`) calls `load(_:)`, which runs an
+unbounded `JournalEntity` fetch via `ctx.performAndWait` (`:376-385`) — and
+`ensureLoaded` is called from `record`, `recordedEntries`, `canUndo`,
+`canRedo`, `planUndo`, and `planRedo`. `canUndo`/`canRedo` are read by the
+toolbar essentially as soon as a puzzle screen renders, so opening a
+heavily-edited or long-undo-history game can briefly stall the UI on this
+one-time-per-session fetch. Consider prefetching asynchronously before the
+puzzle screen becomes interactive, or fetching lazily off-main and updating
+state once ready.
+
+**6. (Performance — low) `MovesEntity`/`PlayerEntity` also lack a
+`ckRecordName` index.** Only composite indexes exist
+(`byGameAndAuthorAndDevice`, `byGameAndAuthor`), but plain
+`"ckRecordName == %@"` lookups against these entities happen in
+`GameStore.ensureMovesEntity` (`GameStore.swift:2769`), `RecordApplier`,
+`RecordSerializer`, `MovesUpdater`, and `PlayerSelectionPublisher`/
+`PlayerNamePublisher` — none benefit from the existing composite indexes.
+
+**7. (Code quality — low) `CellEntity` carries four dead attributes.**
+`ckRecordName`, `ckSystemFields`, `databaseScope`, and `lastSyncedAt`
+(`contents:81-90`) are declared on `CellEntity` but never read or written
+anywhere in the codebase (verified against every `CellEntity(context:`
+construction site). Looks like leftover schema from an earlier
+per-cell-record sync design, since replaced by the Moves-record model
+(`CellEntity` is now purely a derived local cache). Safe to drop.
+
+**Non-findings and deferred checks:**
+- `GameEntity`'s `cells`/`journal`/`moves`/`peerChanges`/`players`
+ relationships are all `deletionRule="Cascade"`, matching
+ `GameStore.resetAllData`'s doc-comment assumption — confirmed directly
+ against the schema, not just the comment.
+- `Game.swift`'s completion-cache bookkeeping (filled/wrong counts, gap-cell
+ exclusion, revealed-cell locking) matches `GameMutatorTests` exactly,
+ including author-preservation on a same-letter rewrite.
+- `CellMark`'s code/decode round-trip is lossless and exhaustive; unknown
+ codes safely default to `.none`.
+- `PlayerColor`'s companion/color assignment hashing is deterministic and
+ array-bounds-safe.
+- The manual `Puzzle.Direction` `rawValue`/`init?(rawValue:)` extension in
+ `PlayerSelection.swift:13-30` (across=0, down=1) is consistent with the
+ independent manual encoding `Journal.swift:447` uses for the same field —
+ checked directly since a mismatch here would silently flip undo/redo
+ cursor direction between the Core Data and JSON-upload paths.
+- No force-unwraps on untrusted/CloudKit-sourced data anywhere in this
+ file set.
+- `GameCursorStore.swift`, `GameViewedStore.swift`, `ParticipantSummaries.swift`:
+ straightforward, no bugs found.
+- `TipStore.markDismissed` re-inserts into an already-dismissed set on a
+ repeat dismiss, firing a redundant (harmless) `UserDefaults` write —
+ not worth a fix on its own. (The working-tree change to this file is
+ catalog-only — four new tips — and doesn't touch this path.)
+- The Phase 1 deferred check on non-monotonic `PlayerEntity.readThrough`
+ adoption resolves as a non-finding: `RecordApplier.swift:285-301` adopts it
+ under last-writer-wins *outside* the `updatedAt` guard deliberately, with a
+ bounded, self-healing dip documented in place (a still-present device
+ re-asserts its lease on draining the inbound close). The separate *local*
+ read watermark `GameEntity.readThroughAt` (`GameStore.swift:1766-1792`) is
+ properly monotonic.
+
+---
+
+## Phase 3 — Friends, Sharing & Invites
+
+**Status:** Done
+
+Friend graph, `CKShare`-based collaboration, invite links, and the two
+Workers that back link resolution and the realtime room.
+
+- `Crossmate/Sync/FriendController.swift`, `FriendZone.swift`
+- `Crossmate/Sync/ShareController.swift`
+- `Crossmate/Services/InviteCoordinator.swift`
+- `Crossmate/Services/ShareLinkRoute.swift`, `ShareLinkShortener.swift`
+- `Crossmate/Models/FriendEntity+DisplayName.swift`,
+ `InviteEntity+DisplayName.swift`
+- `Shared/NicknameDirectory.swift`, `FriendEncryptionKeyDirectory.swift`
+- `Crossmate/Services/KeychainHelper.swift`
+- `Crossmate/Views/Friends/*`
+- `Workers/link-worker.js`, `Workers/room-worker.js`
+- `Tests/Unit/Sync/FriendControllerNicknameReplayTests.swift`,
+ `FriendModelTests.swift`, `FriendZoneTests.swift`, `ShareRoutingTests.swift`,
+ `Tests/Unit/NicknameDirectoryTests.swift`, `ShareLinkRouteTests.swift`,
+ `ShareLinkShortenerTests.swift`
+
+### Findings (consolidated from Sonnet 5 and Opus 4.8, reviewed 2026-07-01)
+
+The dominant theme is the same trust-boundary class Phase 1 flagged: several
+inbound-`Decision` cases in `RecordSerializer.applyDecisionRecord` are adopted
+without checking the record's zone or database scope. Phase 3 makes that gap
+*concretely reachable*: a friendship gives the other party `.readWrite` on the
+`friend-<pairKey>` inbox zone **you own** (private DB, `scope == 0`), so any
+non-blocked friend can write arbitrary `Decision`/`Ping` records into a zone
+your private `CKSyncEngine` fetches. The two properly-gated cases (`name`,
+`nickname`) are the exception, not the rule — `block`, `left`, and
+`encryptionKey` all under-check. The `ShareController` owner gating, the two
+Workers' auth, and the link/token validation are all in good shape (see
+non-findings). The carry-over items are addressed as findings #1, #3, #4 (2)
+and non-findings (3).
+
+**1. (Security — high) `block` and `left` `Decision`s are adopted from any
+fetched zone, so a friend can tamper with your relationships and delete your
+local shared games.** `RecordSerializer.applyDecisionRecord`'s `block` case
+(`RecordSerializer.swift:1055-1074`) and `left` case (`:1075-1090`) apply with
+no zone/scope gate at all — unlike the sibling `nickname` case, which requires
+`record.recordID.zoneID.zoneName == accountZoneID.zoneName && databaseScope == 0`
+(`:1153-1155`). Both are legitimately written only into the private `account`
+zone (`FriendController.setBlocked` and `ShareController.leaveShare` both go
+through `SyncEngine.enqueueDecision`, which targets `accountZoneID`,
+`SyncEngine.swift:918-919`), but `applyDecisionRecord` runs for *every* fetched
+`Decision` on both engines (`SyncEngine.swift:1520-1525`). Concrete exploit: Bob
+is an accepted friend, so he holds `.readWrite` on Alice's inbox zone
+`friend-<pairKey(Alice,Bob)>` (`FriendController.saveZoneWideShare`,
+`FriendController.swift:609-623`). Bob saves a record named
+`decision-block-<Carol>` (`kind=block`, `payload="1"`, a large `version`) into
+that zone. Alice's private engine fetches her own inbox, the `block` case
+adopts it, and Alice's `FriendEntity` for Carol is marked `isBlocked` at Bob's
+version — silently blocking a third party (Alice can no longer invite Carol;
+`FriendController.sendInvite` throws `.friendBlocked`, `:366`) and forcing a
+version fight to undo it. The same write with `decision-left-<gameID>` hits the
+`left` case, which hard-deletes Alice's local participant `GameEntity` for any
+shared game she's in (`:1083-1090`) — a self-healing but disruptive
+kick/DoS. (Bob cannot un-block *himself* this way: blocking downgrades him to
+`.readOnly`, so he loses the inbox write access — the server-side enforcement
+holds. The exposure is tampering with the victim's state toward *third
+parties*, and `left`-driven local deletion.) Fix: gate both cases on the
+private `account` zone + `scope == 0`, exactly as `nickname` does.
+
+**2. (Security — medium) `applyFriendPing` accepts and starts syncing a share
+whose zone name is never checked against the bootstrap payload's `pairKey`.**
+`FriendController.applyFriendPing` validates the *acceptor* against the payload
+(`FriendZone.canAcceptBootstrap` requires
+`pairKey(localAuthorID, payload.ownerAuthorID) == payload.pairKey`,
+`FriendZone.swift:92-96`), but after fetching the share metadata it trusts the
+accepted zone wholesale: `outboxZoneID = metadata.share.recordID.zoneID`
+(`FriendController.swift:196`) with no check that
+`outboxZoneID.zoneName == FriendZone.zoneName(pairKey: payload.pairKey)`. A
+co-player (bootstrap Pings ride a shared *game* zone, so the attacker must
+already share a game) can mint a `CKShare` on a zone he owns but *names*
+`friend-<pairKey(Alice,Carol)>`, then send a `.friend` Ping whose payload sets
+`ownerAuthorID = Bob` and `pairKey = pairKey(Alice,Bob)` (so `canAcceptBootstrap`
+passes). Alice accepts and now syncs, at `scope == 1`, a zone Bob controls whose
+*name* impersonates the Alice–Carol pair. This is the enabler for finding #3,
+and independently means Bob-controlled `Decision`/`Ping` records flow into
+Alice's fetch under a forged pair identity. Fix: after fetching metadata, reject
+the accept unless `outboxZoneID.zoneName == FriendZone.zoneName(pairKey: payload.pairKey)`.
+
+**3. (Security — medium) `encryptionKey` `Decision` adoption omits the
+scope-0 gate its sibling `name` case has, allowing a third party's
+friend-channel key to be poisoned.** The `encryptionKey` case
+(`RecordSerializer.swift:1172-1187`) verifies the zone *name* matches
+`friend-<pairKey(localAuthorID, key)>` but — unlike the `name` case, which also
+requires `databaseScope == 0` (`:1116`) precisely to reject a record seen in a
+zone we merely joined — applies at any scope. Combined with finding #2, Bob (a
+mutual co-player who therefore knows Carol's authorID) shares a zone named
+`friend-<pairKey(Alice,Carol)>`, gets Alice to accept it (scope 1), and writes
+`decision-encryptionKey-<Carol>` carrying an attacker-chosen
+`FriendEncryptionKeyPayload`. Alice's fetch adopts it via
+`FriendEncryptionKeyDirectory.upsert(payload, for: Carol)`. That payload holds
+the `address` and `contentKey` used when Alice later sends Carol an invite push
+(`AccountPushCoordinator.swift:473`) and when the NSE decrypts one
+(`NotificationService.swift:62`), so a poisoned entry misroutes Alice's invite
+push for Carol to an attacker-chosen address and/or breaks decryption — an
+invite-push disclosure/DoS scoped to that pair. Fix: add `databaseScope == 0`
+to the `encryptionKey` gate (fixing #2 also closes the practical path).
+
+**4. (Security — high, fix owned by Phase 4) The friend inbox is the concrete
+writable zone that makes Phase 1 finding #1 (account push address/secret
+adoption) exploitable.** Phase 1 #1 noted that
+`parseAccountPushAddressDecision` / `parseAccountPushSecretDecision`
+(`RecordSerializer.swift:219`, `:245`) validate only record
+type/name/kind/payload and are called for every fetched `Decision`
+(`SyncEngine.swift:1514-1518`) but speculated about "a writable non-account
+zone." Phase 3 confirms one exists and is trivially reachable: any accepted
+friend holds `.readWrite` on your `friend-<pairKey>` inbox in the **private**
+database (`scope == 0`). A friend can therefore save a record named
+`decision-account-pushSecret` (`kind=account`, attacker payload, high `version`)
+into that inbox; your private engine fetches it and surfaces it to
+`onAccountPushSecret`. Because the push secret is the HMAC key from which every
+per-game push address is derived (`RecordSerializer.deriveGameAddress`,
+`:263-273`), adopting a foreign secret lets an attacker DoS push delivery
+across all of the victim's games and, if the attacker knows the secret they
+planted, predict/target the victim's per-game push addresses. This raises the
+practical severity of Phase 1 #1. `applyDecisionRecord`'s own `account`-kind
+handling isn't the issue (there is none); the gap is these two dedicated
+parsers plus their unconditional call site. Phase 4 should gate both parsers
+(or their fetch call site) on the private `account` zone + `scope == 0` when it
+reviews `AccountPushCoordinator`. Recorded here because Phase 3 surfaces the
+attack vector; the adoption/republish code is Phase 4.
+
+**5. (Security/resource — low) An `.invite` Ping's `puzzleSource` is trusted
+and materialised into a playable game before any shared-zone validation, with
+no size cap.** `InviteCoordinator.applyInvitePings` stores
+`payload.puzzleSource` from the friend-zone Ping onto the durable `InviteEntity`
+(`InviteCoordinator.swift:217`), and `acceptInvite` feeds it to
+`cloudService.acceptShare(prefetchedPuzzleSource:)` to build a local game before
+the shared-zone fetch (`:307-313`). The inviter controls this string, and the
+friend-zone `Ping.payload` is an unindexed record field bounded only by
+CloudKit's ~1 MB record limit — the comment (`FriendZone.swift:128-134`)
+assumes "a few KB." A malicious inviter can ship a ~1 MB blob to bloat the
+invitee's `InviteEntity` and force an oversized XD parse on accept. This is the
+friend-invite analogue of Phase 1 finding #3 (`puzzleSource` CKAsset without a
+size cap); the XD-parse hardening itself is Phase 5. Fix: enforce a
+conservative byte cap on `payload.puzzleSource` before storing/using it, and
+treat oversize as "fetch from the shared zone instead."
+
+**6. (Security/resource — low) `room-worker.js` allows unauthenticated room
+creation and has no rate limiting.** `register` (`room-worker.js:81-109`)
+creates a Durable Object and persists a secret for *any* well-formed body
+(`isAcceptableSecret` only requires ≥32 decoded bytes, `:249-258`); the room ID
+is an arbitrary path segment (`roomRouteFromPath`, `:242-245`) fed straight to
+`idFromName`. Anyone can therefore mint unbounded rooms (storage/cost) with no
+auth and no per-IP throttle on `register`/`socket`. Rooms self-expire on idle
+TTL (`alarm` → `deleteAll`, `:191-196`), so the blast radius is bounded in time,
+but a burst is uncapped. The TOFU secret model itself is sound — first-write
+wins, `timingSafeEqual` on re-register, and the secret never travels on a
+connect (`:93-104`, `:125-128`). Fix: consider a lightweight rate limit / proof
+requirement on `register`, or accept the risk explicitly given the TTL bound.
+(In-room `authorID`/`deviceID` are self-asserted query params signed by the
+*shared* room secret, so any room member can connect as any authorID and the
+relay does not bind frame authorship — an engagement/presence spoofing surface
+that belongs to Phase 4's engagement review.)
+
+**7. (Code quality — low) `FriendController.seedOwnNameDecision`'s `scope`
+parameter is effectively fixed.** It is only ever called with `scope: 1`
+(`FriendController.swift:206`), and `establish` doesn't seed a name at all
+(deferred to the acceptor's `applyFriendPing`). Not a bug, but the parameter
+implies a flexibility that doesn't exist; inline it or document the single
+caller.
+
+**8. (Security — medium) A `.decline` Ping is trusted to evict a share
+participant without checking that the decliner was ever invited to the named
+game.** `InviteCoordinator.applyDeclinePing` (`InviteCoordinator.swift:700-
+725`) acts on any inbound `.decline` Ping addressed to the local user and
+calls `ShareController.removeFriendParticipant(fromGameID: ping.gameID,
+userRecordName: ping.authorID)` (`ShareController.swift:211-254`). Both
+`ping.gameID` and `ping.authorID` are plain CKRecord fields
+(`Ping.parseRecord`, `Presence.swift:77-108`) on a record written into the
+*recipient's own inbox* zone — i.e. self-reported by whichever friend wrote
+it, with no check that this particular friend has any connection to the
+named game at all. `removeFriendParticipant` only checks that the local user
+owns the named game (`databaseScope == 0`) and that the named
+`userRecordName` currently holds a seat on *that* game's share; it never
+confirms a matching outstanding invite exists for `(gameID, authorID)`, or
+that the Ping's sender is the one who received that invite. Concretely: any
+established friend — not necessarily a collaborator on the target game — who
+can name a live `(gameID, authorID)` pair for one of the owner's *other*
+shared games (e.g. a former collaborator on that other game, or anyone who
+otherwise learned the pair) can forge a `.decline` Ping into the owner's
+inbox and evict that unrelated participant from a game the forger was never
+part of. The practical bar is knowing a live `(gameID, authorID)` pair, but
+nothing ties the claimed decliner identity to the actual CloudKit writer, and
+every accepted friend has permanent inbox write access (the same durable
+property finding #1 relies on) to attempt it at will. Fix: before freeing the
+seat, cross-check against a durable record of invites this owner actually
+sent for that `(gameID, authorID)` pair rather than trusting the Ping's
+self-reported fields.
+
+**9. (Code quality — low) `ShareController` hardcodes the zone-wide share
+record name instead of reusing the existing constant.**
+`ShareController.zoneWideShareRecordName = "cloudkit.zoneshare"`
+(`ShareController.swift:10`) duplicates the value of the system constant
+`CKRecordNameZoneWideShare`, which `FriendController.swift:521,632` and
+`PlayerRoster.swift:525` already use directly for the identical purpose.
+Harmless today (the literal is correct), but needless duplication that could
+silently drift. Replace with `CKRecordNameZoneWideShare`.
+
+**Non-findings and deferred checks:**
+- **Carry-over #2 resolved:** `nickname`'s account-zone+scope-0 gate
+ (`RecordSerializer.swift:1153-1155`) is *not* the lone exception — `name`
+ carries an equivalent pair-zone-name + `scope == 0` gate (`:1106-1116`), and
+ both are correct (the `name` gate even documents rejecting a scope-1 sighting
+ of the friend's own inbox). The exceptions that *under*-check are `block`,
+ `left` (finding #1) and `encryptionKey` (finding #3).
+- **Carry-over #3 (`ShareController` join confirmation) resolved as a
+ non-finding:** `confirmSeatAfterJoin` / `hasLostSeat`
+ (`ShareController.swift:684-732`) is explicitly *cooperative* — CloudKit can't
+ enforce a participant cap, so an over-cap joiner leaves voluntarily and a
+ transient failure lets the join stand. It reads its own identity from
+ `container.userRecordID()` and the server share, adds no trust boundary, and a
+ non-cooperating client is the documented, accepted residual. Ticket-seat
+ consumption uses correct optimistic-lock retries (`:852-897`).
+- `ShareController`'s owner-only gating is consistent and correct:
+ `prepareShareRecord`, `createShareLink`, `addFriendParticipant`,
+ `removeFriendParticipant`, and `existingShareLink` all guard
+ `entity.databaseScope == 0`; `leaveShare`/`confirmSeatAfterJoin` guard
+ `== 1`. `removeParticipant` refuses to drop the owner (`role != .owner`).
+- The `sessionInvitedAuthorIDs` re-assert on every invite save correctly
+ guards the eventually-consistent-share window without ever *removing* a
+ participant a fetched share carries (`ShareController.swift:44-51`,
+ `:171-195`).
+- `room-worker.js` connect auth is solid: HMAC-SHA256 over
+ `roomID|authorID|deviceID|timestamp|nonce`, ±120 s skew window, single-use
+ nonces with TTL pruning, and `timingSafeEqual` on the signature
+ (`:111-155`). No secret is logged or returned.
+- `link-worker.js` is a safe stateless redirector: the token is constrained to
+ RFC 3986 unreserved chars (`TOKEN_PATTERN`, `:45`) so the target can only ever
+ be `https://www.icloud.com/share/<token>` (no open redirect), title decode is
+ charset-checked → strict-UTF-8 → control-stripped → capped → HTML-escaped at
+ the interpolation site (`:279-301`), and every other interpolated value
+ (`token`, `origin`, `shape`) is charset-validated upstream. `escapeHTML` omits
+ `'`, but every interpolation site is a double-quoted attribute or element text
+ where `'` is inert. Silhouette rendering is bounded to ≤35×35
+ (`parseShape`, `:232`). No ReDoS in the crawler/token patterns.
+- `ShareLinkRoute` / `ShareLinkShortener` token validation mirrors the worker's
+ and rejects percent-encoded slash smuggling (`ShareLinkShortenerTests` line
+ 156-165); `iCloudShareURL`'s force-unwrap is safe behind the charset check.
+- Invite tokens are not a Crossmate secret to guess: joining is gated by the
+ underlying `CKShare` acceptance (Apple-enforced), not by link
+ unguessability; the short link carries only the iCloud share token, which is
+ as strong as CloudKit makes it.
+- `KeychainHelper` uses `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`
+ (no iCloud keychain sync, sensible for device-local secrets), update-then-add
+ upsert, and surfaces non-`errSecItemNotFound` failures as thrown errors.
+- `NicknameDirectory` / `FriendEncryptionKeyDirectory` are App-Group JSON
+ mirrors rebuilt from Core Data ground truth; empty-save removes the file, and
+ the `@TaskLocal` test override keeps parallel suites isolated. (The *source*
+ of a `FriendEncryptionKeyDirectory` entry is the trust gap in finding #3, not
+ the directory storage itself.)
+- `FriendZone.pairKey` is a symmetric SHA-256 of the sorted author pair
+ (64-hex, deterministic, self-excluding), giving both devices the same zone
+ name without coordination; covered by `FriendZoneTests`.
+- Re-check the `encryptionKey`/push-address/push-secret *adoption* side and the
+ in-room `authorID` self-assertion in Phase 4 alongside
+ `AccountPushCoordinator` and the engagement layer (findings #4, #6).
+
+---
+
+## Phase 4 — Push Notifications & Engagement
+
+**Status:** Done
+
+Push credential registration, badge/session pushes, the realtime engagement
+(co-solving) layer, the notification service extension, and the push Worker.
+
+- `Crossmate/Services/AccountPushCoordinator.swift`, `PushClient.swift`,
+ `PushRequestAuthenticator.swift`, `BadgeCoordinator.swift`
+- `Crossmate/Sync/GamePushCredentials.swift`
+- `Crossmate/Services/SessionPushPlanner.swift`, `SessionCoordinator.swift`
+- `Crossmate/Services/EngagementHost.swift`,
+ `EngagementHostEnvironment.swift`, `EngagementLifecycle.swift`
+- `Crossmate/Sync/EngagementCoordinator.swift`
+- `Crossmate/Services/AnnouncementCenter.swift`,
+ `PlayerNamePublisher.swift`, `DriveMonitor.swift`, `InputMonitor.swift`
+- `Shared/PushPayload.swift`, `PushPayloadCipher.swift`,
+ `NotificationState.swift`, `PuzzleNotificationText.swift`
+- `Crossmate/Models/PuzzleNotificationText+GameEntity.swift`
+- `NotificationService/NotificationService.swift`
+- `Workers/push-worker.js`
+- `Tests/Unit/AccountPushCoordinatorTests.swift`,
+ `AnnouncementCenterTests.swift`, `Tests/Unit/Sync/GamePushCredentialsTests.swift`,
+ `Tests/Unit/Sync/EngagementCoordinatorTests.swift`,
+ `SessionPushPlannerTests.swift`, `PlayerNamePublisherTests.swift`,
+ `NotificationStateTests.swift`, `PuzzleNotificationTextTests.swift`,
+ `PushPayloadTests.swift`, `PushPayloadCipherTests.swift`,
+ `NotificationNavigationBrokerTests.swift`, `OpenPuzzleBannerTests.swift`,
+ `PlayerSessionNavigationTests.swift`
+
+### Findings (consolidated from Sonnet 5 and Opus 4.8, reviewed 2026-07-01)
+
+Both carry-over items from Phase 1/3 resolve *in* this phase, and both are
+concrete, high-severity gaps rather than theoretical ones: the account
+push-secret adoption path (carried since Phase 1) has no defense-in-depth
+anywhere downstream of the ungated parsers, and the engagement relay's
+self-asserted identity (carried since Phase 3) turns out to durably corrupt
+shared grid content, not just spoof presence. Elsewhere the push/crypto core —
+App Attest enrolment, HMAC request signing, AES-GCM payload sealing — is
+sound and both passes independently verified it closely. The recurring
+"blocking Core Data on an async main-actor path" theme from Phases 1/2
+reappears at three new call sites, found independently by both passes.
+
+**1. (Security — high) Account push address/secret `Decision`s are adopted
+with no account-zone gate anywhere in the pipeline, and a friend can hijack
+the HMAC push secret with an equal-version write.** This is the confirmed
+resolution of Phase 1 finding #1 / Phase 3 finding #4; the fix is owned here.
+`RecordSerializer.parseAccountPushAddressDecision` / `parseAccountPushSecretDecision`
+(`RecordSerializer.swift:219-227`, `:245-255`) validate only record
+type/name/kind/payload — never zone or database scope.
+`SyncEngine.handleFetchedRecordZoneChanges`'s `Decision` case
+(`SyncEngine.swift:1513-1519`) calls both parsers for every fetched Decision
+on both engines; `scope` is already in scope two lines later (passed into
+`applyDecisionRecord(..., databaseScope: scope)`) but is never checked before
+appending to `effects.accountPushAddresses`/`accountPushSecrets`. The
+`serverRecordChanged` conflict twin (`SyncEngine.swift:1888-1894`) has the
+same gap, lower-exposure since it only fires on this device's own
+account-zone save attempts.
+
+Tracing the adoption side confirms there is no second line of defense.
+`AccountPushCoordinator.adoptInboundPushAddress` (`AccountPushCoordinator.swift:126-130`)
+unconditionally caches whatever address it's handed and reconciles.
+`adoptInboundPushSecret` (`:140-149`) gates only on
+`version > local || (version == local && secret != current)` — and because a
+`Decision` with no `version` field maps to `decisionBaseVersion` (1), the
+*same* value a fresh local mint uses (`RecordSerializer.swift:235`,
+`AccountPushCoordinator.swift:350`), an attacker doesn't even need a high
+version: a `decision-account-pushSecret` record with no version field and a
+differing payload wins outright under the equal-version "server wins" branch.
+Adoption caches the foreign secret and calls `reconcilePushRegistration`,
+which re-derives every per-game push address via `deriveGameAddress`
+(`RecordSerializer.swift:263`), rewrites local Player rows, and — per
+`updatePushRegistration(republishPlayerRows: true)`
+(`AccountPushCoordinator.swift:168-190`) — re-publishes the poisoned
+derivation into every shared game's synced `Player.pushAddress` field via
+`syncEngine.enqueuePlayer(gameID:authorID:reason:"pushAddress")`
+(`:178-184`), visible to every co-player in each affected game, not just the
+attacker. Combined with Phase 3's confirmed reachable vector (any accepted
+friend holds `.readWrite` on your private-DB `friend-<pairKey>` inbox, which
+your private engine fetches), this DoSes push delivery across the victim's
+entire push surface and, since the attacker chose the secret, lets them
+predict/target the victim's derived push address for any game.
+
+Fix: gate both call sites (`SyncEngine.swift:1513-1519` and `:1888-1894`) on
+`record.recordID.zoneID.zoneName == accountZoneID.zoneName && databaseScope == 0`,
+mirroring the `nickname`/`name` gates already present in
+`applyDecisionRecord` (`RecordSerializer.swift:1106-1116`, `:1153-1155`). The
+call site is the right place — the parsers are also used on the local-save
+reconciliation path where scope/zoneID aren't otherwise threaded through and
+the gate would be redundant. Separately, tighten the equal-version branch so
+a foreign inbox copy with a missing/matching version can never win.
+
+**2. (Security — high) The realtime engagement channel trusts each frame's
+self-asserted `authorID`/`deviceID`/`cellAuthorID`/`updatedAt`, letting a
+room member durably corrupt and misattribute shared grid content — the
+concrete client-side consequence of Phase 3 finding #6's spoofable room
+identity.** `room-worker.js`'s `webSocketMessage` (`room-worker.js:157-170`)
+is a blind byte relay: `peer.send(data)` forwards the raw frame to every
+other connected socket with zero inspection or identity binding — so a
+frame's `authorID`/`cellAuthorID`/`deviceID` fields are entirely
+self-chosen, not even tied to the already-spoofable connect-time query-string
+`authorID` Phase 3 flagged. `EngagementLifecycle.handleEngagementMessage`'s
+`.cellEdit`/`.cellEditBatch` cases (`EngagementLifecycle.swift:346-376`) hand
+the decoded edit straight to `store.applyRealtimeCellEdit(s)` with no
+roster/identity check. `GameStore.applyRealtimeCellEdits`
+(`GameStore.swift:884-953`) only rejects an empty `authorID`/`deviceID` or a
+`deviceID` equal to the *local* device (`:897-902`) — unlike
+`EngagementStore.set` (`EngagementStore.swift:48-65`), which at least
+requires a cursor's `authorID` to resolve to a known roster player color
+before accepting it. The edit's `letter`/`mark`/`updatedAt`/`cellAuthorID`
+are written straight into the same local `MovesEntity` cache that legitimate
+CloudKit-fetched Moves records for that identity populate, using the
+attacker-supplied `updatedAt` unclamped against the local clock.
+
+The escalation: when the real CloudKit Moves record for the impersonated
+`(authorID, deviceID)` eventually syncs, `RecordSerializer.applyMovesRecord`
+merges cell-by-cell against the existing cache via `mergeIncomingMovesCells`
+(`RecordSerializer.swift:1014-1027`, the same tie-break flagged in Phase 1
+finding #9), keeping whichever `updatedAt` is newer. A frame stamped with a
+far-future `updatedAt` therefore wins that per-cell LWW race permanently —
+the genuine author's real, later edits to that exact cell can never overwrite
+it again through incremental sync, on any device that was live-connected to
+the room when the frame arrived. Concrete exploit: Bob, an accepted
+collaborator (so he legitimately holds the room secret), sends a
+`cellEditBatch` claiming `authorID`/`cellAuthorID` = Carol (a real co-player)
+with `updatedAt` far in the future; every live peer applies it, and Carol's
+genuine future keystrokes at that cell never win again on those devices.
+Mitigating factors: presence (`hasPeer`) is derived from `Player.readAt`
+leases in Core Data, not the room (`EngagementLifecycle.swift:116-121`), so
+the spoof can't fake presence; no push is emitted from an inbound frame; and
+the corruption never re-uploads to CloudKit (outbound Moves are always built
+from the local user's own identity), so blast radius is bounded to devices
+live-connected at attack time — but for those devices it is durable, not
+transient.
+
+Fix: have `room-worker.js` stamp the connection's own HMAC-verified
+`authorID`/`deviceID` onto (or strip from) relayed frames rather than passing
+client-chosen identity through untouched; on the client, validate
+`edit.authorID` against the known roster before writing into the durable
+cache (mirroring `EngagementStore.set`'s gate); and/or clamp
+`edit.updatedAt` to `≤ now + small skew` so a spoof can't win LWW durably —
+ideally combined with keeping live-channel content in a short-lived overlay
+that any subsequent real CloudKit fetch unconditionally supersedes, rather
+than merging it into the persisted cache CloudKit's own merge also reads
+from.
+
+**3. (Security/Availability — medium) `push-worker.js` has no rate limiting
+at either the authenticated `/publish` endpoint or the unauthenticated App
+Attest bootstrap endpoints.** Every client-side send throttle
+(`SessionCoordinator.nudgeCooldown` = 60s,
+`AccountPushCoordinator.accountSeenCoalesceWindow` = 30s, etc.) is enforced
+only in `PushClient`/`SessionCoordinator` — nothing in `handlePublish`
+(`push-worker.js:414-497`) limits how often a given `credID`/address may be
+published to. A malicious collaborator already has everything needed to
+bypass the app entirely: `GamePushCredentials` (secret + `credID`) is synced
+into the Game record's `notification` field for every participant
+(`RecordSerializer.swift:413`, `:858-868`), and every other participant's
+derived `pushAddress` is a plain field on the synced `Player` record
+(`RecordSerializer.swift:560-563`, `:624-631`) — both readable directly off
+CloudKit by any collaborator. A co-player can script raw, correctly-signed
+`/publish` calls at unlimited frequency, sending real alert pushes (sound +
+banner) to every co-player with none of the app's UI-layer cooldowns
+applying — the caller is fully App-Attest-authenticated, so this can't be
+dismissed as requiring attacker infrastructure. Separately, `/attest/challenge`
+and `/attest/register` (`push-worker.js:143-162`, `:164-222`) necessarily run
+*before* auth (they bootstrap the key) and take an attacker-chosen
+`deviceID`; `pruneExpired` only sweeps the same `deviceID` prefix
+(`:153`, `:313-323`), so an attacker rotating `deviceID` accumulates
+`appattest-challenge:*` storage rows faster than they TTL out. Both are the
+same class of gap Phase 3 flagged for `room-worker.js`'s open `register`
+(finding #6); rooms/challenges self-expire so the blast radius is
+time-bounded, but a burst is uncapped. Fix: add a per-`credID`/per-address
+sliding-window rate limit on `/publish`, and a lightweight per-IP/per-device
+throttle on the attest bootstrap endpoints.
+
+**4. (Performance — medium) Three new call sites repeat the recurring Phase
+1/2 "blocking Core Data on an async `@MainActor` path" pattern — found
+independently by both review passes.** Each creates a background context and
+calls `ctx.performAndWait { ... }` synchronously from an `@MainActor` chain
+with no intervening `await`, blocking the main thread for the fetch's
+duration despite the enclosing methods being `async`: `SessionCoordinator.pushPlan`
+(`SessionCoordinator.swift:521-553`), invoked from `nudge`,
+`publishJoinPush`, `publishSessionEndPush`, `publishCompletionPush`, and
+`publishReplayPush` — all reachable from the active puzzle's foreground path,
+and the fetch pulls the game plus all its `PlayerEntity` rows;
+`AccountPushCoordinator.friendEncryptionKeyTargets`
+(`AccountPushCoordinator.swift:232-251`), invoked from every
+`reconcilePushRegistration` and `publishInvitePush`; and
+`PlayerNamePublisher.friendZoneTargets` / `upsertPlayerRecord`
+(`PlayerNamePublisher.swift:167`, `:192`), invoked from `fanOut`/
+`publishName(for:)` — the latter on every shared-puzzle open. Fix: convert to
+`await ctx.perform { ... }` throughout.
+
+**5. (Resource — low) Game push credentials are never expired or cleaned up
+after a game is deleted or archived.** `GamePushCredentials` is deliberately
+durable/no-expiry by design (`GamePushCredentials.swift:25`), unlike
+`EngagementRoomCredentials`'s 10-minute TTL, but no client call anywhere
+unregisters a game credential or its bound addresses from `push-worker.js` —
+`PushClient.unregister(bindings:)` exists but is only driven by
+`setAddresses`'s diff against the current live binding set, never by game
+deletion. This slowly accumulates orphaned `gamecred:<uuid>` and
+`addr:<credID>:*` Durable Object storage entries for every game a user ever
+plays and later deletes/archives.
+
+**6. (Correctness/privacy — low) A failed push `unregister` is silently
+forgotten, leaving a stale address registered with the worker.**
+`PushClient.reconcile` (`PushClient.swift:170-203`) awaits
+`unregister(bindings:)`, which catches and swallows its own errors
+(`:448-467`) rather than throwing, then unconditionally advances
+`lastRegistered = signature` (`:196`). If the DELETE fails (transport blip,
+worker 5xx), the removed bindings drop out of `lastRegistered.bindings`, so
+the next reconcile computes an empty `toRemove` and never retries — the
+stale address→token mapping persists on the worker, so after leaving a game
+or switching accounts the device can keep receiving that game's pushes until
+an APNs 410 or token rotation clears it. Fix: only advance `lastRegistered`
+for bindings that actually unregistered, or re-drive removals until
+confirmed — the register side already models this correctly by leaving
+`lastRegistered` unchanged on failure.
+
+**Non-findings and deferred checks:**
+- `PushPayloadCipher` uses AES-GCM correctly (combined nonce‖ciphertext‖tag
+ box, random nonce, ≥32-byte key enforcement) and fails closed to `nil` on
+ any decode/verify failure — no plaintext leak on error
+ (`PushPayloadCipher.swift:26-52`). `NotificationService.swift:52-98`
+ correctly falls back to the generic cleartext body when `enc` is absent or
+ undecryptable, and never force-unwraps worker-sourced fields.
+- `PushRequestAuthenticator`/`push-worker.js`'s App Attest scheme (challenge/
+ attest/assertion, full CBOR/DER/cert-chain parsing, per-device
+ nonce+timestamp binding, `timingSafeEqual` throughout) is sound and matches
+ Apple's documented scheme; `GamePushSigner`'s HMAC game-participation
+ signature (`credID|bodyHash|timestamp|nonce`, with `bodyHash` covering
+ `credID`) is correctly bound so `credID` can't be swapped without breaking
+ App-Attest auth. Considerably more robust than `room-worker.js`'s
+ HMAC-only connect auth reviewed in Phase 3.
+- `push-worker.js`'s game-credential registration and `/publish` *are* gated
+ behind full App Attest auth (checked before routing,
+ `push-worker.js:19-49`) — a meaningful contrast with `room-worker.js`'s
+ fully open `register` (Phase 3 finding #6); only the missing rate limit
+ (finding #3) carries equivalent risk forward.
+- Account-scoped pushes (the `acct-<UUID>` address) are not spoofable
+ without the account zone: the address is random, not secret-derived, and
+ published only into the private account zone
+ (`push-worker.js:405-413`, `:529-563`).
+- `EngagementHost`'s socket connect auth mirrors the Phase-3-approved
+ room-worker connect auth (HMAC over
+ `roomID|authorID|deviceID|timestamp|nonce`, secret never in the URL);
+ `EngagementCoordinator`'s connect/migrate/teardown state machine and
+ stale-connection sweep are clean.
+- `applyGameRecord`'s adoption of the `engagement`/`notification` fields
+ (`RecordSerializer.swift:848-868`) is correctly scoped — these live on the
+ Game record itself, already resolved to the correct per-game zone/entity,
+ so "whoever can write this game's own zone" is the intended trust boundary
+ (the game's own CKShare participants), unlike the account-zone Decision
+ gap in finding #1.
+- `AnnouncementCenter`, `BadgeCoordinator`/`NotificationState`'s
+ `seenAt`/`suppressedUntil` ledger, `PuzzleNotificationText`(+`GameEntity`),
+ `DriveMonitor`, `InputMonitor` are straightforward and correct; no bugs
+ found. No force-unwraps on untrusted CloudKit- or worker-sourced data
+ anywhere in the Phase 4 file set.
+- `GamePushCredentialsTests` confirms the HMAC key-derivation contract
+ (base64url-decoded secret bytes, not UTF-8) matches the worker's
+ `hmacSHA256` exactly — no drift risk.
+- `fromAuthorID` on a push is worker-forwarded and attacker-settable but is
+ display-only: the NSE only rewrites the body when the sealed payload
+ actually decrypts, so a spoofed `fromAuthorID` on an undecryptable push
+ yields the generic body, not a nickname leak.
+- Carry over to **Phase 5**: `DriveMonitor.readSource`/`importFile` reads a
+ user-selected local/iCloud-Drive `.xd`/`.puz` file with no size cap before
+ `PUZToXDConverter.convert` — not a remote trust boundary like Phase 1 #3 /
+ Phase 3 #5, but worth folding into Phase 5's general XD/PUZ parse hardening
+ review.
+- Carry over to **Phase 6**: finding #2's exploit rides the same LWW
+ tie-break flagged in Phase 1 finding #9; a `now`-clamp or monotonic guard
+ there would also harden the CloudKit ingest path, worth revisiting
+ alongside gameplay/grid state in Phase 6.
+- Carry over to **Phase 7**: `NotificationNavigationBroker`/
+ `PlayerSessionNavigation` live in the app-shell composition root, outside
+ this phase's file list; a shallow look shows they only route an
+ already-parsed `gameID` to in-app navigation with no obvious trust-boundary
+ issue, but they deserve a full pass in Phase 7.
+
+---
+
+## Phase 5 — Puzzle Import & Format Conversion
+
+**Status:** Done
+
+Bringing puzzles into the app: NYT auth/fetch, `.puz`/NYT-JSON → XD
+conversion, and the XD format itself.
+
+- `Crossmate/Services/NYTAuthService.swift`, `NYTPuzzleFetcher.swift`,
+ `NYTPuzzleUpgrader.swift`, `NYTToXDConverter.swift`
+- `Crossmate/Services/PUZToXDConverter.swift`, `ImportService.swift`
+- `Crossmate/Models/XD.swift`, `XDFileType.swift`, `XDMarkup.swift`
+- `Crossmate/Models/PuzzleCatalog.swift`, `PuzzleSource.swift`,
+ `Puzzle.swift`
+- `Puzzles/cm-starter/*`, `Puzzles/debug/*`
+- `Tests/Unit/NYTAuthServiceTests.swift`, `NYTPuzzleUpgraderTests.swift`,
+ `NYTToXDConverterTests.swift`, `PUZToXDConverterTests.swift`,
+ `PuzzleCatalogTests.swift`, `XDAcceptTests.swift`, `XDMarkupTests.swift`
+
+### Findings (consolidated from Sonnet 5 and Opus 4.8, reviewed 2026-07-01)
+
+This is the phase where the Phase 1 finding #3 / Phase 3 finding #5 carry-over
+("`puzzleSource` reaches the parser with no size cap") cashes out concretely —
+and the result is sharper than either earlier phase assumed: the parser itself
+has an unmemoized exponential algorithm that a **sub-300-byte** input can drive
+into a multi-hour hang, so a byte-size cap alone would not have closed the
+gap. Both passes found this independently; Sonnet 5 additionally built a
+standalone harness from the real source and empirically measured it. The
+second major finding — a `.puz` rebus-placeholder allocator that both
+misassigns reserved characters and traps on `UInt8` overflow — was also found
+independently by both passes and empirically confirmed. All file:line
+references below were spot-checked directly against the current source.
+
+**1. (Security/Performance — high, empirically confirmed) `XD.segment` is an
+unmemoized exponential backtracking search, reachable from attacker-controlled
+`puzzleSource` on the CloudKit sync path and, via invite acceptance, on the
+main actor.** `XD.segment` (`XD.swift:705-748`) assigns free-length substrings
+from a clue's `~` answer onto a word's still-unsolved cells via plain
+recursion (`recurse(cellIndex:answerIndex:current:)`, `:706-742`). The
+`maxResults` cap (`results.count < maxResults` at `:709`) only prunes *after*
+a complete valid segmentation is found; when the crafted answer text has
+**zero** valid segmentations against a word's already-crossing-fixed cells,
+the full composition tree is exhausted before returning empty. For a run of
+*E* adjacent unsolved cells constrained only by one trailing fixed-letter
+cell, this is `O(2^E)`-ish combinatorial work. Sonnet 5 reproduced this on
+real device hardware from the extracted source: a 2-column synthetic grid (one
+down-word of unconstrained cells + one crossing-fixed cell) measured 2.13s at
+14 free cells / slack 10 (~1.14M compositions) and **32.2s at 16 free cells /
+slack 12** (~17.4M compositions, from a ~250-byte source file), extrapolating
+to minutes-to-hours at 18-20 free cells — all comfortably under 1 KB.
+
+Reachability has no existing mitigation: `RecordSerializer.applyGameRecord`
+calls `XD.parse(source)` on the synced Game record's `puzzleSource` CKAsset
+with no complexity guard (`RecordSerializer.swift:875`) — the same asset
+Phase 1 #3 flagged for size only. Concretely for invites, the exact Phase 3 #5
+vector completes the chain: `InviteCoordinator.acceptInvite`'s
+inviter-controlled `payload.puzzleSource` flows through
+`CloudService.acceptShare(prefetchedPuzzleSource:)`
+(`CloudService.swift:55`, `:82`) into
+`GameStore.constructJoinedGame(gameID:zoneID:source:)`
+(`GameStore.swift:1202`), which calls `try XD.parse(source)` directly at
+`:1209` — and `GameStore` is `@MainActor` (`GameStore.swift:504`), confirmed
+by direct inspection, so this is a **synchronous main-thread hang**: a friend
+sends one crafted invite and the victim's UI freezes solid the moment they tap
+Accept (likely followed by an iOS watchdog kill). The same call also fires at
+ordinary game-open/local `.xd` import. Recursion depth is bounded by word
+length, so this is pure combinatorial-time DoS, not a stack overflow. Fix:
+memoize `segment` on `(cellIndex, answerIndex)` — this turns it into standard
+`O(word.count × answer.count)` word-break DP, since the failing subproblems
+repeat — and/or cap total recursive-call count and free-cell run length ahead
+of a fail-closed error. This finding supersedes the "just add a size cap"
+framing from Phase 1 #3 / Phase 3 #5: a byte cap remains worth adding for
+memory/parse-cost reasons generally (see #5), but it does not bound this
+specific attack.
+
+**2. (Correctness/Security — high, empirically confirmed) `PUZToXDConverter`'s
+rebus-placeholder allocator is keyed by cell index instead of by distinct
+rebus value, so it both misassigns reserved grammar characters at low counts
+and hard-crashes via `UInt8` overflow past ~207 rebus-covered cells.** The
+loop at `PUZToXDConverter.swift:78-87` walks every rebus-covered cell once
+(`for index in 0..<cellCount where isOpen(...)`) and assigns a fresh key from
+`nextRebusKey`, plain (non-`&+=`) `UInt8` arithmetic starting at
+`Character("1").asciiValue!`: `guard rebusKeys[index] == nil else { continue }`
+never fires because `rebusKeys` is keyed by cell **index** and each index is
+visited exactly once — the guard cannot dedupe by *value*, unlike the correct
+value-keyed `NYTToXDConverter.rebusLookup` (`NYTToXDConverter.swift:150-163`,
+whose header comment at `:22-23` explicitly documents having already hit and
+fixed this exact class of bug: "walking ASCII naïvely from `'1'` overflows
+into `=` by the 13th key, which produced an unparseable `==VALUE` entry"). So
+even a `.puz` with only a handful of *distinct* rebus values spread across
+enough cells burns through placeholders one-per-cell: the 13th assigned key is
+`=`, breaking the `Rebus: key=value` grammar into an unparseable entry; the
+16th is `@`, which silently re-parses as a *circled*-cell marker instead of a
+rebus key. At the 207th rebus-covered cell, `nextRebusKey` reaches 256 and
+`nextRebusKey += 1` **traps**, crashing the app outright. Sonnet 5 confirmed
+the crash empirically against a synthetic 256-cell 16×16 `.puz` with only 3
+distinct rebus values mapped across all cells (exit 133, fatal arithmetic-overflow
+trap), proving the bug is cell-count-keyed, not distinct-value-keyed as a
+size-based mitigation might assume. `PUZToXDConverterTests.swift` has no
+rebus/`GRBS`/`RTBL` coverage at all, which is exactly why this shipped
+unnoticed. Reachable via any locally-imported `.puz` (Files app, AirDrop,
+email) — a sufficiently rebus-heavy variety puzzle could trigger the
+`=`/`@` corruption by accident, no malice required. Fix: dedupe by rebus
+*value* into a `[String: Character]` map exactly mirroring
+`NYTToXDConverter.rebusLookup`, reusing its curated, reserved-character-avoiding
+`rebusPlaceholders` set (`NYTToXDConverter.swift:24-30`) instead of raw ASCII
+walking, and use `&+=` or a bounds check regardless.
+
+**2b. (Correctness — medium; NOT found in this audit, discovered 2026-07-01
+while fixing #2 — TODO H2b) `PUZToXDConverter.parseRebus` reads the `GRBS` grid
+byte directly as the `RTBL` key, but `.puz` stores `GRBS = RTBL_key + 1`.** The
+loop at `PUZToXDConverter.swift:338-343` did `let key = Int(grid[gridIndex]);
+table[key]`, treating the 1-indexed grid byte as a 0-indexed table key. The
+`.puz` format reserves `GRBS` byte 0 for "no rebus" and stores `1 + n` for the
+square whose solution is `RTBL` key `n` — confirmed independently by the puz
+FileFormat wiki ("A value of 1+n indicates a rebus square … key n in the RTBL
+section"), puzpy (`self.table[i] = k + 1`), and other implementations. Effect on
+a *real* Across Lite rebus file: a single-value rebus (`RTBL` key 0, `GRBS` byte
+1) looked up the absent `table[1]` and silently dropped every rebus cell — the
+cell fell back to its raw single-letter solution byte; a multi-value rebus
+mis-mapped each cell to the *next* table entry and dropped the last. Silent
+corruption, never a crash, which is why it went unnoticed (and why the #2
+allocator crash was in practice only reachable from a hand-crafted file, since
+no real rebus `.puz` ever populated the rebus map). Fix applied: `table[key - 1]`
+with the grid byte guarded `> 0`; regression test `singleRebusValueDecodes`.
+
+**3. (Security/Correctness — medium) `NYTToXDConverter.convert` doesn't
+validate that `dimensions.width`/`height` are positive before using them,
+crashing on a malformed NYT response; a sibling cell-index access is
+similarly unguarded.** `convert(jsonData:)` extracts `width`/`height` as plain
+`Int` from JSON (`NYTToXDConverter.swift:71-75`) and only checks
+`cells.count == width * height` (`:81-83`) before looping
+`for row in 0..<height { for col in 0..<width { ... } }` (`:173-175`). A
+response with `width: -1, height: -1` and a single-element `cells` array
+satisfies `(-1)*(-1) == 1 == cells.count` and passes every guard, then traps
+with `Fatal error: Range requires lowerBound <= upperBound` — confirmed by
+running the converter against a crafted minimal body. Separately,
+`let answerStr = cellIndices.compactMap { answers[$0] }` (`:241`) subscripts
+`answers` with `clue["cells"]` indices with no bounds check — an
+out-of-range/negative index is a fatal array access — while the sibling
+`acceptedAnswerVariants` correctly guards the same kind of access with
+`answers.indices.contains` (`:330`). NYT's API is comparatively trusted, but
+it's still externally-supplied structure parsed as untrusted input per this
+phase's brief; a corrupted proxy/cache, API regression, or on-device MITM
+crashes the fetch/upgrade flow. Fix: `guard width > 0, height > 0` alongside
+the existing cell-count check, and guard every `answers[$0]` access the same
+way `acceptedAnswerVariants` already does.
+
+**4. (Security/privacy — medium) `NYTPuzzleFetcher` unconditionally logs a
+partial NYT session cookie and full request/response bodies via raw `print`,
+not gated by `#if DEBUG`.** `fetchPuzzle` prints
+`"Cookie header: NYT-S=\(cookie.prefix(40))..."`
+(`NYTPuzzleFetcher.swift:99`, confirmed) — 40 characters of the live session
+cookie that authenticates every NYT request — plus the request URL, full
+response bodies up to 1000-3000 chars, and the entire converted `.xd` source
+(`:97-133`, confirmed), on every single fetch, in release builds, since `print`
+is not stripped. `NYTAuthService.completeSignIn` logs only cookie *names* and
+*domains*, never values (confirmed) — the more careful pattern already exists
+in this file set and simply wasn't applied here. This is a real departure from
+the discipline Phases 1/3/4 verified for `CloudDiagnostics` and the push/crypto
+layers (never log tokens/secrets even truncated); a 40-char cookie prefix
+reaching Console.app/sysdiagnose/MDM log collection/screen-share is a genuine,
+if not catastrophic (NYT-S is an ordinary web session cookie, not a
+cryptographic key), credential-exposure surface. `fetchPuzzleList`, which
+carries the same logging, also has no callers anywhere in `Crossmate/` or
+`Tests/` — dead debug scaffolding left in the shipped file. Fix: delete all of
+these prints (or route a redacted line through the same event-logging pattern
+used elsewhere), and remove the dead `fetchPuzzleList` method or wire it up.
+
+**5. (Performance — low) No length/complexity cap on clue text compounds two
+independently-quadratic-or-worse render/parse paths.** `XDMarkup.segments`
+(`XDMarkup.swift:61-86`) rescans from `i+2` to end-of-string on every
+unterminated markup open (`{` + valid marker with no matching close) and falls
+through to a single-character advance rather than skipping the failed scan —
+clue text with many repeated unterminated sequences (e.g. `{/{/{/{/...`)
+triggers a full rescan per occurrence. `Puzzle.init`'s `parseCrossReferences`
+(`Puzzle.swift:305-336`) runs a lazy-quantified regex over every clue's text at
+construction time, which — per finding #1's reachability — executes on the
+sync worker for attacker-influenced clue text; not exponential, but unbounded
+polynomial cost purely because clue length is unbounded. Both are lower
+severity than finding #1 (clue prose is normally short, and `XDMarkup` runs on
+render rather than the sync/accept hot path) but reinforce the same theme: add
+a conservative byte/length cap on `puzzleSource` and per-clue text before
+parsing, at every boundary identified in Phase 1 #3 / Phase 3 #5 / the Phase 4
+`DriveMonitor` carry-over (`ImportService.importGame`'s uncapped
+`String(contentsOf:)`/`Data(contentsOf:)` at `ImportService.swift:27,29` is the
+same gap). This bounds memory/parse cost generally even though, per finding
+#1, it does not alone bound the exponential case.
+
+**6. (Consistency — low) Bundled/debug `.xd` sources are stamped `CmVer: 3`
+against a current parser version of 7.** Every file under `Puzzles/cm-starter/*`
+and `Puzzles/debug/*` carries `CmVer: 3` while `XD.currentCmVersion == 7`
+(`XD.swift:7`). All 13 bundled/debug puzzles were parsed end-to-end with the
+real parser (not just cross-checked against manifests) and load correctly
+regardless, and grid/`blockMask` consistency against each manifest was also
+verified directly — this is purely a stale version stamp. Worth confirming
+whether bundled-puzzle staleness/refresh logic (Phase 2) treats a `CmVer`
+behind the parser version as meaningful, or whether it should — otherwise
+just re-stamp the bundled sources to track the authoring tool's actual output.
+
+**7. (Code quality — low) `PUZToXDConverter` indexes `Data` with absolute
+offsets that assume a zero-based buffer.** `data[0x2C]`, `asciiString(in:range:)`,
+and `Array(data[solutionStart..<fillStart])` (`PUZToXDConverter.swift:37-52`)
+use literal/absolute offsets into `Data`. Safe with the current caller
+(`Data(contentsOf:)` is zero-based) but would crash on any `Data` slice with a
+non-zero `startIndex`. Normalize with `data.startIndex + offset`.
+
+**Non-findings and deferred checks:**
+- No force-unwraps in `XD.swift` on untrusted input: all regex captures are
+ `Int`-guarded, grid/word walks stay bounds-checked through `isOpen`/`isBlock`,
+ and `Numbering`/segment array accesses stay within grid bounds. The only
+ algorithmic hazard is `segment` itself (finding #1), not a crash.
+- `XDMarkup` degrades safely (no crash) on an unterminated span or an invalid
+ `{` marker — only the perf cost in finding #5.
+- `XD.Numbering.build` and `Puzzle.init`'s independent clue-numbering
+ computation agree (checked directly; a mismatch would silently misnumber
+ clues).
+- `NYTToXDConverter`'s *own* rebus placeholder set correctly avoids every
+ reserved grid/header character and is tested
+ (`manyRebusFillsAvoidReservedKeys`) — the exact hardening `PUZToXDConverter`
+ lacks (finding #2); its header comment even documents having hit and fixed
+ the identical bug class previously.
+- `NYTAuthService` credential handling is sound: cookie/email go through
+ `KeychainHelper` (`kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`, no
+ iCloud sync, proper upsert — consistent with Phase 3's review of the same
+ helper); `cookieLoadResult`/keychain-load logging records only status and a
+ `hasData` boolean, never cookie content (contrast with finding #4); a locked
+ device correctly maps `errSecInteractionNotAllowed` to
+ `.temporarilyUnavailable` rather than a false sign-out.
+- `NYTAuthService.extractAccountGraphQLConfiguration`'s HTML-scraping regex
+ (`firstJSONStringValue`) is single-pass with mutually-exclusive alternation
+ on first character — no catastrophic backtracking, and it reconstructs a
+ well-formed JSON literal to decode escapes safely rather than hand-parsing
+ them.
+- `NYTPuzzleUpgrader.structuralDivergence` correctly gates source replacement
+ on identical dimensions + block layout + per-cell solution; both
+ `.mismatched`/`.failed` paths preserve the player's existing moves, and
+ `.plan` only fires for owner-side NYT puzzles.
+- `PUZToXDConverter.decodeString`'s CP1252→Latin1→UTF8 fallback chain
+ terminates in a non-optional decode; the non-rebus parsing (dimensions,
+ string table, `GEXT`/circled cells, clue-count reconciliation) validates
+ every offset/length against `data.count` before slicing, and `width`/`height`
+ are single bytes (≤255) so `cellCount` can't overflow.
+- `Puzzle.init`'s loops can't see a mismatched width/height, because `Puzzle`
+ is only ever constructed from an already-successfully-parsed `XD`, whose
+ `width`/`height` are derived from the same grid data its rows were built
+ from.
+- `PuzzleCatalog` manifest loading is defensive (`try?` throughout, skips any
+ bundle directory with an unreadable/undecodable manifest) and reads only
+ app-bundle resources — not a trust boundary, no user input reaches it.
+- `ImportService.importGame` is a thin, straightforward extension dispatcher;
+ its missing size cap is the same Phase 4 `DriveMonitor` carry-over folded
+ into finding #5 above (and, per finding #1, a size cap wouldn't have stopped
+ that specific attack anyway).
+- Nothing new carries forward to Phase 6/7 beyond what's already noted above —
+ the XD/PUZ/NYT parsing surface is self-contained within this file set, and
+ finding #1's exploit path terminates at `GameStore`/`XD.parse` reviewed here,
+ even though its *reachability* runs through Phase 2/3/6 call sites.
+
+---
+
+## Phase 6 — Gameplay & Puzzle UI
+
+**Status:** Done
+
+The actual solving experience: the puzzle screen, custom keyboard, replay,
+and session state.
+
+- `Crossmate/Views/Puzzle/*`
+- `Crossmate/Services/PuzzleSession.swift`, `ReplayLoader.swift`
+- `Crossmate/Models/ReplayControls.swift`, `CheckResult.swift`
+- `Crossmate/Services/GridSilhouette.swift`
+- `Tests/Unit/PuzzleSessionTests.swift`, `ReplayCacheTests.swift`,
+ `ReplayControlsTests.swift`, `GridSilhouetteTests.swift`,
+ `GameSummaryThumbnailTests.swift`, `PendingEditFlagTests.swift`
+
+### Findings (consolidated from Sonnet 5 and Opus 4.8, reviewed 2026-07-01)
+
+This is one of the cleaner phases so far. `GridView`'s single-`Canvas` redesign
+is carefully reasoned about render cost, `PuzzleSession`'s grace-timer/
+background-assertion state machine is cleanly modeled and well-tested, and
+`GridSilhouette`'s codec is defensive with no force-unwraps anywhere in the
+file set. The puzzle UI itself turns out to be a pure, passive consumer of
+already-merged grid state — it does not re-implement or second-guess any
+last-writer-wins tie-break, which resolves the Phase 4 carry-over as a
+non-finding for new gameplay-side logic (see below), though tracing the
+consumers confirms concretely how a Phase 4 finding #2 spoof would surface in
+this layer. The one substantive bug both passes independently zeroed in on
+different corners of is a replay-completeness gap in the app-shell
+composition root that can permanently cache an incomplete scrubber for a
+session; the rest is low-severity robustness/quality, including a recurring
+"redundant full-grid pass" pattern this audit has flagged in earlier phases.
+
+**1. (Correctness — medium) The app-shell's "local-only" replay fast path
+infers a game has no other contributors from an unverified local Core Data
+snapshot, and can permanently cache an empty/incomplete replay for the rest
+of the session.** `CrossmateApp.swift:787-797`:
+```swift
+let entries = store.localJournalEntries(for: gameID)
+let otherDevices = store.contributingDevices(for: gameID)
+ .filter { $0.deviceID != localDeviceID }
+if otherDevices.isEmpty {
+ return .ready(ReplayTimeline(merging: [entries]))
+}
+```
+`contributingDevices(for:)` (`GameStore.swift:2313-2324`) only reads this
+device's already-synced local `MovesEntity` rows — unlike
+`ReplayLoader.loadReplay` (`ReplayLoader.swift:45-120`), which derives
+`expectedDevices` from a live CKQuery and gates on every contributor's journal
+having landed. If this device's Moves catch-up sync for the game hasn't
+finished yet — the common case the first time a device opens an
+already-completed shared game (freshly accepted an invite to a finished
+puzzle, a reinstall, an iCloud restore, or a collaborator returning to a game
+finished while they were away) — `contributingDevices` sees zero remote rows
+even though real collaborators exist, so the shortcut wrongly takes the solo
+branch and builds `ReplayTimeline(merging: [entries])` from this device's own
+(often empty) local journal, bypassing `ReplayAssembler`'s strict-completeness
+gate entirely. That wrong result is cached as `.ready` by
+`ReplayAssembler.memoised` (`JournalReplay.swift:173-189`) into
+`ReplayLoader.replayTimelineCache` (`ReplayLoader.swift:23`); because
+`ReplayControls.load` is a no-op once `.ready` (`ReplayControls.swift:169-174`)
+and the memo short-circuits on every later call for that `gameID`
+(`JournalReplay.swift:180-183`), every subsequent reopen of the puzzle within
+the same app session keeps serving the wrong scrubber even after the real
+Moves records finish syncing in the background — only a full app relaunch
+clears the in-memory cache. Fix: gate the fast path on a hard, durable signal
+that the game was never shared (e.g. `!session.mutator.isShared` /
+`GameEntity.ckShareRecordName == nil`) rather than on the currently-synced
+local Moves cache; for anything shared, always defer to
+`services.replays.loadReplay`, which already handles the true solo-but-shared
+case correctly via its own `expectedDevices` check.
+
+**2. (Correctness — low) A whitespace-only rebus commit writes a phantom
+"filled" cell that silently blocks completion.**
+`PlayerSession.committedRebusValue` (`PlayerSession.swift:488-493`) returns
+the buffer unchanged whenever it contains no non-whitespace character —
+`guard buffer.rangeOfCharacter(from: .whitespacesAndNewlines.inverted) != nil
+else { return buffer }` — so a whitespace-only buffer commits as a
+non-empty, visually-blank cell instead of clearing it. The reachable path is
+the rebus modal's system `TextField` (`PuzzleView.swift:623-637`), which,
+unlike the custom keyboard, permits a space; the blank-looking cell then reads
+as filled to `PuzzleScoreboard.filledCellCount` and `Game.completionState`, so
+a fully-filled grid resolves to `filledWithErrors` with no visibly wrong cell
+for the player to find. This lives in `PlayerSession.swift`, a Phase 2 file,
+but is recorded here since the rebus-modal gameplay path is what surfaces it.
+Fix: treat a whitespace-only committed value as empty, mirroring the
+empty-buffer case.
+
+**3. (Code quality/robustness — low) Three render-path dictionaries built with
+the trapping `Dictionary(uniqueKeysWithValues:)` sit alongside a fourth that
+deliberately defends against duplicate keys.** `GridView.body`'s
+`authorTintByID` (`GridView.swift:42-46`), `PuzzleScoreboard.scores`
+(`PuzzleScoreboard.swift:124`), and `SuccessPanel.contributions`
+(`SuccessPanel.swift:178`) all build a `[String: T]` from
+`roster.entries`/`entries` with `Dictionary(uniqueKeysWithValues:)`, which
+traps on a duplicate `authorID`. `RecentChangeBorders.resolve`, in the same
+`GridView.swift` file, already hedges the equivalent construction with
+`Dictionary(..., uniquingKeysWith: { first, _ in first })`
+(`GridView.swift:687-690`), showing the author has considered duplicates
+possible. Today this is safe by construction —
+`ParticipantSummaries.remoteParticipants` de-duplicates via a `Set` before the
+local entry is prepended (`ParticipantSummaries.swift:59-66`,
+`PlayerRoster.swift:393`) — but the three trapping sites should adopt the same
+`uniquingKeysWith` form for defense-in-depth, since a future change to roster
+assembly would otherwise turn a benign data glitch into a hard crash while a
+puzzle is on screen.
+
+**4. (Code quality — low) `GridSilhouette.decode` accepts over-length /
+trailing-garbage payloads instead of requiring an exact bit count.** `decode`
+checks only `bits.count == storedCount` after `unpackBits` truncates to
+`storedCount` (`GridSilhouette.swift:106-108`); it never rejects a payload
+carrying *more* base64url bytes than the dimensions require, so two distinct
+segments can decode to the same grid and a segment with appended junk still
+decodes successfully. Bounded (≤35×35) and low-impact (only paints a
+link-tap placeholder), but a strict `bytes.count == (storedCount + 7) / 8`
+check would make the codec total and match the encoder's output exactly.
+
+**5. (Performance/code quality — low) The live scoreboard does repeated
+full-grid passes on every keystroke, and duplicates the same
+author-normalization/scoring logic across two files.**
+`PuzzleScoreboard.swift`'s `fillableCellCount` (`:39-43`), `filledCellCount`
+(`:45-56`), `revealedSquareCount` (`:58-69`), and `scores` (`:111-175`) each
+independently loop over every cell — four full-grid passes recomputed on each
+render, one of which (`fillableCellCount`) doesn't depend on mutable state at
+all — and `SuccessPanel.swift`'s `revealedSquareCount` (`:27-40`) and
+`contributions` (`:161-232`) repeat the pattern; `SuccessPanel`'s
+`normalizedAuthorID` (`:252-257`) is a near-verbatim duplicate of
+`PuzzleScoreboard.normalizedAuthorID` (`:177-182`), with the roster-fallback/
+extra-contributor bucketing logic duplicated almost line-for-line between the
+two files. Puzzles are bounded to 35×35, so this is genuinely negligible in
+practice (no Core Data involved, unlike the recurring blocking-fetch theme
+from Phases 1/2/4) — recorded for the redundant-work/duplication pattern, not
+as a performance emergency. Worth consolidating into one shared per-grid-stats
+helper reused by both views.
+
+**Non-findings and deferred checks:**
+- **Phase 4 finding #2 / Phase 1 finding #9 carry-over resolved:** grepped
+ every `updatedAt`/`cellAuthorID`/`letterAuthorID` use across
+ `Views/Puzzle/*` and confirmed the gameplay UI adds no independent
+ clamping, trust check, or tie-break logic of its own — it is a passive
+ renderer of `session.game.squares[r][c]` and replayed `ReplayFrame.cells`.
+ The only `updatedAt` comparison in this file set
+ (`GridView.swift:540`, `RemoteCursorTints.remoteTrackTints`) is cosmetic
+ cursor-track tint precedence, not cell content. `Square.enqueuedAt`,
+ applied in `GameStore.restore` (`GameStore.swift:2467-2520`, Phase 2,
+ covered by this phase's `PendingEditFlagTests.swift`), protects a
+ locally-buffered keystroke from being clobbered by an inbound merge, but
+ that is a separate, correctly-functioning mechanism from the Phase 4 #2
+ spoof path. Tracing the consumers does confirm the concrete UI-visible
+ blast radius of that already-recorded issue, though: a poisoned
+ `cellAuthorID` from a spoofed realtime frame would misattribute the
+ author tint in `GridView` (`GridView.swift:78`, `:91`) and miscount
+ per-player credit in `PuzzleScoreboard.scores` /
+ `SuccessPanel.contributions` (`PuzzleScoreboard.swift:118`,
+ `SuccessPanel.swift:143`, `:173`) — useful color for Phase 4's
+ already-owned fix, not a new gameplay-side bug.
+- `PuzzleSession.swift`'s grace-timer/background-assertion state machine
+ (`scheduleEndPush`/`cancelPendingEndPush`/`fireEndPush`) is correctly
+ serialized with no interleaving `await` between guard and mutation, and its
+ `UIBackgroundTaskIdentifier`/idle bookkeeping matches `PuzzleSessionTests`'s
+ interleaving cases (including expedited-expiration and supersede paths)
+ exactly.
+- Replay data is a trust boundary but is display-only and crash-safe: a
+ malicious co-player's uploaded journal can misrender/misattribute a
+ *finished* game's replay, but never writes back to the live `Game` or to
+ CloudKit, and every consumer of a replayed cursor position is bounds-checked
+ (`Puzzle.swift:366-431`, `GridView.swift:74`, `SuccessPanel.swift:240`,
+ `ReplayControls`'s scrub/playback state machine).
+- `ReplayLoader`'s own CloudKit paths are non-blocking
+ (`store.cachedRemoteJournals`/`cacheRemoteJournals` correctly use
+ `await ctx.perform`, `GameStore.swift:1459-1525`). Its one synchronous
+ main-actor read, `store.localReplaySource` → `movesJournal.recordedEntries`
+ (`ReplayLoader.swift:57`), is **Phase 2 finding #5** reaffirmed from a new
+ call site (fires once on the finish-banner path), not a new finding.
+- `PlayerRoster.refresh`'s `ctx.performAndWait` (`PlayerRoster.swift:256`) is
+ also reached from this phase's UI (colour change in
+ `PuzzleToolbarModifier.swift:218`, `PuzzleLifecycleModifier`) — **Phase 2
+ finding #3** reaffirmed from a new call site, not a new finding.
+- No force-unwraps (`try!`, `as!`, `.first!`/`.last!`, bare postfix `!`)
+ anywhere in `Views/Puzzle/*`, `PuzzleSession.swift`, `ReplayLoader.swift`,
+ `ReplayControls.swift`, `CheckResult.swift`, or `GridSilhouette.swift`.
+ `HardwareKeyboardInputView`'s HID-usage mapping safely rejects any
+ unrecognized `UIKeyCommand.input` via `guard`/`default: return nil`
+ (`HardwareKeyboardInputView.swift:120-171`), and custom `Layout`
+ implementations (`KeyboardRow`, `PuzzleGridLayerLayout`/
+ `PuzzleGridGeometry`) guard degenerate (zero-size/zero-count) input.
+- `PuzzleCommands`/`PuzzleActionTarget` equality is deliberately keyed on
+ `session === && isEnabled` to stop a UIKit menu-builder reentrancy deadlock
+ (`PuzzleCommands.swift:26-28`) — correct as documented, not a staleness bug.
+- `CheckResult` is a trivial two-case enum with no correctness surface;
+ cross-reference hatching (`CellPatterns`/`CrossRefLines`) is pure geometry
+ with `max(2, …)` step floors, no divide-by-zero.
+- `GameSummaryThumbnailTests.swift`, `ReplayCacheTests.swift`, and
+ `PendingEditFlagTests.swift` exercise Phase 2 source (`GameStore.swift`'s
+ `GameSummary`, `cacheRemoteJournals`/`cachedRemoteJournals`, and
+ `restore`'s pending-edit flag) rather than any Phase 6 file directly; each
+ was checked against the corresponding Phase 2 source and found accurate.
+- Carry over to **Phase 7**: `PuzzleView`'s `.task` beat sets
+ `session.recentChanges = loadRecentChanges()` after a 750ms hold
+ (`PuzzleView.swift:229-233`); `loadRecentChanges` is injected from
+ `AppServices` (Phase 7) — worth confirming it isn't a synchronous
+ main-thread Core Data fetch when Phase 7 reviews the composition root,
+ alongside the existing Phase 4 carry-over note on
+ `NotificationNavigationBroker`/`PlayerSessionNavigation`.
+
+---
+
+## Phase 7 — App Shell & General UI
+
+**Status:** Done
+
+Everything else: app-wide composition/DI, general browsing/list/settings
+UI, and cross-cutting diagnostics.
+
+- `Crossmate/Services/AppServices.swift`, `DebuggingMonitors.swift`
+- `Crossmate/Views/Browse/*`, `Views/GameList/*`, `Views/Settings/*`,
+ `Views/Components/*`
+- `Crossmate/CrossmateApp.swift` (app-shell composition root; not listed
+ under any earlier phase, reviewed here for the first time)
+- `Tests/Unit/LogScrubberTests.swift`
+
+### Findings (consolidated from Sonnet 5 and Opus 4.8, reviewed 2026-07-01)
+
+Both passes independently confirmed the two outstanding carry-overs
+(`NotificationNavigationBroker`/"`PlayerSessionNavigation`" from Phase 4,
+`loadRecentChanges` from Phase 6) as non-findings, and both independently
+found the recurring "blocking Core Data on an async `@MainActor` path"
+pattern at new composition-root call sites in `AppServices.swift`. The two
+passes disagreed sharply on one item — Sonnet 5 flagged `RecordEditorView`
+as a high-severity finding, Opus 4.8 assessed the same view as a non-finding
+("a power-user CloudKit-Console substitute, not a cross-trust-boundary
+surface"). This was resolved by direct verification of the source below:
+Sonnet 5's read is correct and Opus 4.8's is not — the view's `scope: .shared`
+option targets `container.sharedCloudDatabase`, i.e. zones *other users*
+have shared with the signed-in user, so it is a cross-trust-boundary surface
+by design, not merely an owner-facing tool.
+
+**1. (Security — high, verified) A raw CloudKit record browser/editor/deleter
+is reachable by any user via a client-side toggle with no other gate, turning
+every trust-boundary gap this audit has flagged in Phases 1/3/4 into a
+one-tap, no-code exploit.** `RecordEditorView.swift` lets the user pick
+`scope: .private` or `.shared`, list every zone in that scope
+(`listZonesForEdit`, `CloudDiagnostics.swift:151-162`), list every record of a
+chosen type in a zone (`queryRecordsForEdit`, `:169-190`), fetch/edit/save any
+record by name (`fetchRecordForEdit`/`saveRecordForEdit`, `:122-145`), and
+delete any record via swipe (`deleteRecordForEdit`, `:195-204`) — all backed
+by nothing but the CloudKit API itself, confirmed directly against the
+source. The only gate is `SettingsView.swift:9`'s
+`@AppStorage("debugMode") private var debugMode = false`, flipped by tapping
+the About footer text four times (`SettingsView.swift:127-133`,
+`easterEggTaps >= 4 { debugMode.toggle() }`) — a runtime UserDefaults flag any
+user can flip, not a `#if DEBUG` compile gate, developer entitlement, or
+server-side check. Once toggled, "Record Editor" appears as an ordinary
+`NavigationLink` in Settings → Debugging (`SettingsView.swift:94-96`).
+
+Critically, `scope: .shared` routes to `container.sharedCloudDatabase`
+(`CloudDiagnostics.swift:126-128`, `:139-141`, `:152-154`, `:176-177`,
+`:199-201`) — CloudKit's shared database, which surfaces every zone *another
+user* has shared with the signed-in account. This is exactly the class of
+writable-but-not-yours zone this audit has repeatedly identified as the
+concrete attack surface: any accepted friend's `friend-<pairKey>` inbox that
+the app itself doesn't expose as browsable (Phase 1 finding #1, Phase 3
+findings #1/#3/#4), and any shared game's zone. With this view, a user needs
+no reverse-engineered client and no custom CloudKit code to carry out those
+findings by hand: pick "Shared", "List zones", pick a `friend-<pairKey>`
+zone, type "Decision" as the record type, "List records in zone", tap a
+`decision-account-pushSecret` or `decision-block-<X>` record (or none —
+create one blind by record name), add/edit a String field, "Save changes".
+The swipe-to-delete action equally lets any participant with zone
+`.readWrite` (which CKShare zone-sharing grants at the zone level, not
+per-record) delete a co-player's `Game`/`Player`/`MovesEntity` records
+outright, bypassing every app-level ownership check. Fix: compile this view
+out of release builds (`#if DEBUG`) rather than gating it behind a
+user-flippable preference; if a Production-facing inspection tool is still
+wanted for support purposes, make it read-only and restrict it to the
+signed-in user's own private-database zones.
+
+**2. (Performance — medium) `AppServices` blocks the main actor with
+`ctx.performAndWait` at three call sites — the same recurring pattern flagged
+in Phases 1, 2, 4, and 6, now at the composition-root layer that wires those
+calls together.** `AppServices.start(appDelegate:)` creates a background
+context and calls `nicknameCtx.performAndWait { FriendEntity
+.rebuildNicknameDirectory(...); GameEntity.rebuildContentKeyDirectory(...) }`
+(`AppServices.swift:678-685`) synchronously on the `@MainActor`-isolated
+`start()`, during cold launch; `rebuildContentKeyDirectory`
+(`GameEntity+ContentKey.swift:13-25`) fetches every `GameEntity` with a
+non-nil `notification` field — effectively every game that has ever minted
+push credentials, not just the small friends table the neighboring comment
+describes. Separately, `reconcilePendingJournalUploads` — called from every
+`runFreshenGameList` (foreground, appear, manual pull-to-refresh, and
+remote-push-driven freshens) — does two more `ctx.performAndWait` blocks on a
+fresh background context (`AppServices.swift:1268-1271`, a `completedAt !=
+nil AND journalUploaded == NO` fetch against `GameEntity`, which per Phase 2
+finding #4 has no `fetchIndex`, so it's a full-table scan; and `:1288-1295`,
+the conditional mark-done save). All three block the calling thread — the
+main actor, since `AppServices` is `@MainActor` with no intervening `await` —
+despite the enclosing methods being `async`. The inconsistency is visible in
+the same file: `logPlayerLeaseSnapshot`, `hasPresentPeer`, `soonestPeerLease`,
+and `presentPeers` (`AppServices.swift:2225-2373`) all correctly use
+`context.perform` wrapped in `withCheckedContinuation` instead of
+`performAndWait` — the non-blocking pattern is already established elsewhere
+in this exact file. Fix: convert all three sites to the same
+`context.perform`/`withCheckedContinuation` (or `await ctx.perform`) pattern.
+
+**3. (Correctness — low) `GameListView`'s "Leave Puzzle" failure is captured
+into a dead `leaveError` state that is never displayed, unlike every sibling
+destructive action.** `leaveShare(game:)` catches a thrown error from
+`shareController.leaveShare(gameID:)` into `@State private var leaveError:
+Error?` (`GameListView.swift:61`, `:777-785`), and grepping the file confirms
+those are the *only* two references to `leaveError` — no `.alert` or
+`Announcement` ever reads it. Every other destructive action in the same file
+(resign, delete, decline) posts an `Announcement` on failure via
+`Self.destructiveActionErrorID`, and the equivalent in-puzzle leave flow
+(`PuzzleView.swift:62,609` → `PuzzleModifiers.swift:364-374`) wires its own
+`leaveError` to a `"Couldn't Leave"` alert — so the pattern is known and used
+correctly one screen over. A network blip or CloudKit error during a Game
+List "Leave" swipe fails completely silently: the row stays in the list with
+no feedback. Fix: post an `Announcement` (or add the missing `.alert`)
+mirroring the in-puzzle leave flow.
+
+**4. (Security — low) `NYTLoginView`'s sign-in-redirect detector uses a
+substring hostname check instead of an exact/suffix match.**
+`NYTWebView.Coordinator.webView(_:decidePolicyFor:)` gates cookie extraction
+on `url.host?.contains("nytimes.com") == true`
+(`NYTLoginView.swift:71`, confirmed) rather than an exact match or
+`hasSuffix(".nytimes.com")` — the standard hostname-validation footgun (it
+would also match `nytimes.com.attacker.tld`). Exploitability is narrow: the
+`WKWebView`'s only navigation entry point is the hardcoded
+`NYTAuthService.loginURL` (an `nytimes.com` URL) with no address bar, so
+reaching a look-alike host would require an actual open redirect on NYT's own
+login flow, and the extracted cookie store only ever contains cookies this
+WebView session itself set. Still a one-line fix worth making since the
+substring check offers no real benefit over an exact/suffix match.
+
+**5. (Performance/code quality — low) The "recent changes" read does a
+redundant, unbounded diagnostic Core Data fetch on every puzzle-open and
+foreground-resume beat.** The injected `loadRecentChanges` closure
+(`CrossmateApp.swift:805-817`, driving `PuzzleView`'s `.task` beat,
+`PuzzleView.swift:232`) and `recaptureRecentChanges`
+(`CrossmateApp.swift:1052-1062`, driving background→foreground resume) each
+call both `store.recentlyChangedCells(...)` (bounded, `changedAt > since`,
+`GameStore.swift:2209-2224`) and `store.recentChangesDiagnosticSummary(...)`
+(`GameStore.swift:2227-2233`) — the latter runs a **second, unbounded** fetch
+of every `PeerChangeEntity` row for the game (predicate is `gameID == %@`
+only, no time filter) purely to build one diagnostic log line. Both run on
+`GameStore.context`, which is `persistence.viewContext`
+(`GameStore.swift:508`) — a main-thread context, so this is the ordinary
+"synchronous Core Data on the render path" class already accepted elsewhere
+(Phase 2 finding #3, Phase 6's `solveTime` non-finding), not a cross-queue
+blocking stall; **this resolves the Phase 6 carry-over** on `loadRecentChanges`
+as a non-finding on the threading question specifically. The genuinely new
+issue is the redundant unbounded diagnostic fetch doubling the ledger read on
+every open/resume. Fix: gate the diagnostic-summary fetch behind an actual
+diagnostics flag, or derive both the summary and the cell map from a single
+fetch.
+
+**6. (Performance — low) `DiagnosticsView` rebuilds and rewrites the entire
+event-log dump on every log event while the screen is open.**
+`diagnosticDump` (`DiagnosticsView.swift:251-273`) string-joins the whole
+`eventLog.entries` buffer (up to 30,000 entries, `DebuggingMonitors.swift:279`)
+as a computed property; `.onChange(of: diagnosticDump)`
+(`DiagnosticsView.swift:142`) forces SwiftUI to recompute that full O(n) join
+on every body evaluation just to diff it, and each change triggers
+`refreshDiagnosticShareFile` → `writeDiagnosticShareFile` (`:155-169`), which
+rewrites the entire multi-MB dump to `temporaryDirectory` atomically. During
+an active co-solve the log churns at ~5 events/sec (documented at
+`DebuggingMonitors.swift:271-273`), so simply having the Diagnostics screen
+open triggers several full rebuilds + full-file disk rewrites per second.
+Bounded to a rarely-open debug screen. Fix: rebuild the share file lazily,
+only when the `ShareLink` is actually invoked.
+
+**7. (Code quality — low) `GameListView` wraps its entire body in a
+`GeometryReader` solely to read container height.** `GameListView.body` opens
+with `GeometryReader { geometry in ... }` (`GameListView.swift:74`) only to
+pass `geometry.size` into `usesRoomierType(for:)`
+(`size.height >= 760 && dynamicTypeSize <= .medium`, `:787-789`) — this
+contradicts the project's documented preference for `containerRelativeFrame`/
+size-class environment values over `GeometryReader`. Functionally harmless
+here (single read, no layout feedback loop), but worth swapping for a
+vertical-size-class or `containerRelativeFrame` check.
+
+**Non-findings and deferred checks:**
+- **Phase 4 carry-over resolved:** `NotificationNavigationBroker`
+ (`CrossmateApp.swift:309-376`) and its siblings `CloudShareAcceptanceBroker`
+ (`:379-406`) and `ShareLinkBroker` (`:416-441`) are `@MainActor` singletons
+ with an identical buffer-until-handler-set pattern to survive the
+ cold-launch race before `RootView.task` wires up handlers.
+ `NotificationNavigationBroker` only routes an already-parsed `gameID` (from
+ `AppDelegate.gameID(from:)`, which validates a UUID string or a
+ `game-<uuid>` zone-name pattern and returns `nil` on anything malformed)
+ into in-app navigation — no trust-boundary issue. "`PlayerSessionNavigation`"
+ turned out not to be a composition-root type at all: it is only the name of
+ `Tests/Unit/PlayerSessionNavigationTests.swift`, which exercises
+ `PlayerSession`'s clue-navigation methods (Phase 2/6 territory) — the
+ original Phase 4 note conflated a test filename with a type. That test
+ file's `rebusCommitPreservesAllWhitespace` case incidentally locks in, as
+ expected behavior, the exact whitespace-only-rebus bug Phase 6 finding #2
+ already flagged (confirmatory, not new).
+- **Phase 6 carry-over resolved:** see finding #5 — `loadRecentChanges` is a
+ synchronous main-thread fetch on `viewContext`, the same accepted class as
+ Phase 2 finding #3, not the background-context-`performAndWait` anti-pattern
+ this audit has been flagging; the redundant unbounded diagnostic fetch it
+ also does is recorded as its own item above.
+- `DebuggingMonitors.swift` (`PerformanceMonitor`, `EventLog`, `SyncMonitor`,
+ `LogScrubber`) is careful and well-tested: `LogScrubber.scrub` (truncates
+ UUIDs, `_`+32-hex/bare-32-hex tokens, and iCloud share-link tokens) runs on
+ the full formatted message at `EventLog.append` (`:320-321`), so
+ `SyncMonitor.recordError`'s `NSError.userInfo` dump (`:415-418`) is scrubbed
+ too, not just the separately-stored description — verified directly against
+ `LogScrubberTests`' cases. One honest caveat: base64/base64url secrets (the
+ account push secret, content/encryption keys) are not hex and would pass
+ through unscrubbed, but nothing in this phase's file set (or elsewhere,
+ outside the already-recorded Phase 5 `NYTPuzzleFetcher` cookie finding) logs
+ those — headroom, not a live leak.
+- No force-unwraps (`try!`, `as!`, `.first!`/`.last!`, bare postfix `!`) on
+ untrusted/CloudKit-sourced or user-controlled data anywhere in scope
+ (confirmed by grep across the full file list); the only `!` sites are on
+ hardcoded constants (`TimeZone(identifier: "America/New_York")!`, two
+ hardcoded `URL(string:)!` links in `AboutView.swift`) or modulo-bounded
+ weekday-symbol lookups in `NYTBrowseView.swift`.
+- The inviter-controlled `invite.gridSilhouette`, decoded via
+ `GridSilhouette.decode` on the Game List and accept path
+ (`GameListView.swift:602`, `:696`), is a real CloudKit trust boundary but
+ was already verified crash-safe and bounded (≤35×35) in Phase 6 non-finding
+ #4; a `nil` decode here just renders no thumbnail.
+- The invite-accept path (`GameListView.accept`, `:693-717`) only forwards the
+ inviter-controlled `shareURL`/`pingRecordName` to
+ `InviteCoordinator.acceptInvite` (Phase 3), which owns validation. The
+ account-control push path (`AppServices.handleAccountControlPush`,
+ `:1714-1774`) adopts `readAt`/`gameID` only from `accountJoined`/
+ `accountSeen` pushes targeting the random, account-private `acct-<UUID>`
+ address (Phase 4 non-finding: not spoofable without the account zone) and
+ self-guards on `senderDeviceID == localDeviceID` — sibling-device-scoped,
+ not a co-player boundary.
+- `GameShareSheet.describe` (`GameShareItem.swift:355-361`) copies a full
+ unscrubbed `nsError.userInfo` dump to the pasteboard on "Copy Error",
+ bypassing `LogScrubber` unlike the shareable diagnostics dump — owner-facing
+ and transient, not raised as a numbered finding, but worth aligning with the
+ scrubber if this ad-hoc error surface is ever meant to be shared further.
+- `PerformanceMonitor`, `EventLog`/`EventLogStore` (bounded 30k entries + 24h
+ retention, debounced off-main persist), `AnnouncementCenter`-driven banners,
+ `NewGameSheet`/`ImportedBrowseView` (thin dispatchers into Phase 5's
+ import/XD path), and the remaining `Views/Components/*` are straightforward
+ with no bugs found. `FriendAvatarView`'s ring-reveal `Task` is properly
+ cancelled on view changes. `ImportedBrowseView`'s uncapped
+ `DriveMonitor.readSource`/`importFile` is the already-recorded Phase 4→5
+ carry-over, not a new finding.
+- Nothing new carries forward — Phase 7 is the last phase in the plan, and
+ every app-shell issue found here terminates in a layer already reviewed
+ (Phase 2 indexing, Phase 4 push scope, Phase 5 import path, Phase 6
+ replay/recent-changes).
+
+---
+
+## Process
+
+Work through phases in order above (roughly risk/complexity order). For each
+phase:
+
+1. Mark it "In progress".
+2. Review the listed files (and anything they pull in that isn't listed
+ elsewhere).
+3. Record findings inline in this file under the phase, or in a linked doc if
+ long.
+4. Mark it "Done" and move to the next phase.