RegressionAudit.md (64788B)
1 # Crossmate Regression Audit 2 3 ## Scope and method 4 5 This is a phased regression audit of Crossmate, excluding `Crossmake/`. It 6 reviews implementation and corresponding tests together, with particular 7 attention to data crossing CloudKit, Core Data, and process boundaries. The 8 historical reviews in `Notes/DesignFlaws.md`, `Notes/ServerSecurity.md`, 9 `Notes/ViewStructure.md`, and `DEFERRED.md`, plus changes since 2026-07-01, 10 are review context rather than findings in this report. 11 12 `DEFERRED.md`, and the `TODO.md` that tracked remediation, were working 13 documents kept outside this repository; they are named below to record what 14 each phase read, not as links to committed files. Deferred items are cited by 15 their D-numbers so the reasoning stands on its own here. 16 17 ## Post-audit remediation status 18 19 Updated 2026-07-19 from the post-audit remediation work. The phase sections 20 below preserve what the audit found at review time; statements there that a 21 finding is outstanding are historical. Every finding reported below has since 22 been implemented and verified. 23 24 The completed remediation binds CloudKit and realtime input to the correct 25 game and author, makes push membership credentials revocable, validates remote 26 coordinates, and bounds puzzle, replay/archive, realtime, and push ingress. It 27 also gates mutations after revocation, routes VoiceOver reveal through the 28 standard confirmation, preserves the caller's keychain configuration during a 29 release attempt, and buffers early APNs callbacks and push wakes across 30 serialized app startup. What remains is broader validation and infrastructure 31 follow-up rather than an unresolved numbered finding from this audit: worker 32 runtime coverage for the App Attest, registration, publish, and room-socket 33 paths; a pinned worker toolchain and CI; real-device validation against App 34 Attest, APNs, and CloudKit; and integration/UI coverage for the lifecycle, 35 notification-extension, accessibility, and multi-device scenarios. Each is 36 described in the residual-risk sections of the phases that found it. 37 38 ## Review protocol and execution record 39 40 This audit was a review rather than a refactor: it did not change production 41 code. `Crossmake/`, the standalone puzzle-authoring Swift package, was out of 42 scope. Existing findings in the historical review notes and `DEFERRED.md` were 43 treated as context and re-checked for regression, not repeated automatically 44 as findings. 45 46 Each phase reviewed implementation and its corresponding tests together. The 47 review traced data across CloudKit records, Core Data, notifications, worker 48 HTTP and WebSocket requests, app-group storage, and imported puzzle files. A 49 completed phase records its reviewed scope, checked invariants, findings and 50 non-findings, checks run, residual test gaps, and handoff risks below. 51 Findings remain in the phase where they were discovered and are 52 cross-referenced when a later phase owns follow-up. 53 54 Before each phase, the review read the completed portions of this report, the 55 relevant historical notes and `DEFERRED.md`, the changes since 2026-07-01 56 outside `Crossmake/`, and the phase's applicable source files and tests. Each 57 phase mapped relevant recent changes first, then traced implementation and 58 tests through their external boundaries. Targeted checks ran during the 59 applicable phase where practical; the full unit and worker suites ran again in 60 the final operational-readiness phase. 61 62 The phases ran in this order: 63 64 1. Cloud state, persistence, and migration. 65 2. Collaboration, sharing, and realtime engagement. 66 3. Push and notification pipeline. 67 4. Puzzle content boundary. 68 5. Application lifecycle and workflow integration. 69 6. Gameplay, accessibility, and UI state. 70 7. Operational readiness and test gaps. 71 72 The handoff at the end of each phase was the durable source of truth for the 73 next phase. Where a discovery changed a later phase's risk or scope, it was 74 carried forward through the cross-phase follow-up list rather than assumed 75 away. 76 77 ## Cross-phase follow-up at audit completion 78 79 - **H1:** Bind every inbound game-scoped CloudKit record to its per-game zone 80 and authenticate its claimed author. This is discovered in Phase 1 and must 81 be re-verified in Phase 2's membership/authorization review. 82 - **H2:** Validate remote grid and journal coordinates before conversion to 83 Core Data's `Int16` fields. 84 - **M1:** Bound Journal and Archive asset sizes and decoded entry counts 85 before loading them into memory. 86 - **H3:** Bind authenticated realtime frames to the game associated with their 87 active engagement channel before applying them to local Moves state. 88 - **M2:** Bound realtime WebSocket frame sizes and decoded batch sizes before 89 parsing, authenticating, and applying them. 90 - **H4:** Require proof of a current game credential when registering a game 91 push address, and rotate that credential when a participant loses access. 92 - **M3:** Bound worker request bodies, address lists, opaque payloads, and 93 per-credential fanout before JSON parsing, Durable Object scans, or APNs 94 sends. 95 - **M8:** Buffer/replay APNs registration callbacks and coalesce silent-push 96 work received before AppServices has installed its app-delegate handlers. 97 - **H5:** Bound XD grid geometry, clue count, and aggregate markup work before 98 parser/Puzzle construction; the current source-byte limit still admits 99 main-actor quadratic work. 100 - **M5:** Apply a streaming, byte-capped read to every local/iCloud `.xd` and 101 `.puz` ingress path, and bound NYT response bodies before JSON conversion. 102 - **M7:** Require the NYT account-page GraphQL endpoint to be HTTPS and an 103 approved NYT host before forwarding the session-cookie header to it. 104 - **M9:** Make access revocation a mutation gate, not merely a sync-emission 105 gate, so a revoked open puzzle cannot show or persist locally fabricated 106 progress/completion. 107 - **M10:** Route the VoiceOver Reveal Square action through the same explicit 108 confirmation used by the visual menu and keyboard-command surfaces. 109 - **M11:** Make iOS release signing-key cleanup failure-safe and restore the 110 caller's exact keychain search/default state after a release attempt. 111 112 ## Phase 1 — Cloud State, Persistence, and Migration 113 114 **Status at audit completion:** Complete, with follow-up required for H1–M1. 115 116 ### Scope reviewed 117 118 `Crossmate/Sync/` (including SyncEngine, CloudQuery, RecordSerializer, 119 RecordApplier, RecordBuilder, Moves, GridStateMerger, archive, zones, journal 120 upload, presence/read-state, and diagnostics), `Crossmate/Persistence/`, the 121 Core Data model at 122 `Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents`, 123 `cloudkit.ckdb`, and the corresponding unit tests in `Tests/Unit/`. 124 125 ### Invariants checked 126 127 - CloudKit records remain confined to their game/zone and their claimed 128 author; retry, fetched snapshots, and system-field conflict recovery 129 converge. 130 - Moves, player state, journal replay/upload, deletion, archive, and presence 131 paths are idempotent and preserve durable data through retries/revocation. 132 - Store recovery preserves a failed on-disk store before rebuilding; model 133 renames and indexes support the current data shape. 134 - A fresh/restored device can reconstruct durable game, player, and replay 135 state without accepting malformed external data. 136 137 ### Findings 138 139 #### H1 — Inbound record identity is not bound to its CloudKit zone or creator 140 141 `SyncEngine` dispatches fetched Game, Moves, Player, Journal, and deletion 142 records based on their mutable record names without first requiring the 143 encoded game ID to match the record's `game-<UUID>` zone 144 (`Crossmate/Sync/SyncEngine.swift:1480`, 145 `Crossmate/Sync/RecordApplier.swift:72`). The apply paths then identify local 146 games and subordinate records by `ckRecordName` alone, not by the full 147 CloudKit identity (zone plus record name) 148 (`Crossmate/Sync/RecordSerializer.swift:780`, 149 `Crossmate/Sync/RecordSerializer.swift:809`, 150 `Crossmate/Sync/RecordSerializer.swift:987`). Deletion uses the same name-only 151 lookup (`Crossmate/Sync/RecordApplier.swift:513`). 152 153 A participant who can create records in one writable shared zone can create a 154 Game, Moves, Player, Ping, or Journal record whose name embeds another game or 155 another participant. In particular, a forged `game-<victim>` Game record in a 156 different accessible zone matches the victim's local row and replaces its zone 157 identity, system fields, metadata, credentials, and potentially puzzle source. 158 Forged Moves/Player records can likewise affect another game's local state, 159 and a same-zone fabricated `(authorID, deviceID)` record is accepted without 160 checking CloudKit creator identity. A forged deletion can remove a local row 161 with the same record name. This violates scope isolation and the Phase 2 rule 162 that a participant affects only their own authored state. 163 164 Require each game-scoped record's parsed game ID to equal the per-game zone 165 UUID before dispatching it; require the root Game record name to equal that 166 zone name; persist/look up the complete zone identity; and verify claimed 167 author IDs against CloudKit creator identity (or replace the writable record 168 scheme with authenticated per-author payloads). Apply equivalent provenance 169 checks to Archive records before materialization. Add hostile cross-zone, 170 same-zone author-forgery, and forged-deletion tests to both fetch paths. 171 172 #### H2 — Malformed remote coordinates can crash cache and replay persistence 173 174 Moves and Journal decoders accept arbitrary `Int` row and column values 175 (`Crossmate/Sync/Moves.swift:99`, `Crossmate/Persistence/Journal.swift:567`). 176 The fetched Moves path later converts those values with trapping `Int16` 177 initializers while rebuilding the cell cache 178 (`Crossmate/Sync/RecordApplier.swift:434`). Remote journals reach the same 179 conversion when a completed replay is cached 180 (`Crossmate/Persistence/Journal.swift:462`). 181 182 A writable collaborator can submit, for example, `row: 32768`; decoding 183 succeeds, then the inbound cache replay traps while assigning 184 `CellEntity.row`. This is a persistent remote denial of service because each 185 fetch replays the record. Validate representability and non-negative/grid 186 bounds before storing or replaying any remote cell/journal entry; reject the 187 whole malformed record or skip invalid entries with a diagnostic. Tests cover 188 normal and legacy codecs but not out-of-range coordinates or a malformed 189 record reaching either sink. 190 191 #### M1 — Replay and archive assets have no size or entry-count limits 192 193 `fetchReplay` reads each peer-controlled Journal asset in full before decoding 194 (`Crossmate/Sync/CloudQuery.swift:1098`). Archive payload decoding likewise 195 reads its puzzle, cells, and journal assets without a file-size gate 196 (`Crossmate/Sync/Archive.swift:350`). Unlike Game `puzzleSource`, these paths 197 do not enforce a byte limit, maximum entry count, or decoded-string budget. A 198 collaborator can make a finished-game replay consume excessive memory/CPU; H1 199 also lets a friend-zone Archive reach the materializer without an archive-zone 200 provenance check. 201 202 Use conservative file-size limits before 203 `Data(contentsOf:)`/`String(contentsOf:)`, bound decoded journal/cell counts 204 and string lengths, and report rejection. Cover oversized and count-exhaustion 205 assets in the replay/archive tests. 206 207 ### Verified non-findings 208 209 - Account push address and secret Decisions now require the private `account` 210 zone, closing the historical friend-zone injection issue 211 (`Crossmate/Sync/RecordSerializer.swift:226`). 212 - Archive promotion does not delete a revoked original unless materialization 213 produced its replacement (`Crossmate/Sync/GameArchiver.swift:221`). 214 - Game puzzle-source assets are size-gated before decoding 215 (`Crossmate/Sync/RecordSerializer.swift:919`); this protection is missing 216 only from M1's other asset paths. 217 - Current fetched database-zone deletions use the common orphan cleanup path, 218 and equal-timestamp inbound Moves values retain the existing cell state. 219 - Core Data recovery enables automatic lightweight migration, preserves failed 220 store/WAL/SHM files before rebuild, and has a regression test; current model 221 attribute renames have `renamingIdentifier`s and the frequently used game 222 and record-name indexes are present. 223 - The historical synchronous hot-path findings have been addressed in the main 224 SyncEngine, MovesUpdater, and PlayerSelectionPublisher paths; remaining 225 `performAndWait` calls reviewed here are synchronous utility accessors. 226 227 ### Residual risks and test gaps 228 229 - No integration test exercises a fresh/restored device through real CloudKit 230 zone pagination, conflict recovery, migration, and archive restoration. 231 232 ### Checks run 233 234 - Mapped all changes since 2026-07-01 outside `Crossmake/`, including the 235 follow-up fixes for historical sync, persistence, archive, migration, and 236 model-index findings. 237 - Static review and targeted source/test cross-checks for record parsing, 238 apply/delete paths, replay/archive assets, conflict/retry handling, model 239 migration/recovery, and schema parity. 240 - `bash Scripts/test-unit.sh` (after granting simulator access): passed — 241 `** TEST SUCCEEDED **` (28.089 seconds). Full output: 242 `/tmp/crossmate-phase1-tests.log`. 243 244 ### Handoff to Phase 2 245 246 Treat H1 as an open authorization prerequisite: Phase 2 must review whether 247 CloudKit ACLs, record creation metadata, invitations, roster state, and 248 realtime authentication can establish per-author ownership. Do not assume the 249 record-name convention supplies that guarantee. 250 251 ## Phase 2 — Collaboration, Sharing, and Realtime Engagement 252 253 **Status at audit completion:** Complete, with follow-up required for H1–H3 254 and M2. 255 256 ### Scope reviewed 257 258 Friend mailbox bootstrap and lifecycle in 259 `Crossmate/Sync/FriendController.swift` and `FriendZone.swift`; direct/public 260 `CKShare` lifecycle in `Crossmate/Sync/ShareController.swift`; invitations in 261 `Crossmate/Services/InviteCoordinator.swift` and `CloudService.swift`; player 262 roster/presence in `Crossmate/Models/PlayerRoster.swift` and 263 `EngagementStore.swift`; engagement transport/authentication/lifecycle in 264 `Crossmate/Services/EngagementHost.swift`, `EngagementLifecycle.swift`, 265 `Crossmate/Sync/EngagementCoordinator.swift`, and 266 `EngagementMessageAuthenticator.swift`; `Workers/room-worker.js`; and the 267 corresponding friend, sharing, engagement, roster, GameStore, and worker 268 tests. 269 270 ### Invariants checked 271 272 - Friendship bootstrap accepts only the deterministic pairwise mailbox owned 273 by the claimed friend; inbound direct invite/decline Pings are tied to the 274 writer's private-scope friend zone. 275 - Share owners alone manage direct participants; public-link seats converge 276 through the optimistic ticket record, and a participant leave removes local 277 state and records an account-wide leave decision. 278 - A realtime sender can mutate only state belonging to the game/room in which 279 it is authorized, and only under its own author identity; loss or failure of 280 the live channel falls back safely to CloudKit Moves sync. 281 - Roster/presence and engagement reconnect/lease behavior remain coherent 282 across reconnects, foreground/background changes, and same-account devices. 283 284 ### Findings 285 286 #### H3 — Authenticated realtime frames are not bound to their engagement game 287 288 `EngagementLifecycle` decodes an inbound frame and authenticates it only with 289 the sender's pairwise friend key; it never resolves the `engagementID` to its 290 associated game and compares that game with the frame's payload 291 (`Crossmate/Services/EngagementLifecycle.swift:333`, 292 `Crossmate/Services/EngagementLifecycle.swift:358`, 293 `Crossmate/Services/EngagementLifecycle.swift:379`). Although the MAC 294 canonical form includes the attacker-chosen `gameID`, it does not include a 295 room/channel identity 296 (`Crossmate/Sync/EngagementMessageAuthenticator.swift:33`, 297 `Crossmate/Sync/EngagementMessageAuthenticator.swift:146`). 298 299 Consequently, a participant who shares any live room with the victim and has 300 the normal friend-channel key can sign a frame for a different game ID. The 301 recipient accepts the valid tag, then `GameStore` groups, finds, and updates 302 the arbitrary payload game without requiring it to be the channel's game or 303 that its sender is a current participant 304 (`Crossmate/Persistence/GameStore.swift:913`, 305 `Crossmate/Persistence/GameStore.swift:930`, 306 `Crossmate/Persistence/GameStore.swift:938`). A former co-player or any 307 current co-player who knows another local game UUID (for example from a prior 308 invitation) can therefore alter its own device row in that unrelated game on 309 every victim device currently connected to the first room. The edit is not 310 re-uploaded as the victim's move, but it is written to the durable local 311 cache; its clamped future timestamp can keep the wrong cell visible until a 312 genuine newer cell write arrives. Selection frames have the same missing 313 channel/game binding, allowing a cross-game cursor injection. 314 315 Derive the expected game ID from `engagementID` in the coordinator and reject 316 single edits, every batch element, and selections whose game ID differs before 317 MAC verification or store application. Also require the verified sender to be 318 in that game's authoritative current roster once H1's CloudKit provenance 319 issue is fixed. Add tests for a correctly MACed cross-game edit, a mixed-game 320 batch, and a cross-game selection; each must leave the unrelated store and 321 engagement selection state unchanged. 322 323 #### M2 — Realtime messages and edit batches have no application-level bounds 324 325 The room worker relays every WebSocket message verbatim 326 (`Workers/room-worker.js:242`). The client immediately materializes the 327 complete message with `JSONDecoder` before authenticity is checked 328 (`Crossmate/Services/EngagementHost.swift:99`, 329 `Crossmate/Services/EngagementLifecycle.swift:333`), then canonicalizes every 330 batch entry and applies it in one unbounded grouping pass 331 (`Crossmate/Sync/EngagementMessageAuthenticator.swift:93`, 332 `Crossmate/Persistence/GameStore.swift:920`). 333 334 An authenticated collaborator can send a large valid frame or a very large 335 batch addressed to a peer. The receiver allocates/decodes it before rejecting 336 anything and may then run a large Core Data mutation on the main actor. The 337 worker's registration throttle does not bound in-room traffic, and the tests 338 exercise only registration limiting rather than socket frame limits. This is a 339 resource-exhaustion path on the live channel; durable Moves synchronization is 340 not a fallback while the receiver is busy decoding or saving the frame. 341 342 Set a conservative encoded-frame limit in the worker and client before JSON 343 decoding, cap the number of edits and strings per batch, and reject invalid or 344 out-of-grid positions before storage. Add worker and client tests for an 345 oversized frame, an over-count batch, and a valid boundary-size batch. 346 347 ### Verified non-findings 348 349 - Friend-zone bootstrap validates both the deterministic pair-key zone name 350 and CloudKit-provided owner name before accepting a share 351 (`Crossmate/Sync/FriendController.swift:203`). The historical borrowed-zone 352 attack is closed; pairwise name/encryption-key Decisions also require 353 private-scope delivery. 354 - Direct invite and decline Pings authenticate their claimed author from the 355 private friend-zone source metadata, so one friend cannot impersonate 356 another to create an invite or free that person's seat 357 (`Crossmate/Services/InviteCoordinator.swift:202`, 358 `Crossmate/Services/InviteCoordinator.swift:767`). 359 - Direct participant additions reassert the in-session intended invitee set 360 across eventually-consistent share reads, conflict recovery preserves that 361 set, and public-link tickets use optimistic save retries 362 (`Crossmate/Sync/ShareController.swift:171`, 363 `Crossmate/Sync/ShareController.swift:360`, 364 `Crossmate/Sync/ShareController.swift:906`). 365 - The worker keeps the room secret out of connect URLs, requires a registered 366 secret-backed HMAC with freshness and single-use nonce checks, supersedes a 367 stale same-device socket, and rate-limits new room registration 368 (`Crossmate/Services/EngagementHost.swift:130`, 369 `Workers/room-worker.js:196`, `Workers/room-worker.js:336`). 370 - End-to-end per-recipient frame MACs now prevent a room member from writing a 371 different author's Moves record; future timestamps are clamped before the 372 last-writer-wins merge 373 (`Crossmate/Sync/EngagementMessageAuthenticator.swift:171`, 374 `Crossmate/Persistence/GameStore.swift:913`). Same-account devices 375 intentionally lack a pairwise friend key and therefore fall back to durable 376 Moves sync, as recorded in D3. 377 - `PlayerRoster` is an `@MainActor` `@Observable` model; it fetches Core Data 378 asynchronously, generation-gates stale refreshes, and only assigns its 379 Equatable entries when they actually change 380 (`Crossmate/Models/PlayerRoster.swift:8`, 381 `Crossmate/Models/PlayerRoster.swift:252`, 382 `Crossmate/Models/PlayerRoster.swift:426`). 383 384 ### Residual risks and test gaps 385 386 - No integration test simulates two iCloud accounts/devices through friend 387 bootstrap healing, direct invite acceptance/decline, share conflict retries, 388 public-link concurrent joins, room expiry/re-registration, leave/revocation, 389 or the same-account durable-only fallback. The unit tests cover the helpers 390 and state machines but not their real CloudKit/worker composition. 391 392 ### Checks run 393 394 - Read the completed Phase 1 record, all named historical audit notes and 395 `DEFERRED.md`; mapped every non-`Crossmake` commit since 2026-07-01, with 396 focused review of friendship, invite, share, roster, presence, engagement, 397 and room-worker changes. 398 - Static implementation/test cross-check of CloudKit source-zone/scope gates, 399 share and ticket conflict handling, leave/revocation behavior, websocket 400 registration/connect authentication, MAC canonicalization, reconnect/lease 401 ownership, roster data flow, and realtime store application. 402 - `bash Scripts/test-workers.sh`: passed — 17 tests. 403 - `bash Scripts/test-unit.sh` (after granting simulator access): passed — 404 `** TEST SUCCEEDED **` (27.797 seconds). Full output: 405 `/tmp/crossmate-phase2-tests.log`. 406 407 ### Handoff to Phase 3 408 409 Phase 3 should treat H3/M2 as active realtime-to-push boundary work: any push 410 or notification side effect must not turn a spoofed or oversized live frame 411 into durable state. Preserve the account/game credential provenance checks 412 while reviewing worker request authorization. H1 and H2 remain prerequisites 413 for trusting any CloudKit-derived game, player, or coordinate identity; H2 now 414 also requires a realtime ingress test. 415 416 ## Phase 3 — Push and Notification Pipeline 417 418 **Status at audit completion:** Complete, with follow-up required for H1–H4 419 and M3. 420 421 ### Scope reviewed 422 423 `Crossmate/Services/PushClient.swift`, `PushRequestAuthenticator.swift`, 424 `AccountPushCoordinator.swift`, `SessionPushPlanner.swift`, and 425 `BadgeCoordinator.swift`; `Crossmate/Sync/GamePushCredentials.swift`; the push 426 delivery and App Group code in `Shared/` (including `PushPayload`, 427 `PushPayloadCipher`, `NotificationState`, content/friend-key directories, and 428 notification text); `NotificationService/NotificationService.swift`; app 429 delegate push routing; `Workers/push-worker.js` and `wrangler.push.toml`; push 430 and notification entitlements/target wiring; and the corresponding unit and 431 worker tests. 432 433 ### Invariants checked 434 435 - An installation proves App Attest possession for protected worker requests; 436 request bodies, paths, timestamps, and nonces are bound to its assertion, 437 while a game push additionally proves the current game credential. 438 - A device can register or remove only its own address binding, and a departed 439 or revoked game participant cannot retain delivery or publish access. 440 - The worker, app, and notification extension agree on encrypted payload, 441 generic-alert, badge, coalescing, and background-push semantics; malformed 442 data fails without mutating game state. 443 - Push data and diagnostics do not expose personal notification text, puzzle 444 names, keys, or other capabilities beyond their intended local recipient. 445 446 ### Findings 447 448 #### H4 — Game push registrations do not establish current participation or revoke departed access 449 450 `PushRegistry.handleRegister` accepts every `{ address, credID }` supplied by 451 any App-Attest-enrolled installation and stores it under that credential 452 (`Workers/push-worker.js:345`, `Workers/push-worker.js:371`). Unlike 453 `/publish`, it neither requires a game HMAC nor even confirms that 454 `gamecred:<credID>` exists. `PushClient` batches all game bindings into this 455 App-Attest-only `/register` request 456 (`Crossmate/Services/PushClient.swift:462`). The game credential is 457 deliberately durable and has no expiry 458 (`Crossmate/Sync/GamePushCredentials.swift:26`), and the departure handling 459 only asks the honest local client to unregister 460 (`Crossmate/Services/PushClient.swift:487`); the worker has no 461 membership/revocation state and cannot remove a stale or malicious device 462 binding. 463 464 Consequently, a former participant who cached a game's `credID` can bind its 465 own APNs token to that room without possessing the secret and receive later 466 broadcast pushes. A former participant who also cached the normal shared 467 credential can additionally keep publishing until credentials are rotated. The 468 same registration gap lets anyone who learns a `credID` subscribe before they 469 have the game secret. Address derivation and device-ID matching prevent one 470 installation from deleting another installation's key, but they do not make a 471 subscription an authorized current membership. This violates the 472 departure/revocation invariant and makes H1 more urgent: Phase 1 can currently 473 let a forged Game record replace the credentials a device trusts. 474 475 Bind each game-address registration to a valid HMAC for that credential (for 476 example, register each credential/binding independently), reject unknown 477 credential IDs, and rotate `credID`, worker secret, and content key on leave 478 or revocation. The remaining participants must publish/register the 479 replacement; the worker should retain no acceptance path for the old 480 credential. Add worker tests proving an App-Attest-only game binding and a 481 stale credential are rejected, while an authorized replacement credential 482 delivers only to current bindings. 483 484 #### M3 — Worker ingress, address fanout, and opaque notification payloads are unbounded 485 486 The worker materializes every request body before authentication or JSON 487 validation (`Workers/push-worker.js:31`), then accepts arbitrary-length 488 `addresses`, `mutedKinds`, address strings, titles, bodies, collapse IDs, and 489 encrypted/legacy opaque payloads. A non-broadcast publish only requires a 490 non-empty array (`Workers/push-worker.js:430`); it scans storage for every 491 submitted address (`Workers/push-worker.js:672`), and a broadcast scans and 492 sends to every credential-scoped registration without a target limit 493 (`Workers/push-worker.js:714`, `Workers/push-worker.js:484`). The notification 494 extension likewise base64-decodes and AES-GCM-opens an unbounded `enc` value 495 before decoding it (`NotificationService/NotificationService.swift:52`, 496 `Shared/PushPayloadCipher.swift:44`). 497 498 An authenticated participant can therefore make a single valid request consume 499 unbounded worker memory/CPU/storage scans and sequential APNs work; the 500 current per-credential rate limit restricts request frequency, not request or 501 fanout size. Oversized messages also exceed APNs' payload limit only after 502 this work, causing delivery failure rather than a safe, cheap rejection. H4 503 permits an attacker who knows a credential ID to inflate the room's 504 registration set, worsening broadcast work. 505 506 Set conservative byte limits before reading request bodies and before 507 base64/AES decoding; validate string and ID formats; cap bindings, muted 508 kinds, explicit recipients, registrations per credential, and broadcast 509 targets; reject APNs payloads that would exceed the supported size. Cover 510 exact-boundary success and oversized body, recipient list, ciphertext, and 511 fanout failures in the worker and shared-payload tests. 512 513 ### Verified non-findings 514 515 - The historical embedded bearer has not returned: protected registration, 516 credential registration, and publish calls require an App Attest assertion; 517 its canonical request binds method, path, body hash, timestamp, nonce, 518 device ID, and key ID 519 (`Crossmate/Services/PushRequestAuthenticator.swift:159`, 520 `Workers/push-worker.js:71`). Worker nonce/timestamp checks make a captured 521 assertion replay fail during its validity window 522 (`Workers/push-worker.js:103`). 523 - Game publishes do verify a credential HMAC and resolve only the 524 corresponding credential-scoped address keys (`Workers/push-worker.js:463`, 525 `Workers/push-worker.js:646`). The gap is address registration/revocation 526 (H4), not request-body swapping or ordinary cross-credential publish 527 routing. 528 - Current game notifications send a generic cleartext alert and seal personal 529 structured payload fields with a content key that is not sent to the worker 530 (`Crossmate/Services/PushClient.swift:313`, 531 `Crossmate/Sync/GamePushCredentials.swift:20`). Worker authentication logs 532 record lengths/statuses rather than request contents 533 (`Workers/push-worker.js:34`). 534 - The APNs environment derives from one build setting used by both entitlement 535 and client configuration, and the extension shares only the expected App 536 Group entitlement (`Crossmate/Crossmate.entitlements`, `project.yml:104`). 537 - Badge horizons distinguish monotonic read state from collapsible presence 538 suppression, and the notification extension uses decrypted structured 539 payload semantics before marking a game unread 540 (`Shared/NotificationState.swift:302`, 541 `NotificationService/NotificationService.swift:122`). 542 543 ### Residual risks and test gaps 544 545 - The worker tests cover rate-limit helpers only; they do not exercise actual 546 App Attest verification, register/unregister authorization, credential 547 registration, publish routing, APNs-payload construction, or any H4/M3 548 rejection. Unit tests exercise payload and ledger helpers, but there is no 549 Notification Service Extension integration test for decrypt/rewrite, 550 coalescing, badge, expiry, or App Group contention. Real-device validation 551 is still needed for App Attest, APNs payload limits, a fresh install, 552 multi-device badge/read-state convergence, and leave/revocation credential 553 rotation. 554 555 ### Checks run 556 557 - Read the completed Phase 1–2 record, all named historical notes and 558 `DEFERRED.md`, and mapped the non-`Crossmake` change set since 2026-07-01, 559 including App Attest, credential-gated push, payload encryption, address 560 unregister, rate-limit sweep, and notification-text changes. 561 - Static implementation/test cross-check of App Attest enrollment/assertion 562 canonicalization, nonce/timestamp/counter behavior, game credential and 563 address binding, registration/removal, APNs construction, encrypted payload 564 compatibility, App Group directories, badge/read horizons, coalescing, 565 account-control pushes, entitlements, and diagnostics logging. 566 - `node --check Workers/push-worker.js`: passed. 567 - `bash Scripts/test-workers.sh`: passed — 17 tests. 568 - `bash Scripts/test-unit.sh` (after granting simulator access): passed — 569 `** TEST SUCCEEDED **` (27.249 seconds). Full output: 570 `/tmp/crossmate-phase3-tests.log`. 571 572 ### Handoff to Phase 4 573 574 H1 remains an authorization prerequisite for all CloudKit-derived Game fields, 575 including push credentials and content keys. H4 must be designed with the 576 Phase 2 leave/revocation model: client-side unregister is a hygiene step, not 577 an access-control boundary. Phase 5 should retain the push-control rule that 578 an advisory notification must not be the sole authority for durable game 579 state. 580 581 ## Phase 4 — Puzzle Content Boundary 582 583 **Status at audit completion:** Complete, with follow-up required for H5, M5, 584 and M7. 585 586 ### Scope reviewed 587 588 The XD parser, metadata/rebus/special/markup handling, normalized `Puzzle` 589 construction, catalog/source model, bundled and debug puzzle resources; `.puz` 590 and NYT-JSON converters; NYT fetch/auth/upgrade paths; local URL, 591 security-scoped and iCloud Drive import paths; and the corresponding XD, 592 markup, converter, upgrader, auth, catalog, invitation, and serializer tests. 593 `Crossmake/` was excluded. 594 595 ### Invariants checked 596 597 - A local, shared, or remote puzzle fails closed before it can exhaust memory 598 or CPU, and its declared geometry remains usable by persistence and 599 gameplay. 600 - XD, PUZ, and NYT content produce a stable, playable XD representation, 601 including rebuses, special cells, formatted clues, accepted alternatives, 602 and legacy/current converter headers. 603 - A converter upgrade never replaces an in-progress game's answer semantics 604 unless existing moves remain valid; malformed/newer content leaves the 605 durable source recoverable. 606 - Network responses and credentials are bounded and validated before use; a 607 NYT session cookie cannot be disclosed to an endpoint named by response 608 data. 609 610 ### Findings 611 612 #### H5 — The XD byte cap still admits quadratic main-actor parser work 613 614 `XD.parse` limits only the UTF-8 byte count, then accepts arbitrary grid 615 width, height, clue count, and header cardinality 616 (`Crossmate/Models/XD.swift:199`, `Crossmate/Models/XD.swift:537`, 617 `Crossmate/Models/XD.swift:607`). For every grid position, 618 `positionsOutsideExactCellAnswers` re-discovers the whole containing word 619 before deciding whether an `Accept` projection exists 620 (`Crossmate/Models/XD.swift:905`, `Crossmate/Models/XD.swift:948`, 621 `Crossmate/Models/XD.swift:1118`). A syntactically valid source with one very 622 long all-open row, fixed letters, and a single answer-less clue stays under 623 `maxSourceBytes` yet makes that pass walk the same word from every cell: 624 quadratic work before a game is created. This reaches the main actor through 625 imported-game creation and fast invite acceptance 626 (`Crossmate/Persistence/GameStore.swift:1201`, 627 `Crossmate/Persistence/GameStore.swift:1237`). 628 629 The per-clue limit does not bound aggregate work either. `Puzzle.init` renders 630 each clue twice through `XDMarkup` (`Crossmate/Models/Puzzle.swift:157`); 631 every unterminated markup opener scans to the end of its clue 632 (`Crossmate/Models/XDMarkup.swift:54`, `Crossmate/Models/XDMarkup.swift:91`). 633 Hundreds of legal 1,024-character malformed clues can therefore cause hundreds 634 of millions of character inspections within the 262-KB source cap. A shared 635 Game asset or invitation can freeze the accept/open flow, violating the 636 content availability invariant despite the prior segmentation memoization fix. 637 638 Enforce conservative maximum width, height, cell count, clue count, and 639 aggregate clue/metadata budgets before allocating grids. Build a word index 640 once rather than walking each word per cell, and make markup linear (or bound 641 the total number of unmatched-open scans). Add adversarial boundary tests for 642 a long thin grid, maximum legitimate geometry, many answer-less clues, and 643 many unterminated markup spans. 644 645 #### M5 — iCloud/local import and NYT download paths materialize unbounded data 646 647 The direct open-URL importer generally checks `fileSizeKey`, but explicitly 648 falls through when that value is unavailable and then uses 649 `String(contentsOf:)`/`Data(contentsOf:)` 650 (`Crossmate/Services/ImportService.swift:23`, 651 `Crossmate/Services/ImportService.swift:35`). The primary Imported-tab path 652 has no preflight or streaming limit at all: `DriveMonitor.readSource` 653 coordinates the file and reads its complete contents before XD parsing or PUZ 654 conversion (`Crossmate/Services/DriveMonitor.swift:85`). Its Files picker 655 copies a selected file into the app's iCloud container before any validation 656 (`Crossmate/Views/Browse/ImportedBrowseView.swift:64`, 657 `Crossmate/Services/DriveMonitor.swift:148`). 658 659 The NYT fetcher has the analogous response boundary: `URLSession.data(for:)` 660 buffers the full response and hands it directly to JSON conversion without a 661 content-length/type/byte check 662 (`Crossmate/Services/NYTPuzzleFetcher.swift:28`, 663 `Crossmate/Services/NYTToXDConverter.swift:25`). The XD output cap happens 664 only after these allocations; a PUZ may also carry a large ignored tail or 665 string table. A hostile file provider, a very large file already placed in the 666 Crossmate iCloud folder, or an unexpected server response can thus exhaust 667 memory before rejection. 668 669 Use one coordinated, streaming capped-read helper for all local/imported URLs; 670 cap raw PUZ independently from produced XD, and validate before copying into 671 iCloud. For NYT, require a successful JSON content type where supplied and 672 stream/cancel after a conservative byte budget. Add tests for oversize and 673 unknown-length URLs on both local paths, an oversize PUZ with a small XD 674 prefix, and oversized/non-JSON NYT responses. 675 676 #### M7 — Response-derived NYT GraphQL URL can receive the session cookie 677 678 After downloading account-page HTML from NYT, `NYTAuthService` extracts 679 `gqlUrlClient` without checking its scheme or host 680 (`Crossmate/Services/NYTAuthService.swift:273`). It then POSTs the full NYT 681 cookie header to that response-provided URL 682 (`Crossmate/Services/NYTAuthService.swift:201`, 683 `Crossmate/Services/NYTAuthService.swift:209`). A compromised/misrouted 684 account-page response, or future configuration error, can therefore redirect a 685 bearer session cookie to an arbitrary origin. The existing test verifies 686 extraction of a legitimate NYT endpoint but exercises no rejection path 687 (`Tests/Unit/NYTAuthServiceTests.swift:63`). 688 689 Reject non-HTTPS URLs and require an exact approved host or a strict 690 `.nytimes.com` suffix before constructing the configuration; test an attacker 691 host, `nytimes.com.attacker.tld`, and a non-HTTPS URL. Keep the cookie header 692 scoped to the approved destination. 693 694 ### Verified non-findings 695 696 - The historical exponential XD segmentation issue is closed: `segment` is 697 memoized and caps produced segmentations (`Crossmate/Models/XD.swift:828`); 698 its deliberately adversarial regression test completes successfully 699 (`Tests/Unit/XDAcceptTests.swift:563`). 700 - XD rejects sources over 262,144 bytes, over-long individual clue text, 701 ragged grids, unsupported answer characters, ambiguous projections, and 702 missing inferred cells. Direct imported URLs use security-scoped access and 703 normally preflight their size (`Crossmate/Models/XD.swift:199`, 704 `Crossmate/Services/ImportService.swift:16`). 705 - NYT conversion rejects non-positive/overflowing geometry and mismatched cell 706 counts before grid indexing (`Crossmate/Services/NYTToXDConverter.swift:49`, 707 `Crossmate/Services/NYTToXDConverter.swift:63`). Its rebus placeholder 708 allocation is bounded and avoids XD syntax; PUZ now deduplicates rebus 709 values, correctly applies GRBS's one-based table index, and preserves a 710 rebus over a conflicting circle marker 711 (`Crossmate/Services/PUZToXDConverter.swift:83`, 712 `Crossmate/Services/PUZToXDConverter.swift:342`). 713 - Legacy `CmVer` sources remain readable, current converters write `ConVer`, 714 and owner-only NYT upgrades parse both sources and reject ordinary grid, 715 block, or single-letter solution divergence 716 (`Crossmate/Models/XD.swift:207`, 717 `Crossmate/Services/NYTPuzzleUpgrader.swift:68`). 718 - NYT cookies use the keychain, fetch paths no longer log cookie/body/source 719 content, and normal HTTP status handling distinguishes expired sessions and 720 rate limiting (`Crossmate/Services/NYTAuthService.swift:78`, 721 `Crossmate/Services/NYTPuzzleFetcher.swift:28`). 722 723 ### Residual risks and test gaps 724 725 - There are no tests for `ImportService`, `DriveMonitor`, or the Imported-tab 726 picker; specifically absent are security-scoped lifetime, coordinated-read 727 errors, File Provider URLs with no file size, iCloud copy failure/cleanup, 728 and size-limit behavior. 729 - No test parses every bundled/debug XD and checks its dimensions/block mask 730 against its manifest. The catalog tests establish discovery and 731 manifest-mask length only (`Tests/Unit/PuzzleCatalogTests.swift:8`). 732 - PUZ tests cover normal/circled/rebus inputs and Data slicing, but not raw 733 size limits, malformed extension lengths, invalid grid bytes, checksum 734 handling, or newline-bearing metadata/clues. 735 - NYT converter tests cover geometry and output round trips, but not response 736 content type/size, maximum strings/clues/relatives, or a full auth/fetch 737 integration with a controlled URL protocol. Real-device validation remains 738 necessary for Keychain accessibility and the NYT login redirect. 739 740 ### Checks run 741 742 - Read all completed Phase 1–3 sections of this report; all named historical 743 review notes and `DEFERRED.md`; and the complete non-`Crossmake` change set 744 since 2026-07-01. Traced the direct file-import, iCloud, CloudKit-invite, 745 NYT fetch/auth/upgrade, parser, converter, persistence, and UI callers. 746 - Static source/test cross-check of XD sections, geometry, answer projection, 747 markup, rebus/special conversion, version compatibility, upgrade guards, 748 security-scoped access, network status/credential handling, and bundle 749 manifests/resources. The SwiftUI Imported-tab boundary was also checked 750 against the applicable SwiftUI data-flow, environment, list identity, 751 localization, and API guidance; no independent SwiftUI-state finding arose. 752 - `bash Scripts/test-unit.sh` (after granting simulator access): passed — 753 `** TEST SUCCEEDED **` (27.457 seconds). Full output: 754 `/tmp/crossmate-phase4-tests.log`. 755 756 ### Handoff to Phase 5 757 758 H5 is the content-boundary prerequisite for any lifecycle route that opens a 759 puzzle: do not add a new deep-link/import route that parses 760 attacker-controlled source without the same geometry and work budgets. M5's 761 iCloud copy/read behavior and M7's credential forwarding should be rechecked 762 where app/scene lifecycle schedules and cancels work. Puzzle 763 source-replacement semantics should be considered by any workflow that assumes 764 a loaded game and its cell cache remain valid across background upgrade 765 activity. 766 767 ## Phase 5 — Application Lifecycle and Workflow Integration 768 769 **Status at audit completion:** Complete, with follow-up required for M8. 770 771 ### Scope reviewed 772 773 `CrossmateApp` and its app/scene delegates and notification/share-link 774 brokers; `AppServices`, `AppActions`, `CloudService`, `SessionCoordinator`, 775 `PuzzleSession`, `InviteCoordinator`, `AccountPushCoordinator`, `PushClient`, 776 `ShareLinkRoute`, `DebuggingMonitors`, and `DiagnosticsReport`; the diagnostic 777 view and notification-service receipt boundary; and the corresponding session, 778 route, account-transition, push-registration, notification-navigation, log 779 scrubber, and announcement tests. 780 781 ### Invariants checked 782 783 - Launch, foreground/background, open/close, and completion paths coalesce 784 work, cancel superseded work, preserve buffered edits, and leave a durable 785 retry/reconciliation path after suspension. 786 - Notification, universal-link, and CloudKit-share routes are validated before 787 navigation or share acceptance; cold-launch routes survive handler setup. 788 - Account switches purge only an actually different account's local cache and 789 rebuild CloudKit state without deleting either account's remote data. 790 - Support diagnostics preserve useful operational state without exporting 791 notification text, player names, puzzle titles, credentials, or other 792 private user data. 793 794 ### Findings 795 796 #### M8 — APNs tokens and silent-push wakes can be lost before AppServices installs its delegate handlers 797 798 `AppDelegate` starts APNs registration during `didFinishLaunching` 799 (`Crossmate/CrossmateApp.swift:101`), but merely calls optional closures when 800 registration completes or a background push arrives 801 (`Crossmate/CrossmateApp.swift:110`, `Crossmate/CrossmateApp.swift:218`). 802 Those closures are not assigned until the root view's asynchronous startup 803 reaches `AppServices.start` (`Crossmate/Services/AppServices.swift:781`, 804 `Crossmate/Services/AppServices.swift:843`). Unlike notification navigation, 805 CloudKit share acceptance, and universal links, which each have a broker that 806 buffers the cold-launch handoff, this path has no stored token or pending 807 remote-notification queue. 808 809 APNs does not guarantee that the token callback or a content-available wake 810 will wait for a SwiftUI root-view `.task`. If either arrives in that window, 811 the delegate discards it and returns `.newData`. `PushClient` keeps the APNs 812 token only in memory and skips registration when it is absent 813 (`Crossmate/Services/PushClient.swift:161`, 814 `Crossmate/Services/PushClient.swift:210`), so this installation may not 815 register any game or account push address until a later token callback. A lost 816 silent wake also misses its intended background fetch/session scan and is 817 reported to iOS as new data; foreground catch-up eventually converges durable 818 state, but background delivery and notification reliability degrade silently. 819 820 Retain the most recent APNs registration result and token in `AppDelegate` and 821 replay them when their handlers are set. Queue/coalesce pre-start remote 822 notifications (or install a durable AppServices-owned receiver before 823 registration) and drain them after startup, preserving the background state. 824 Add deterministic tests for token, registration failure, and private/shared 825 remote notifications delivered before handler installation, including a 826 background-only launch with no scene activation. 827 828 ### Verified non-findings 829 830 - The foreground and game-list refresh paths use single-flight tasks and 831 per-scope guards, while background flushes take independent UIKit execution 832 assertions and requeue unconfirmed work on the next foreground 833 (`Crossmate/Services/AppServices.swift:1330`, 834 `Crossmate/Services/AppServices.swift:1372`). 835 - Puzzle-session end/bounce interleavings are centralized in `PuzzleSession`: 836 cancellation releases its assertion, the timer and expiry handler share an 837 idempotent fire path, and open/close phases cancel or supersede it 838 (`Crossmate/Services/PuzzleSession.swift:82`, 839 `Crossmate/Services/PuzzleSession.swift:127`, 840 `Crossmate/Services/SessionCoordinator.swift:139`). 841 - Notification routes parse a UUID (or the constrained `game-<UUID>` CloudKit 842 zone form) before navigation. Share links constrain token syntax and 843 reconstruct an iCloud URL; `CloudService` independently requires the app's 844 CloudKit container before accepting the share 845 (`Crossmate/CrossmateApp.swift:177`, 846 `Crossmate/Services/ShareLinkRoute.swift:17`, 847 `Crossmate/Services/CloudService.swift:112`). 848 - A true iCloud-account change clears only the local cache and resets sync 849 state; it does not delete the former account's zones or leave its shares. 850 First sign-in and transient sign-out do not purge 851 (`Crossmate/Services/AppServices.swift:449`, 852 `Crossmate/Services/CloudService.swift:292`). 853 - Diagnostics history is bounded and persisted off-main-actor; the full report 854 is snapshotted cheaply at tap time and rendered only when an activity 855 requests it, so the historical continuous full-log rewrite has not regressed 856 (`Crossmate/Services/DebuggingMonitors.swift:262`, 857 `Crossmate/Views/Settings/DiagnosticsView.swift:266`). 858 - The app-entry SwiftUI composition retains stable `AppServices` state and 859 injects stable action/model objects rather than custom closure environment 860 values; no independent SwiftUI environment/data-flow finding arose in this 861 phase. 862 863 ### Residual risks and test gaps 864 865 - Neither the Notification Service Extension nor a full diagnostics export has 866 an integration test for the App Group receipt path. 867 - No test models an app launch/wake before `RootView.task` installs delegate 868 handlers, APNs token delivery timing, background-only silent-push execution, 869 or the OS background-task expiration path around 870 `AppServices.syncOnBackground`. 871 - The broker tests cover notification navigation buffering, but not 872 `CloudShareAcceptanceBroker`/`ShareLinkBroker` ordering, duplicate share 873 links, or cancellation/replacement of simultaneous link acceptances. 874 - Real-device validation remains needed for APNs callback ordering, CloudKit 875 silent-push/background execution budgets, account change while a puzzle is 876 open, scene restoration, and direct/public share acceptance/revocation. 877 - Deferred D5 remains a known benign lifecycle-tidiness issue: an 878 `NYTBrowseView.fetch` task is not cancelled on dismissal. It is outside this 879 phase's primary workflow scope and remains correctly parked unless its 880 behavior changes. 881 882 ### Checks run 883 884 - Read all completed Phase 1–4 sections of this report; all named historical 885 review notes and `DEFERRED.md`; and the complete non-`Crossmake` change set 886 since 2026-07-01. 887 - Static implementation/test cross-check of app startup, lifecycle task 888 ownership, foreground/background persistence, remote-push coalescing, 889 session timers, account transition, notification/deep/share routing, 890 diagnostics persistence/export, and related unit tests. SwiftUI app-entry 891 and environment use were reviewed with the applicable SwiftUI guidance. 892 - `bash Scripts/test-unit.sh` (after granting simulator access): passed — 893 `** TEST SUCCEEDED **` (24.483 seconds). Full output: 894 `/tmp/crossmate-phase5-tests.log`. 895 896 ### Handoff to Phase 6 897 898 Keep M8 in scope where Phase 6 relies on foreground notification delivery or 899 scene transitions to preserve UI state. The pending Phase 4 content limits 900 (H5, M5, and M7) also apply to any UI route that imports, fetches, or opens 901 puzzle source. 902 903 ## Phase 6 — Gameplay, Accessibility, and UI State 904 905 **Status at audit completion:** Complete, with follow-up required for M9–M10. 906 907 ### Scope reviewed 908 909 `Crossmate/Views/`, with focused tracing through `PuzzleView`, `GridView`, 910 `GridAccessibility`, `KeyboardView`, `HardwareKeyboardInputView`, `ClueList`, 911 `ClueBar`, `PuzzleHeader`, `PuzzleScoreboard`, `SuccessPanel`, puzzle 912 modifiers/commands, Game List cards/rows/sections, browse and invitation 913 surfaces, and view-facing `PlayerSession`, `ReplayControls`, `PlayerRoster`, 914 `GameCursorStore`, `GameMutator`, and puzzle-display lifecycle code. Reviewed 915 the corresponding PlayerSession, GameMutator, replay, cursor, accessibility, 916 summary, and navigation unit tests. 917 918 ### Invariants checked 919 920 - Every on-screen, hardware-keyboard, and accessibility action acts on the 921 current game/cell and cannot mutate a solved or revoked session. 922 - Navigation, scene/layout changes, replay, completion, and session 923 replacement preserve the intended puzzle, cursor, clue context, and 924 read-only state. 925 - VoiceOver exposes navigable cells, clues, values, and equivalent safe 926 actions for the core solving workflow. 927 - Grid and list rendering stay bounded: high-frequency selection/peer updates 928 do not rebuild per-cell or per-game subtrees unnecessarily. 929 930 ### Findings 931 932 #### M9 — A revoked open puzzle still accepts local mutations and can be marked completed 933 934 `markAccessRevoked` only flips the active mutator's `isAccessRevoked` flag 935 (`Crossmate/Persistence/GameStore.swift:2776`). That flag prevents work only 936 at `emitMove`, after `setLetter`, `clearLetter`, and every bulk 937 check/reveal/clear operation have already mutated the in-memory `Game` 938 (`Crossmate/Persistence/GameMutator.swift:86`, 939 `Crossmate/Persistence/GameMutator.swift:132`, 940 `Crossmate/Persistence/GameMutator.swift:317`). 941 942 The intended input block is incomplete: `PuzzleView` disables only the custom 943 keyboard and declines hardware keys 944 (`Crossmate/Views/Puzzle/PuzzleView.swift:124`, 945 `Crossmate/Views/Puzzle/PuzzleView.swift:530`, 946 `Crossmate/Views/Puzzle/PuzzleView.swift:564`). The toolbar continues to 947 expose Entry and Hints mutations 948 (`Crossmate/Views/Puzzle/PuzzleModifiers.swift:91`, 949 `Crossmate/Views/Puzzle/PuzzleModifiers.swift:125`), and the VoiceOver custom 950 actions call those mutators directly 951 (`Crossmate/Views/Puzzle/GridAccessibility.swift:497`). 952 953 Entering or revealing the final squares therefore changes the local game and 954 emits a local solved event (`Crossmate/Models/PlayerSession.swift:427`). 955 `PuzzleView` handles that event as an ordinary local solve 956 (`Crossmate/Views/Puzzle/PuzzleView.swift:430`), and the puzzle destination 957 persists `completedAt`, locks the local mutator, and queues its normal 958 completion work without checking revocation 959 (`Crossmate/CrossmateApp.swift:681`, 960 `Crossmate/Persistence/GameStore.swift:1427`). The fabricated fills themselves 961 are not journaled or uploaded, but the revoked user can see phantom progress 962 and turn their local revoked copy into a terminal completed game; later 963 refreshes may make that state appear to regress. 964 965 Gate all `GameMutator` mutation entry points on `!isAccessRevoked` alongside 966 `!isCompleted`, and disable or hide all editable action surfaces from the same 967 single read-only predicate. Add tests for letter, clear, check, reveal, rebus, 968 and completion attempts after revocation, including an open `PuzzleView` path 969 that proves no completion persistence or outgoing completion work is 970 triggered. 971 972 #### M10 — VoiceOver’s Reveal Square action bypasses the reveal confirmation 973 974 The visual Hints menu and app-level command surface deliberately request a 975 confirmation before revealing 976 (`Crossmate/Views/Puzzle/PuzzleModifiers.swift:137`, 977 `Crossmate/Views/Puzzle/PuzzleCommands.swift:32`). The VoiceOver cell action 978 instead calls `session.revealSquare()` directly via `revealAndAnnounce` 979 (`Crossmate/Views/Puzzle/GridAccessibility.swift:329`, 980 `Crossmate/Views/Puzzle/GridAccessibility.swift:502`). Reveal is non-undoable, 981 so a VoiceOver user can accidentally spend a hint and alter the shared puzzle 982 with no confirmation that sighted and hardware-command users receive. This is 983 an accessibility parity and destructive-action safety gap, not a limitation of 984 the grid's synthetic hierarchy. 985 986 Pass a confirmation request into the accessibility modifier (or surface the 987 same focused `PuzzleActionTarget`) and present the existing alert before 988 calling the mutator. Add an accessibility/UI regression test that activating 989 Reveal Square first presents the alert and that cancelling leaves the square 990 unchanged. 991 992 ### Verified non-findings 993 994 - Player cursor state is owned by a `@MainActor` `PlayerSession`, restored 995 only for a valid non-block cell, and persisted on selection changes; 996 undo/redo land on their recorded cells and orientations 997 (`Crossmate/Models/PlayerSession.swift:97`, 998 `Crossmate/Models/PlayerSession.swift:360`). 999 - The grid now draws cell content in a single Canvas snapshot while selection, 1000 remote cursor, and recent-change overlays observe their narrower state 1001 slices. Peer cursor updates do not rebuild a per-cell `ForEach` 1002 (`Crossmate/Views/Puzzle/GridView.swift:55`, 1003 `Crossmate/Views/Puzzle/GridView.swift:516`). 1004 - Replay state is view-owned, loading is idempotent, and its play task checks 1005 cancellation between ticks; a replay frame suppresses live grid input and 1006 maps the clue display to the replay cursor 1007 (`Crossmate/Models/ReplayControls.swift:164`, 1008 `Crossmate/Views/Puzzle/SuccessPanel.swift:396`, 1009 `Crossmate/Views/Puzzle/GridView.swift:124`). 1010 - The current VoiceOver grid exposes one element per open cell, keeps focus 1011 and cursor synchronized, supplies clue rotors and values, and coalesces 1012 peer-fill announcements. Its pure description logic is covered by unit tests 1013 (`Crossmate/Views/Puzzle/GridAccessibility.swift:215`, 1014 `Tests/Unit/CellAccessibilityDescriberTests.swift:40`). 1015 - The Game List caches derived section snapshots outside the render path and 1016 uses stable game/object identities. Current environment defaults are stable 1017 optional model/action values; no regression of the historical closure-based 1018 environment invalidation issue was found. 1019 1020 ### Residual risks and test gaps 1021 1022 - There are no UI tests that enable VoiceOver and drive the Canvas-backed 1023 grid, custom actions, rotors, direct-touch keyboard, alerts, Dynamic Type, 1024 or iPad layout. The existing accessibility tests validate spoken strings 1025 only, not focus, activation, or confirmation wiring. 1026 - The high-frequency Canvas architecture is structurally sound, but no 1027 performance test measures large grids, long clue lists, or a large game 1028 library under live cursor/move traffic. Real-device profiling with VoiceOver 1029 enabled remains needed because it intentionally creates one accessibility 1030 element per open square. 1031 - The large computed SwiftUI sections recorded in `Notes/ViewStructure.md` 1032 remain in PuzzleView, GameListView, and NYTBrowseView. The Game List cache 1033 fixes the hottest collection derivation, but these helpers do not create 1034 independent invalidation boundaries. They are a bounded performance risk, 1035 not a new correctness finding. 1036 - Several announcements and view strings remain English-shaped at runtime, 1037 notably PuzzleView's manual participant list and headings transformed with 1038 `.textCase(.uppercase)` in the clue surfaces 1039 (`Crossmate/Views/Puzzle/PuzzleView.swift:282`, 1040 `Crossmate/Views/Puzzle/ClueList.swift:192`). This is the historical 1041 localization cleanup risk; it does not alter the current solving state 1042 machine. 1043 - M8 remains relevant when scene transitions bracket a puzzle: the app has no 1044 deterministic integration test proving an early APNs callback cannot race 1045 its UI/session startup. 1046 1047 ### Checks run 1048 1049 - Read all completed Phase 1–5 sections of this report, all named historical 1050 review notes and `DEFERRED.md`, and mapped every non-`Crossmake` change 1051 since 2026-07-01, with focused review of the Game List caching, clue-list 1052 extraction, VoiceOver grid, replay, marketing rendering, hardware input, and 1053 layout changes. 1054 - Static implementation/test cross-check of SwiftUI state ownership, 1055 observation/environment usage, collection identity, navigation, keyboard and 1056 accessibility input, replay/completion flow, Dynamic Type/iPad layout, and 1057 high-frequency grid/list paths using the applicable SwiftUI guidance. 1058 - `bash Scripts/test-unit.sh` (after granting simulator access): passed — 1059 `** TEST SUCCEEDED **` (24.587 seconds). Full output: 1060 `/tmp/crossmate-phase6-tests.log`. 1061 1062 ### Handoff to Phase 7 1063 1064 Carry M9 and M10 into release readiness: add the missing mutation and 1065 accessibility UI coverage before treating revocation and reveal safeguards as 1066 verified. Phase 7 should also account for the UI-level coverage gaps above and 1067 retain M8 plus H1–H5 and M1–M3, M5, and M7–M8 as the cross-process issues that 1068 require full-suite and operational validation. 1069 1070 ## Phase 7 — Operational Readiness and Test Gaps 1071 1072 **Status at audit completion:** Complete, with follow-up required for M11 and 1073 the inherited cross-phase findings. 1074 1075 ### Scope reviewed 1076 1077 `project.yml`; the app and notification-service entitlements and Info.plists; 1078 the build, unit-test, release/upload, simulator, secret-template, and Wrangler 1079 wrapper scripts; all three Wrangler configurations; worker logging and test 1080 infrastructure; generated-project consistency; and test coverage across the 1081 preceding six phases. `Crossmake/` was excluded. 1082 1083 ### Invariants checked 1084 1085 - Generated project inputs, signing entitlements, build settings, and the 1086 exported release artifact remain mutually consistent. 1087 - Debug and production APNs/realtime services cannot silently diverge, and a 1088 release attempt neither retains credentials nor damages the caller's signing 1089 environment. 1090 - Worker deployment inputs keep secrets out of version control and have a 1091 deterministic, testable configuration. 1092 - The unit and worker suites exercise the app/extension/worker contracts on 1093 which the earlier phases rely, with remaining real-service boundaries stated 1094 explicitly. 1095 1096 ### Findings 1097 1098 #### M11 — iOS release signing cleanup can retain identities and clobber the caller's keychain configuration 1099 1100 `publish-ios.sh` creates, unlocks, and imports the development and 1101 distribution identities into `.asc/build.keychain-db` before it installs its 1102 `EXIT` trap (`Scripts/publish-ios.sh:84`, `Scripts/publish-ios.sh:89`, 1103 `Scripts/publish-ios.sh:105`). With `set -e`, a missing/corrupt certificate, a 1104 failed import, or a failed partition-list update exits before 1105 `cleanup_keychain` runs, leaving the temporary keychain and whichever signing 1106 identity was already imported on disk. 1107 1108 Even on success, the script replaces the user's keychain search list with the 1109 temporary keychain plus login, then restores only login and resets the default 1110 keychain to login without first saving either prior value 1111 (`Scripts/publish-ios.sh:95`, `Scripts/publish-ios.sh:97`). A developer who 1112 normally uses additional keychains therefore loses those from the search list 1113 after publishing. This makes signing cleanup both credential- retention-prone 1114 on failure and destructive to unrelated local signing setup. 1115 1116 Install a cleanup trap before creating the keychain; capture the complete 1117 preexisting search list and default keychain, then restore those exact values 1118 on every normal/error exit. Ensure cleanup tolerates a partially created 1119 keychain. Add shell-level tests with a stubbed `security` command that fail at 1120 each setup step and assert no temporary keychain or caller-state change 1121 remains. 1122 1123 ### Verified non-findings 1124 1125 - `APS_ENVIRONMENT` remains the shared build setting for the app entitlement 1126 and the value reported to `PushClient`; Debug maps to development and 1127 Release to production (`project.yml:104`, 1128 `Crossmate/Crossmate.entitlements:5`, 1129 `Crossmate/Services/PushClient.swift:124`). 1130 - The release script requires an HTTPS push origin and production APNs value 1131 in the exported IPA, and emits the signed app entitlements for inspection 1132 (`Scripts/publish-ios.sh:160`). 1133 - The checked-in worker configuration contains only public identifiers and 1134 Durable Object bindings; its App Attest root and APNs private key are 1135 runtime secrets. `.asc/`, `Generated/`, `cookies.txt`, `private_keys/`, and 1136 Wrangler local state are gitignored (`.gitignore:1`, 1137 `Workers/wrangler.push.toml:7`). 1138 - `xcodegen generate` regenerated the project successfully and produced no 1139 tracked diff. Shell syntax checks and `node --check` passed for every 1140 repository script and worker. 1141 - Unit suites that mutate shared defaults/storage declare targeted 1142 serialization or the project-provided isolation trait; the full randomized, 1143 parallel scheme passed in this phase. The one historically shared friend-key 1144 suite is explicitly serialized 1145 (`Tests/Unit/Sync/EngagementMessageAuthenticatorTests.swift:13`). 1146 1147 ### Residual risks and test gaps 1148 1149 - The worker suite has 17 tests, all focused on rate-limit/configuration 1150 logic. It does not execute the push worker's App Attest verification, 1151 registration, unregister, credential, publish/APNs paths, the room 1152 WebSocket/register path, or the link worker at all; its Durable Object 1153 storage is a narrow in-memory stub. The real Cloudflare runtime coverage gap 1154 therefore remains. 1155 - There is no committed CI workflow, dependency lockfile, Node-version policy, 1156 or pinned Wrangler version. The wrapper scripts execute whichever global 1157 `wrangler` is installed, so deployment parsing/behavior is not reproducible 1158 from the repository alone. Pin the worker toolchain and add CI that runs 1159 project generation, worker tests, syntax checks, and the iOS unit suite. 1160 - No test drives a generated signed IPA, validates the notification-extension 1161 entitlement/artifact, deploys a Worker preview, verifies a Cloudflare 1162 binding or migration, or performs App Attest/APNs/CloudKit work against real 1163 services. These are required release-validation steps. 1164 - The Phase 5/6 lifecycle, Notification Service Extension, VoiceOver, Dynamic 1165 Type, iPad layout, and multi-device/cross-process scenarios still lack UI or 1166 integration coverage. M9 and M10 should not be marked verified from the 1167 current string/model-level tests. 1168 1169 ### Checks run 1170 1171 - Read all completed Phase 1–6 sections of this report, the named historical 1172 notes and `DEFERRED.md`, and mapped all non-`Crossmake` changes since 1173 2026-07-01, with focus on the release, generated-project, worker, and test 1174 infrastructure changes. 1175 - Static cross-check of generated project input/output, entitlements, release 1176 settings, secret handling, worker configuration/logging, and test isolation 1177 against app/extension/worker contracts. 1178 - `xcodegen generate`: passed; no tracked diff. 1179 - `bash -n Scripts/*.sh`: passed. 1180 - `node --check Workers/push-worker.js`, `Workers/room-worker.js`, and 1181 `Workers/link-worker.js`: passed. 1182 - `bash Scripts/test-workers.sh`: passed — 17 tests. 1183 - `bash Scripts/test-unit.sh` (with simulator access): passed — 1184 `** TEST SUCCEEDED **` (27.913 seconds). Full output: 1185 `/tmp/crossmate-phase7-tests.log`. 1186 1187 ### Handoff / current audit status 1188 1189 All seven planned review phases are complete. Subsequent remediation closed 1190 every finding reported here. The broader worker, integration, real-device, and 1191 performance validation summarised in the post-audit status above remains 1192 outstanding. The original audit itself did not change production code.