crossmate

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

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.