crossmate

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

commit 13dce46323fb1c2dada435137ed33c9b519def77
parent 3ea660b0653a5101ee1e488f0433cad3d0cbc5f9
Author: Michael Camilleri <[email protected]>
Date:   Sun,  2 Aug 2026 11:33:07 +0900

Add notes about the regression audit

Diffstat:
ANotes/RegressionAudit.md | 1192+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 1192 insertions(+), 0 deletions(-)

diff --git a/Notes/RegressionAudit.md b/Notes/RegressionAudit.md @@ -0,0 +1,1192 @@ +# Crossmate Regression Audit + +## Scope and method + +This is a phased regression audit of Crossmate, excluding `Crossmake/`. It +reviews implementation and corresponding tests together, with particular +attention to data crossing CloudKit, Core Data, and process boundaries. The +historical reviews in `Notes/DesignFlaws.md`, `Notes/ServerSecurity.md`, +`Notes/ViewStructure.md`, and `DEFERRED.md`, plus changes since 2026-07-01, +are review context rather than findings in this report. + +`DEFERRED.md`, and the `TODO.md` that tracked remediation, are working +documents kept outside this repository; they are named below to record what +each phase read, not as links to committed files. Deferred items are cited by +their D-numbers so the reasoning stands on its own here. + +## Post-audit remediation status + +Updated 2026-07-19 from the post-audit remediation work. The phase sections +below preserve what the audit found at review time; statements there that a +finding is outstanding are historical. Every finding reported below has since +been implemented and verified. + +The completed remediation binds CloudKit and realtime input to the correct +game and author, makes push membership credentials revocable, validates remote +coordinates, and bounds puzzle, replay/archive, realtime, and push ingress. It +also gates mutations after revocation, routes VoiceOver reveal through the +standard confirmation, preserves the caller's keychain configuration during a +release attempt, and buffers early APNs callbacks and push wakes across +serialized app startup. What remains is broader validation and infrastructure +follow-up rather than an unresolved numbered finding from this audit: worker +runtime coverage for the App Attest, registration, publish, and room-socket +paths; a pinned worker toolchain and CI; real-device validation against App +Attest, APNs, and CloudKit; and integration/UI coverage for the lifecycle, +notification-extension, accessibility, and multi-device scenarios. Each is +described in the residual-risk sections of the phases that found it. + +## Review protocol and execution record + +This audit was a review rather than a refactor: it did not change production +code. `Crossmake/`, the standalone puzzle-authoring Swift package, was out of +scope. Existing findings in the historical review notes and `DEFERRED.md` were +treated as context and re-checked for regression, not repeated automatically +as findings. + +Each phase reviewed implementation and its corresponding tests together. The +review traced data across CloudKit records, Core Data, notifications, worker +HTTP and WebSocket requests, app-group storage, and imported puzzle files. A +completed phase records its reviewed scope, checked invariants, findings and +non-findings, checks run, residual test gaps, and handoff risks below. +Findings remain in the phase where they were discovered and are +cross-referenced when a later phase owns follow-up. + +Before each phase, the review read the completed portions of this report, the +relevant historical notes and `DEFERRED.md`, the changes since 2026-07-01 +outside `Crossmake/`, and the phase's applicable source files and tests. Each +phase mapped relevant recent changes first, then traced implementation and +tests through their external boundaries. Targeted checks ran during the +applicable phase where practical; the full unit and worker suites ran again in +the final operational-readiness phase. + +The phases ran in this order: + +1. Cloud state, persistence, and migration. +2. Collaboration, sharing, and realtime engagement. +3. Push and notification pipeline. +4. Puzzle content boundary. +5. Application lifecycle and workflow integration. +6. Gameplay, accessibility, and UI state. +7. Operational readiness and test gaps. + +The handoff at the end of each phase was the durable source of truth for the +next phase. Where a discovery changed a later phase's risk or scope, it was +carried forward through the cross-phase follow-up list rather than assumed +away. + +## Cross-phase follow-up at audit completion + +- **H1:** Bind every inbound game-scoped CloudKit record to its per-game zone + and authenticate its claimed author. This is discovered in Phase 1 and must + be re-verified in Phase 2's membership/authorization review. +- **H2:** Validate remote grid and journal coordinates before conversion to + Core Data's `Int16` fields. +- **M1:** Bound Journal and Archive asset sizes and decoded entry counts + before loading them into memory. +- **H3:** Bind authenticated realtime frames to the game associated with their + active engagement channel before applying them to local Moves state. +- **M2:** Bound realtime WebSocket frame sizes and decoded batch sizes before + parsing, authenticating, and applying them. +- **H4:** Require proof of a current game credential when registering a game + push address, and rotate that credential when a participant loses access. +- **M3:** Bound worker request bodies, address lists, opaque payloads, and + per-credential fanout before JSON parsing, Durable Object scans, or APNs + sends. +- **M8:** Buffer/replay APNs registration callbacks and coalesce silent-push + work received before AppServices has installed its app-delegate handlers. +- **H5:** Bound XD grid geometry, clue count, and aggregate markup work before + parser/Puzzle construction; the current source-byte limit still admits + main-actor quadratic work. +- **M5:** Apply a streaming, byte-capped read to every local/iCloud `.xd` and + `.puz` ingress path, and bound NYT response bodies before JSON conversion. +- **M7:** Require the NYT account-page GraphQL endpoint to be HTTPS and an + approved NYT host before forwarding the session-cookie header to it. +- **M9:** Make access revocation a mutation gate, not merely a sync-emission + gate, so a revoked open puzzle cannot show or persist locally fabricated + progress/completion. +- **M10:** Route the VoiceOver Reveal Square action through the same explicit + confirmation used by the visual menu and keyboard-command surfaces. +- **M11:** Make iOS release signing-key cleanup failure-safe and restore the + caller's exact keychain search/default state after a release attempt. + +## Phase 1 — Cloud State, Persistence, and Migration + +**Status at audit completion:** Complete, with follow-up required for H1–M1. + +### Scope reviewed + +`Crossmate/Sync/` (including SyncEngine, CloudQuery, RecordSerializer, +RecordApplier, RecordBuilder, Moves, GridStateMerger, archive, zones, journal +upload, presence/read-state, and diagnostics), `Crossmate/Persistence/`, the +Core Data model at +`Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents`, +`cloudkit.ckdb`, and the corresponding unit tests in `Tests/Unit/`. + +### Invariants checked + +- CloudKit records remain confined to their game/zone and their claimed + author; retry, fetched snapshots, and system-field conflict recovery + converge. +- Moves, player state, journal replay/upload, deletion, archive, and presence + paths are idempotent and preserve durable data through retries/revocation. +- Store recovery preserves a failed on-disk store before rebuilding; model + renames and indexes support the current data shape. +- A fresh/restored device can reconstruct durable game, player, and replay + state without accepting malformed external data. + +### Findings + +#### H1 — Inbound record identity is not bound to its CloudKit zone or creator + +`SyncEngine` dispatches fetched Game, Moves, Player, Journal, and deletion +records based on their mutable record names without first requiring the +encoded game ID to match the record's `game-<UUID>` zone +(`Crossmate/Sync/SyncEngine.swift:1480`, +`Crossmate/Sync/RecordApplier.swift:72`). The apply paths then identify local +games and subordinate records by `ckRecordName` alone, not by the full +CloudKit identity (zone plus record name) +(`Crossmate/Sync/RecordSerializer.swift:780`, +`Crossmate/Sync/RecordSerializer.swift:809`, +`Crossmate/Sync/RecordSerializer.swift:987`). Deletion uses the same name-only +lookup (`Crossmate/Sync/RecordApplier.swift:513`). + +A participant who can create records in one writable shared zone can create a +Game, Moves, Player, Ping, or Journal record whose name embeds another game or +another participant. In particular, a forged `game-<victim>` Game record in a +different accessible zone matches the victim's local row and replaces its zone +identity, system fields, metadata, credentials, and potentially puzzle source. +Forged Moves/Player records can likewise affect another game's local state, +and a same-zone fabricated `(authorID, deviceID)` record is accepted without +checking CloudKit creator identity. A forged deletion can remove a local row +with the same record name. This violates scope isolation and the Phase 2 rule +that a participant affects only their own authored state. + +Require each game-scoped record's parsed game ID to equal the per-game zone +UUID before dispatching it; require the root Game record name to equal that +zone name; persist/look up the complete zone identity; and verify claimed +author IDs against CloudKit creator identity (or replace the writable record +scheme with authenticated per-author payloads). Apply equivalent provenance +checks to Archive records before materialization. Add hostile cross-zone, +same-zone author-forgery, and forged-deletion tests to both fetch paths. + +#### H2 — Malformed remote coordinates can crash cache and replay persistence + +Moves and Journal decoders accept arbitrary `Int` row and column values +(`Crossmate/Sync/Moves.swift:99`, `Crossmate/Persistence/Journal.swift:567`). +The fetched Moves path later converts those values with trapping `Int16` +initializers while rebuilding the cell cache +(`Crossmate/Sync/RecordApplier.swift:434`). Remote journals reach the same +conversion when a completed replay is cached +(`Crossmate/Persistence/Journal.swift:462`). + +A writable collaborator can submit, for example, `row: 32768`; decoding +succeeds, then the inbound cache replay traps while assigning +`CellEntity.row`. This is a persistent remote denial of service because each +fetch replays the record. Validate representability and non-negative/grid +bounds before storing or replaying any remote cell/journal entry; reject the +whole malformed record or skip invalid entries with a diagnostic. Tests cover +normal and legacy codecs but not out-of-range coordinates or a malformed +record reaching either sink. + +#### M1 — Replay and archive assets have no size or entry-count limits + +`fetchReplay` reads each peer-controlled Journal asset in full before decoding +(`Crossmate/Sync/CloudQuery.swift:1098`). Archive payload decoding likewise +reads its puzzle, cells, and journal assets without a file-size gate +(`Crossmate/Sync/Archive.swift:350`). Unlike Game `puzzleSource`, these paths +do not enforce a byte limit, maximum entry count, or decoded-string budget. A +collaborator can make a finished-game replay consume excessive memory/CPU; H1 +also lets a friend-zone Archive reach the materializer without an archive-zone +provenance check. + +Use conservative file-size limits before +`Data(contentsOf:)`/`String(contentsOf:)`, bound decoded journal/cell counts +and string lengths, and report rejection. Cover oversized and count-exhaustion +assets in the replay/archive tests. + +### Verified non-findings + +- Account push address and secret Decisions now require the private `account` + zone, closing the historical friend-zone injection issue + (`Crossmate/Sync/RecordSerializer.swift:226`). +- Archive promotion does not delete a revoked original unless materialization + produced its replacement (`Crossmate/Sync/GameArchiver.swift:221`). +- Game puzzle-source assets are size-gated before decoding + (`Crossmate/Sync/RecordSerializer.swift:919`); this protection is missing + only from M1's other asset paths. +- Current fetched database-zone deletions use the common orphan cleanup path, + and equal-timestamp inbound Moves values retain the existing cell state. +- Core Data recovery enables automatic lightweight migration, preserves failed + store/WAL/SHM files before rebuild, and has a regression test; current model + attribute renames have `renamingIdentifier`s and the frequently used game + and record-name indexes are present. +- The historical synchronous hot-path findings have been addressed in the main + SyncEngine, MovesUpdater, and PlayerSelectionPublisher paths; remaining + `performAndWait` calls reviewed here are synchronous utility accessors. + +### Residual risks and test gaps + +- No integration test exercises a fresh/restored device through real CloudKit + zone pagination, conflict recovery, migration, and archive restoration. + +### Checks run + +- Mapped all changes since 2026-07-01 outside `Crossmake/`, including the + follow-up fixes for historical sync, persistence, archive, migration, and + model-index findings. +- Static review and targeted source/test cross-checks for record parsing, + apply/delete paths, replay/archive assets, conflict/retry handling, model + migration/recovery, and schema parity. +- `bash Scripts/test-unit.sh` (after granting simulator access): passed — + `** TEST SUCCEEDED **` (28.089 seconds). Full output: + `/tmp/crossmate-phase1-tests.log`. + +### Handoff to Phase 2 + +Treat H1 as an open authorization prerequisite: Phase 2 must review whether +CloudKit ACLs, record creation metadata, invitations, roster state, and +realtime authentication can establish per-author ownership. Do not assume the +record-name convention supplies that guarantee. + +## Phase 2 — Collaboration, Sharing, and Realtime Engagement + +**Status at audit completion:** Complete, with follow-up required for H1–H3 +and M2. + +### Scope reviewed + +Friend mailbox bootstrap and lifecycle in +`Crossmate/Sync/FriendController.swift` and `FriendZone.swift`; direct/public +`CKShare` lifecycle in `Crossmate/Sync/ShareController.swift`; invitations in +`Crossmate/Services/InviteCoordinator.swift` and `CloudService.swift`; player +roster/presence in `Crossmate/Models/PlayerRoster.swift` and +`EngagementStore.swift`; engagement transport/authentication/lifecycle in +`Crossmate/Services/EngagementHost.swift`, `EngagementLifecycle.swift`, +`Crossmate/Sync/EngagementCoordinator.swift`, and +`EngagementMessageAuthenticator.swift`; `Workers/room-worker.js`; and the +corresponding friend, sharing, engagement, roster, GameStore, and worker +tests. + +### Invariants checked + +- Friendship bootstrap accepts only the deterministic pairwise mailbox owned + by the claimed friend; inbound direct invite/decline Pings are tied to the + writer's private-scope friend zone. +- Share owners alone manage direct participants; public-link seats converge + through the optimistic ticket record, and a participant leave removes local + state and records an account-wide leave decision. +- A realtime sender can mutate only state belonging to the game/room in which + it is authorized, and only under its own author identity; loss or failure of + the live channel falls back safely to CloudKit Moves sync. +- Roster/presence and engagement reconnect/lease behavior remain coherent + across reconnects, foreground/background changes, and same-account devices. + +### Findings + +#### H3 — Authenticated realtime frames are not bound to their engagement game + +`EngagementLifecycle` decodes an inbound frame and authenticates it only with +the sender's pairwise friend key; it never resolves the `engagementID` to its +associated game and compares that game with the frame's payload +(`Crossmate/Services/EngagementLifecycle.swift:333`, +`Crossmate/Services/EngagementLifecycle.swift:358`, +`Crossmate/Services/EngagementLifecycle.swift:379`). Although the MAC +canonical form includes the attacker-chosen `gameID`, it does not include a +room/channel identity +(`Crossmate/Sync/EngagementMessageAuthenticator.swift:33`, +`Crossmate/Sync/EngagementMessageAuthenticator.swift:146`). + +Consequently, a participant who shares any live room with the victim and has +the normal friend-channel key can sign a frame for a different game ID. The +recipient accepts the valid tag, then `GameStore` groups, finds, and updates +the arbitrary payload game without requiring it to be the channel's game or +that its sender is a current participant +(`Crossmate/Persistence/GameStore.swift:913`, +`Crossmate/Persistence/GameStore.swift:930`, +`Crossmate/Persistence/GameStore.swift:938`). A former co-player or any +current co-player who knows another local game UUID (for example from a prior +invitation) can therefore alter its own device row in that unrelated game on +every victim device currently connected to the first room. The edit is not +re-uploaded as the victim's move, but it is written to the durable local +cache; its clamped future timestamp can keep the wrong cell visible until a +genuine newer cell write arrives. Selection frames have the same missing +channel/game binding, allowing a cross-game cursor injection. + +Derive the expected game ID from `engagementID` in the coordinator and reject +single edits, every batch element, and selections whose game ID differs before +MAC verification or store application. Also require the verified sender to be +in that game's authoritative current roster once H1's CloudKit provenance +issue is fixed. Add tests for a correctly MACed cross-game edit, a mixed-game +batch, and a cross-game selection; each must leave the unrelated store and +engagement selection state unchanged. + +#### M2 — Realtime messages and edit batches have no application-level bounds + +The room worker relays every WebSocket message verbatim +(`Workers/room-worker.js:242`). The client immediately materializes the +complete message with `JSONDecoder` before authenticity is checked +(`Crossmate/Services/EngagementHost.swift:99`, +`Crossmate/Services/EngagementLifecycle.swift:333`), then canonicalizes every +batch entry and applies it in one unbounded grouping pass +(`Crossmate/Sync/EngagementMessageAuthenticator.swift:93`, +`Crossmate/Persistence/GameStore.swift:920`). + +An authenticated collaborator can send a large valid frame or a very large +batch addressed to a peer. The receiver allocates/decodes it before rejecting +anything and may then run a large Core Data mutation on the main actor. The +worker's registration throttle does not bound in-room traffic, and the tests +exercise only registration limiting rather than socket frame limits. This is a +resource-exhaustion path on the live channel; durable Moves synchronization is +not a fallback while the receiver is busy decoding or saving the frame. + +Set a conservative encoded-frame limit in the worker and client before JSON +decoding, cap the number of edits and strings per batch, and reject invalid or +out-of-grid positions before storage. Add worker and client tests for an +oversized frame, an over-count batch, and a valid boundary-size batch. + +### Verified non-findings + +- Friend-zone bootstrap validates both the deterministic pair-key zone name + and CloudKit-provided owner name before accepting a share + (`Crossmate/Sync/FriendController.swift:203`). The historical borrowed-zone + attack is closed; pairwise name/encryption-key Decisions also require + private-scope delivery. +- Direct invite and decline Pings authenticate their claimed author from the + private friend-zone source metadata, so one friend cannot impersonate + another to create an invite or free that person's seat + (`Crossmate/Services/InviteCoordinator.swift:202`, + `Crossmate/Services/InviteCoordinator.swift:767`). +- Direct participant additions reassert the in-session intended invitee set + across eventually-consistent share reads, conflict recovery preserves that + set, and public-link tickets use optimistic save retries + (`Crossmate/Sync/ShareController.swift:171`, + `Crossmate/Sync/ShareController.swift:360`, + `Crossmate/Sync/ShareController.swift:906`). +- The worker keeps the room secret out of connect URLs, requires a registered + secret-backed HMAC with freshness and single-use nonce checks, supersedes a + stale same-device socket, and rate-limits new room registration + (`Crossmate/Services/EngagementHost.swift:130`, + `Workers/room-worker.js:196`, `Workers/room-worker.js:336`). +- End-to-end per-recipient frame MACs now prevent a room member from writing a + different author's Moves record; future timestamps are clamped before the + last-writer-wins merge + (`Crossmate/Sync/EngagementMessageAuthenticator.swift:171`, + `Crossmate/Persistence/GameStore.swift:913`). Same-account devices + intentionally lack a pairwise friend key and therefore fall back to durable + Moves sync, as recorded in D3. +- `PlayerRoster` is an `@MainActor` `@Observable` model; it fetches Core Data + asynchronously, generation-gates stale refreshes, and only assigns its + Equatable entries when they actually change + (`Crossmate/Models/PlayerRoster.swift:8`, + `Crossmate/Models/PlayerRoster.swift:252`, + `Crossmate/Models/PlayerRoster.swift:426`). + +### Residual risks and test gaps + +- No integration test simulates two iCloud accounts/devices through friend + bootstrap healing, direct invite acceptance/decline, share conflict retries, + public-link concurrent joins, room expiry/re-registration, leave/revocation, + or the same-account durable-only fallback. The unit tests cover the helpers + and state machines but not their real CloudKit/worker composition. + +### Checks run + +- Read the completed Phase 1 record, all named historical audit notes and + `DEFERRED.md`; mapped every non-`Crossmake` commit since 2026-07-01, with + focused review of friendship, invite, share, roster, presence, engagement, + and room-worker changes. +- Static implementation/test cross-check of CloudKit source-zone/scope gates, + share and ticket conflict handling, leave/revocation behavior, websocket + registration/connect authentication, MAC canonicalization, reconnect/lease + ownership, roster data flow, and realtime store application. +- `bash Scripts/test-workers.sh`: passed — 17 tests. +- `bash Scripts/test-unit.sh` (after granting simulator access): passed — + `** TEST SUCCEEDED **` (27.797 seconds). Full output: + `/tmp/crossmate-phase2-tests.log`. + +### Handoff to Phase 3 + +Phase 3 should treat H3/M2 as active realtime-to-push boundary work: any push +or notification side effect must not turn a spoofed or oversized live frame +into durable state. Preserve the account/game credential provenance checks +while reviewing worker request authorization. H1 and H2 remain prerequisites +for trusting any CloudKit-derived game, player, or coordinate identity; H2 now +also requires a realtime ingress test. + +## Phase 3 — Push and Notification Pipeline + +**Status at audit completion:** Complete, with follow-up required for H1–H4 +and M3. + +### Scope reviewed + +`Crossmate/Services/PushClient.swift`, `PushRequestAuthenticator.swift`, +`AccountPushCoordinator.swift`, `SessionPushPlanner.swift`, and +`BadgeCoordinator.swift`; `Crossmate/Sync/GamePushCredentials.swift`; the push +delivery and App Group code in `Shared/` (including `PushPayload`, +`PushPayloadCipher`, `NotificationState`, content/friend-key directories, and +notification text); `NotificationService/NotificationService.swift`; app +delegate push routing; `Workers/push-worker.js` and `wrangler.push.toml`; push +and notification entitlements/target wiring; and the corresponding unit and +worker tests. + +### Invariants checked + +- An installation proves App Attest possession for protected worker requests; + request bodies, paths, timestamps, and nonces are bound to its assertion, + while a game push additionally proves the current game credential. +- A device can register or remove only its own address binding, and a departed + or revoked game participant cannot retain delivery or publish access. +- The worker, app, and notification extension agree on encrypted payload, + generic-alert, badge, coalescing, and background-push semantics; malformed + data fails without mutating game state. +- Push data and diagnostics do not expose personal notification text, puzzle + names, keys, or other capabilities beyond their intended local recipient. + +### Findings + +#### H4 — Game push registrations do not establish current participation or revoke departed access + +`PushRegistry.handleRegister` accepts every `{ address, credID }` supplied by +any App-Attest-enrolled installation and stores it under that credential +(`Workers/push-worker.js:345`, `Workers/push-worker.js:371`). Unlike +`/publish`, it neither requires a game HMAC nor even confirms that +`gamecred:<credID>` exists. `PushClient` batches all game bindings into this +App-Attest-only `/register` request +(`Crossmate/Services/PushClient.swift:462`). The game credential is +deliberately durable and has no expiry +(`Crossmate/Sync/GamePushCredentials.swift:26`), and the departure handling +only asks the honest local client to unregister +(`Crossmate/Services/PushClient.swift:487`); the worker has no +membership/revocation state and cannot remove a stale or malicious device +binding. + +Consequently, a former participant who cached a game's `credID` can bind its +own APNs token to that room without possessing the secret and receive later +broadcast pushes. A former participant who also cached the normal shared +credential can additionally keep publishing until credentials are rotated. The +same registration gap lets anyone who learns a `credID` subscribe before they +have the game secret. Address derivation and device-ID matching prevent one +installation from deleting another installation's key, but they do not make a +subscription an authorized current membership. This violates the +departure/revocation invariant and makes H1 more urgent: Phase 1 can currently +let a forged Game record replace the credentials a device trusts. + +Bind each game-address registration to a valid HMAC for that credential (for +example, register each credential/binding independently), reject unknown +credential IDs, and rotate `credID`, worker secret, and content key on leave +or revocation. The remaining participants must publish/register the +replacement; the worker should retain no acceptance path for the old +credential. Add worker tests proving an App-Attest-only game binding and a +stale credential are rejected, while an authorized replacement credential +delivers only to current bindings. + +#### M3 — Worker ingress, address fanout, and opaque notification payloads are unbounded + +The worker materializes every request body before authentication or JSON +validation (`Workers/push-worker.js:31`), then accepts arbitrary-length +`addresses`, `mutedKinds`, address strings, titles, bodies, collapse IDs, and +encrypted/legacy opaque payloads. A non-broadcast publish only requires a +non-empty array (`Workers/push-worker.js:430`); it scans storage for every +submitted address (`Workers/push-worker.js:672`), and a broadcast scans and +sends to every credential-scoped registration without a target limit +(`Workers/push-worker.js:714`, `Workers/push-worker.js:484`). The notification +extension likewise base64-decodes and AES-GCM-opens an unbounded `enc` value +before decoding it (`NotificationService/NotificationService.swift:52`, +`Shared/PushPayloadCipher.swift:44`). + +An authenticated participant can therefore make a single valid request consume +unbounded worker memory/CPU/storage scans and sequential APNs work; the +current per-credential rate limit restricts request frequency, not request or +fanout size. Oversized messages also exceed APNs' payload limit only after +this work, causing delivery failure rather than a safe, cheap rejection. H4 +permits an attacker who knows a credential ID to inflate the room's +registration set, worsening broadcast work. + +Set conservative byte limits before reading request bodies and before +base64/AES decoding; validate string and ID formats; cap bindings, muted +kinds, explicit recipients, registrations per credential, and broadcast +targets; reject APNs payloads that would exceed the supported size. Cover +exact-boundary success and oversized body, recipient list, ciphertext, and +fanout failures in the worker and shared-payload tests. + +### Verified non-findings + +- The historical embedded bearer has not returned: protected registration, + credential registration, and publish calls require an App Attest assertion; + its canonical request binds method, path, body hash, timestamp, nonce, + device ID, and key ID + (`Crossmate/Services/PushRequestAuthenticator.swift:159`, + `Workers/push-worker.js:71`). Worker nonce/timestamp checks make a captured + assertion replay fail during its validity window + (`Workers/push-worker.js:103`). +- Game publishes do verify a credential HMAC and resolve only the + corresponding credential-scoped address keys (`Workers/push-worker.js:463`, + `Workers/push-worker.js:646`). The gap is address registration/revocation + (H4), not request-body swapping or ordinary cross-credential publish + routing. +- Current game notifications send a generic cleartext alert and seal personal + structured payload fields with a content key that is not sent to the worker + (`Crossmate/Services/PushClient.swift:313`, + `Crossmate/Sync/GamePushCredentials.swift:20`). Worker authentication logs + record lengths/statuses rather than request contents + (`Workers/push-worker.js:34`). +- The APNs environment derives from one build setting used by both entitlement + and client configuration, and the extension shares only the expected App + Group entitlement (`Crossmate/Crossmate.entitlements`, `project.yml:104`). +- Badge horizons distinguish monotonic read state from collapsible presence + suppression, and the notification extension uses decrypted structured + payload semantics before marking a game unread + (`Shared/NotificationState.swift:302`, + `NotificationService/NotificationService.swift:122`). + +### Residual risks and test gaps + +- The worker tests cover rate-limit helpers only; they do not exercise actual + App Attest verification, register/unregister authorization, credential + registration, publish routing, APNs-payload construction, or any H4/M3 + rejection. Unit tests exercise payload and ledger helpers, but there is no + Notification Service Extension integration test for decrypt/rewrite, + coalescing, badge, expiry, or App Group contention. Real-device validation + is still needed for App Attest, APNs payload limits, a fresh install, + multi-device badge/read-state convergence, and leave/revocation credential + rotation. + +### Checks run + +- Read the completed Phase 1–2 record, all named historical notes and + `DEFERRED.md`, and mapped the non-`Crossmake` change set since 2026-07-01, + including App Attest, credential-gated push, payload encryption, address + unregister, rate-limit sweep, and notification-text changes. +- Static implementation/test cross-check of App Attest enrollment/assertion + canonicalization, nonce/timestamp/counter behavior, game credential and + address binding, registration/removal, APNs construction, encrypted payload + compatibility, App Group directories, badge/read horizons, coalescing, + account-control pushes, entitlements, and diagnostics logging. +- `node --check Workers/push-worker.js`: passed. +- `bash Scripts/test-workers.sh`: passed — 17 tests. +- `bash Scripts/test-unit.sh` (after granting simulator access): passed — + `** TEST SUCCEEDED **` (27.249 seconds). Full output: + `/tmp/crossmate-phase3-tests.log`. + +### Handoff to Phase 4 + +H1 remains an authorization prerequisite for all CloudKit-derived Game fields, +including push credentials and content keys. H4 must be designed with the +Phase 2 leave/revocation model: client-side unregister is a hygiene step, not +an access-control boundary. Phase 5 should retain the push-control rule that +an advisory notification must not be the sole authority for durable game +state. + +## Phase 4 — Puzzle Content Boundary + +**Status at audit completion:** Complete, with follow-up required for H5, M5, +and M7. + +### Scope reviewed + +The XD parser, metadata/rebus/special/markup handling, normalized `Puzzle` +construction, catalog/source model, bundled and debug puzzle resources; `.puz` +and NYT-JSON converters; NYT fetch/auth/upgrade paths; local URL, +security-scoped and iCloud Drive import paths; and the corresponding XD, +markup, converter, upgrader, auth, catalog, invitation, and serializer tests. +`Crossmake/` was excluded. + +### Invariants checked + +- A local, shared, or remote puzzle fails closed before it can exhaust memory + or CPU, and its declared geometry remains usable by persistence and + gameplay. +- XD, PUZ, and NYT content produce a stable, playable XD representation, + including rebuses, special cells, formatted clues, accepted alternatives, + and legacy/current converter headers. +- A converter upgrade never replaces an in-progress game's answer semantics + unless existing moves remain valid; malformed/newer content leaves the + durable source recoverable. +- Network responses and credentials are bounded and validated before use; a + NYT session cookie cannot be disclosed to an endpoint named by response + data. + +### Findings + +#### H5 — The XD byte cap still admits quadratic main-actor parser work + +`XD.parse` limits only the UTF-8 byte count, then accepts arbitrary grid +width, height, clue count, and header cardinality +(`Crossmate/Models/XD.swift:199`, `Crossmate/Models/XD.swift:537`, +`Crossmate/Models/XD.swift:607`). For every grid position, +`positionsOutsideExactCellAnswers` re-discovers the whole containing word +before deciding whether an `Accept` projection exists +(`Crossmate/Models/XD.swift:905`, `Crossmate/Models/XD.swift:948`, +`Crossmate/Models/XD.swift:1118`). A syntactically valid source with one very +long all-open row, fixed letters, and a single answer-less clue stays under +`maxSourceBytes` yet makes that pass walk the same word from every cell: +quadratic work before a game is created. This reaches the main actor through +imported-game creation and fast invite acceptance +(`Crossmate/Persistence/GameStore.swift:1201`, +`Crossmate/Persistence/GameStore.swift:1237`). + +The per-clue limit does not bound aggregate work either. `Puzzle.init` renders +each clue twice through `XDMarkup` (`Crossmate/Models/Puzzle.swift:157`); +every unterminated markup opener scans to the end of its clue +(`Crossmate/Models/XDMarkup.swift:54`, `Crossmate/Models/XDMarkup.swift:91`). +Hundreds of legal 1,024-character malformed clues can therefore cause hundreds +of millions of character inspections within the 262-KB source cap. A shared +Game asset or invitation can freeze the accept/open flow, violating the +content availability invariant despite the prior segmentation memoization fix. + +Enforce conservative maximum width, height, cell count, clue count, and +aggregate clue/metadata budgets before allocating grids. Build a word index +once rather than walking each word per cell, and make markup linear (or bound +the total number of unmatched-open scans). Add adversarial boundary tests for +a long thin grid, maximum legitimate geometry, many answer-less clues, and +many unterminated markup spans. + +#### M5 — iCloud/local import and NYT download paths materialize unbounded data + +The direct open-URL importer generally checks `fileSizeKey`, but explicitly +falls through when that value is unavailable and then uses +`String(contentsOf:)`/`Data(contentsOf:)` +(`Crossmate/Services/ImportService.swift:23`, +`Crossmate/Services/ImportService.swift:35`). The primary Imported-tab path +has no preflight or streaming limit at all: `DriveMonitor.readSource` +coordinates the file and reads its complete contents before XD parsing or PUZ +conversion (`Crossmate/Services/DriveMonitor.swift:85`). Its Files picker +copies a selected file into the app's iCloud container before any validation +(`Crossmate/Views/Browse/ImportedBrowseView.swift:64`, +`Crossmate/Services/DriveMonitor.swift:148`). + +The NYT fetcher has the analogous response boundary: `URLSession.data(for:)` +buffers the full response and hands it directly to JSON conversion without a +content-length/type/byte check +(`Crossmate/Services/NYTPuzzleFetcher.swift:28`, +`Crossmate/Services/NYTToXDConverter.swift:25`). The XD output cap happens +only after these allocations; a PUZ may also carry a large ignored tail or +string table. A hostile file provider, a very large file already placed in the +Crossmate iCloud folder, or an unexpected server response can thus exhaust +memory before rejection. + +Use one coordinated, streaming capped-read helper for all local/imported URLs; +cap raw PUZ independently from produced XD, and validate before copying into +iCloud. For NYT, require a successful JSON content type where supplied and +stream/cancel after a conservative byte budget. Add tests for oversize and +unknown-length URLs on both local paths, an oversize PUZ with a small XD +prefix, and oversized/non-JSON NYT responses. + +#### M7 — Response-derived NYT GraphQL URL can receive the session cookie + +After downloading account-page HTML from NYT, `NYTAuthService` extracts +`gqlUrlClient` without checking its scheme or host +(`Crossmate/Services/NYTAuthService.swift:273`). It then POSTs the full NYT +cookie header to that response-provided URL +(`Crossmate/Services/NYTAuthService.swift:201`, +`Crossmate/Services/NYTAuthService.swift:209`). A compromised/misrouted +account-page response, or future configuration error, can therefore redirect a +bearer session cookie to an arbitrary origin. The existing test verifies +extraction of a legitimate NYT endpoint but exercises no rejection path +(`Tests/Unit/NYTAuthServiceTests.swift:63`). + +Reject non-HTTPS URLs and require an exact approved host or a strict +`.nytimes.com` suffix before constructing the configuration; test an attacker +host, `nytimes.com.attacker.tld`, and a non-HTTPS URL. Keep the cookie header +scoped to the approved destination. + +### Verified non-findings + +- The historical exponential XD segmentation issue is closed: `segment` is + memoized and caps produced segmentations (`Crossmate/Models/XD.swift:828`); + its deliberately adversarial regression test completes successfully + (`Tests/Unit/XDAcceptTests.swift:563`). +- XD rejects sources over 262,144 bytes, over-long individual clue text, + ragged grids, unsupported answer characters, ambiguous projections, and + missing inferred cells. Direct imported URLs use security-scoped access and + normally preflight their size (`Crossmate/Models/XD.swift:199`, + `Crossmate/Services/ImportService.swift:16`). +- NYT conversion rejects non-positive/overflowing geometry and mismatched cell + counts before grid indexing (`Crossmate/Services/NYTToXDConverter.swift:49`, + `Crossmate/Services/NYTToXDConverter.swift:63`). Its rebus placeholder + allocation is bounded and avoids XD syntax; PUZ now deduplicates rebus + values, correctly applies GRBS's one-based table index, and preserves a + rebus over a conflicting circle marker + (`Crossmate/Services/PUZToXDConverter.swift:83`, + `Crossmate/Services/PUZToXDConverter.swift:342`). +- Legacy `CmVer` sources remain readable, current converters write `ConVer`, + and owner-only NYT upgrades parse both sources and reject ordinary grid, + block, or single-letter solution divergence + (`Crossmate/Models/XD.swift:207`, + `Crossmate/Services/NYTPuzzleUpgrader.swift:68`). +- NYT cookies use the keychain, fetch paths no longer log cookie/body/source + content, and normal HTTP status handling distinguishes expired sessions and + rate limiting (`Crossmate/Services/NYTAuthService.swift:78`, + `Crossmate/Services/NYTPuzzleFetcher.swift:28`). + +### Residual risks and test gaps + +- There are no tests for `ImportService`, `DriveMonitor`, or the Imported-tab + picker; specifically absent are security-scoped lifetime, coordinated-read + errors, File Provider URLs with no file size, iCloud copy failure/cleanup, + and size-limit behavior. +- No test parses every bundled/debug XD and checks its dimensions/block mask + against its manifest. The catalog tests establish discovery and + manifest-mask length only (`Tests/Unit/PuzzleCatalogTests.swift:8`). +- PUZ tests cover normal/circled/rebus inputs and Data slicing, but not raw + size limits, malformed extension lengths, invalid grid bytes, checksum + handling, or newline-bearing metadata/clues. +- NYT converter tests cover geometry and output round trips, but not response + content type/size, maximum strings/clues/relatives, or a full auth/fetch + integration with a controlled URL protocol. Real-device validation remains + necessary for Keychain accessibility and the NYT login redirect. + +### Checks run + +- Read all completed Phase 1–3 sections of this report; all named historical + review notes and `DEFERRED.md`; and the complete non-`Crossmake` change set + since 2026-07-01. Traced the direct file-import, iCloud, CloudKit-invite, + NYT fetch/auth/upgrade, parser, converter, persistence, and UI callers. +- Static source/test cross-check of XD sections, geometry, answer projection, + markup, rebus/special conversion, version compatibility, upgrade guards, + security-scoped access, network status/credential handling, and bundle + manifests/resources. The SwiftUI Imported-tab boundary was also checked + against the applicable SwiftUI data-flow, environment, list identity, + localization, and API guidance; no independent SwiftUI-state finding arose. +- `bash Scripts/test-unit.sh` (after granting simulator access): passed — + `** TEST SUCCEEDED **` (27.457 seconds). Full output: + `/tmp/crossmate-phase4-tests.log`. + +### Handoff to Phase 5 + +H5 is the content-boundary prerequisite for any lifecycle route that opens a +puzzle: do not add a new deep-link/import route that parses +attacker-controlled source without the same geometry and work budgets. M5's +iCloud copy/read behavior and M7's credential forwarding should be rechecked +where app/scene lifecycle schedules and cancels work. Puzzle +source-replacement semantics should be considered by any workflow that assumes +a loaded game and its cell cache remain valid across background upgrade +activity. + +## Phase 5 — Application Lifecycle and Workflow Integration + +**Status at audit completion:** Complete, with follow-up required for M8. + +### Scope reviewed + +`CrossmateApp` and its app/scene delegates and notification/share-link +brokers; `AppServices`, `AppActions`, `CloudService`, `SessionCoordinator`, +`PuzzleSession`, `InviteCoordinator`, `AccountPushCoordinator`, `PushClient`, +`ShareLinkRoute`, `DebuggingMonitors`, and `DiagnosticsReport`; the diagnostic +view and notification-service receipt boundary; and the corresponding session, +route, account-transition, push-registration, notification-navigation, log +scrubber, and announcement tests. + +### Invariants checked + +- Launch, foreground/background, open/close, and completion paths coalesce + work, cancel superseded work, preserve buffered edits, and leave a durable + retry/reconciliation path after suspension. +- Notification, universal-link, and CloudKit-share routes are validated before + navigation or share acceptance; cold-launch routes survive handler setup. +- Account switches purge only an actually different account's local cache and + rebuild CloudKit state without deleting either account's remote data. +- Support diagnostics preserve useful operational state without exporting + notification text, player names, puzzle titles, credentials, or other + private user data. + +### Findings + +#### M8 — APNs tokens and silent-push wakes can be lost before AppServices installs its delegate handlers + +`AppDelegate` starts APNs registration during `didFinishLaunching` +(`Crossmate/CrossmateApp.swift:101`), but merely calls optional closures when +registration completes or a background push arrives +(`Crossmate/CrossmateApp.swift:110`, `Crossmate/CrossmateApp.swift:218`). +Those closures are not assigned until the root view's asynchronous startup +reaches `AppServices.start` (`Crossmate/Services/AppServices.swift:781`, +`Crossmate/Services/AppServices.swift:843`). Unlike notification navigation, +CloudKit share acceptance, and universal links, which each have a broker that +buffers the cold-launch handoff, this path has no stored token or pending +remote-notification queue. + +APNs does not guarantee that the token callback or a content-available wake +will wait for a SwiftUI root-view `.task`. If either arrives in that window, +the delegate discards it and returns `.newData`. `PushClient` keeps the APNs +token only in memory and skips registration when it is absent +(`Crossmate/Services/PushClient.swift:161`, +`Crossmate/Services/PushClient.swift:210`), so this installation may not +register any game or account push address until a later token callback. A lost +silent wake also misses its intended background fetch/session scan and is +reported to iOS as new data; foreground catch-up eventually converges durable +state, but background delivery and notification reliability degrade silently. + +Retain the most recent APNs registration result and token in `AppDelegate` and +replay them when their handlers are set. Queue/coalesce pre-start remote +notifications (or install a durable AppServices-owned receiver before +registration) and drain them after startup, preserving the background state. +Add deterministic tests for token, registration failure, and private/shared +remote notifications delivered before handler installation, including a +background-only launch with no scene activation. + +### Verified non-findings + +- The foreground and game-list refresh paths use single-flight tasks and + per-scope guards, while background flushes take independent UIKit execution + assertions and requeue unconfirmed work on the next foreground + (`Crossmate/Services/AppServices.swift:1330`, + `Crossmate/Services/AppServices.swift:1372`). +- Puzzle-session end/bounce interleavings are centralized in `PuzzleSession`: + cancellation releases its assertion, the timer and expiry handler share an + idempotent fire path, and open/close phases cancel or supersede it + (`Crossmate/Services/PuzzleSession.swift:82`, + `Crossmate/Services/PuzzleSession.swift:127`, + `Crossmate/Services/SessionCoordinator.swift:139`). +- Notification routes parse a UUID (or the constrained `game-<UUID>` CloudKit + zone form) before navigation. Share links constrain token syntax and + reconstruct an iCloud URL; `CloudService` independently requires the app's + CloudKit container before accepting the share + (`Crossmate/CrossmateApp.swift:177`, + `Crossmate/Services/ShareLinkRoute.swift:17`, + `Crossmate/Services/CloudService.swift:112`). +- A true iCloud-account change clears only the local cache and resets sync + state; it does not delete the former account's zones or leave its shares. + First sign-in and transient sign-out do not purge + (`Crossmate/Services/AppServices.swift:449`, + `Crossmate/Services/CloudService.swift:292`). +- Diagnostics history is bounded and persisted off-main-actor; the full report + is snapshotted cheaply at tap time and rendered only when an activity + requests it, so the historical continuous full-log rewrite has not regressed + (`Crossmate/Services/DebuggingMonitors.swift:262`, + `Crossmate/Views/Settings/DiagnosticsView.swift:266`). +- The app-entry SwiftUI composition retains stable `AppServices` state and + injects stable action/model objects rather than custom closure environment + values; no independent SwiftUI environment/data-flow finding arose in this + phase. + +### Residual risks and test gaps + +- Neither the Notification Service Extension nor a full diagnostics export has + an integration test for the App Group receipt path. +- No test models an app launch/wake before `RootView.task` installs delegate + handlers, APNs token delivery timing, background-only silent-push execution, + or the OS background-task expiration path around + `AppServices.syncOnBackground`. +- The broker tests cover notification navigation buffering, but not + `CloudShareAcceptanceBroker`/`ShareLinkBroker` ordering, duplicate share + links, or cancellation/replacement of simultaneous link acceptances. +- Real-device validation remains needed for APNs callback ordering, CloudKit + silent-push/background execution budgets, account change while a puzzle is + open, scene restoration, and direct/public share acceptance/revocation. +- Deferred D5 remains a known benign lifecycle-tidiness issue: an + `NYTBrowseView.fetch` task is not cancelled on dismissal. It is outside this + phase's primary workflow scope and remains correctly parked unless its + behavior changes. + +### Checks run + +- Read all completed Phase 1–4 sections of this report; all named historical + review notes and `DEFERRED.md`; and the complete non-`Crossmake` change set + since 2026-07-01. +- Static implementation/test cross-check of app startup, lifecycle task + ownership, foreground/background persistence, remote-push coalescing, + session timers, account transition, notification/deep/share routing, + diagnostics persistence/export, and related unit tests. SwiftUI app-entry + and environment use were reviewed with the applicable SwiftUI guidance. +- `bash Scripts/test-unit.sh` (after granting simulator access): passed — + `** TEST SUCCEEDED **` (24.483 seconds). Full output: + `/tmp/crossmate-phase5-tests.log`. + +### Handoff to Phase 6 + +Keep M8 in scope where Phase 6 relies on foreground notification delivery or +scene transitions to preserve UI state. The pending Phase 4 content limits +(H5, M5, and M7) also apply to any UI route that imports, fetches, or opens +puzzle source. + +## Phase 6 — Gameplay, Accessibility, and UI State + +**Status at audit completion:** Complete, with follow-up required for M9–M10. + +### Scope reviewed + +`Crossmate/Views/`, with focused tracing through `PuzzleView`, `GridView`, +`GridAccessibility`, `KeyboardView`, `HardwareKeyboardInputView`, `ClueList`, +`ClueBar`, `PuzzleHeader`, `PuzzleScoreboard`, `SuccessPanel`, puzzle +modifiers/commands, Game List cards/rows/sections, browse and invitation +surfaces, and view-facing `PlayerSession`, `ReplayControls`, `PlayerRoster`, +`GameCursorStore`, `GameMutator`, and puzzle-display lifecycle code. Reviewed +the corresponding PlayerSession, GameMutator, replay, cursor, accessibility, +summary, and navigation unit tests. + +### Invariants checked + +- Every on-screen, hardware-keyboard, and accessibility action acts on the + current game/cell and cannot mutate a solved or revoked session. +- Navigation, scene/layout changes, replay, completion, and session + replacement preserve the intended puzzle, cursor, clue context, and + read-only state. +- VoiceOver exposes navigable cells, clues, values, and equivalent safe + actions for the core solving workflow. +- Grid and list rendering stay bounded: high-frequency selection/peer updates + do not rebuild per-cell or per-game subtrees unnecessarily. + +### Findings + +#### M9 — A revoked open puzzle still accepts local mutations and can be marked completed + +`markAccessRevoked` only flips the active mutator's `isAccessRevoked` flag +(`Crossmate/Persistence/GameStore.swift:2776`). That flag prevents work only +at `emitMove`, after `setLetter`, `clearLetter`, and every bulk +check/reveal/clear operation have already mutated the in-memory `Game` +(`Crossmate/Persistence/GameMutator.swift:86`, +`Crossmate/Persistence/GameMutator.swift:132`, +`Crossmate/Persistence/GameMutator.swift:317`). + +The intended input block is incomplete: `PuzzleView` disables only the custom +keyboard and declines hardware keys +(`Crossmate/Views/Puzzle/PuzzleView.swift:124`, +`Crossmate/Views/Puzzle/PuzzleView.swift:530`, +`Crossmate/Views/Puzzle/PuzzleView.swift:564`). The toolbar continues to +expose Entry and Hints mutations +(`Crossmate/Views/Puzzle/PuzzleModifiers.swift:91`, +`Crossmate/Views/Puzzle/PuzzleModifiers.swift:125`), and the VoiceOver custom +actions call those mutators directly +(`Crossmate/Views/Puzzle/GridAccessibility.swift:497`). + +Entering or revealing the final squares therefore changes the local game and +emits a local solved event (`Crossmate/Models/PlayerSession.swift:427`). +`PuzzleView` handles that event as an ordinary local solve +(`Crossmate/Views/Puzzle/PuzzleView.swift:430`), and the puzzle destination +persists `completedAt`, locks the local mutator, and queues its normal +completion work without checking revocation +(`Crossmate/CrossmateApp.swift:681`, +`Crossmate/Persistence/GameStore.swift:1427`). The fabricated fills themselves +are not journaled or uploaded, but the revoked user can see phantom progress +and turn their local revoked copy into a terminal completed game; later +refreshes may make that state appear to regress. + +Gate all `GameMutator` mutation entry points on `!isAccessRevoked` alongside +`!isCompleted`, and disable or hide all editable action surfaces from the same +single read-only predicate. Add tests for letter, clear, check, reveal, rebus, +and completion attempts after revocation, including an open `PuzzleView` path +that proves no completion persistence or outgoing completion work is +triggered. + +#### M10 — VoiceOver’s Reveal Square action bypasses the reveal confirmation + +The visual Hints menu and app-level command surface deliberately request a +confirmation before revealing +(`Crossmate/Views/Puzzle/PuzzleModifiers.swift:137`, +`Crossmate/Views/Puzzle/PuzzleCommands.swift:32`). The VoiceOver cell action +instead calls `session.revealSquare()` directly via `revealAndAnnounce` +(`Crossmate/Views/Puzzle/GridAccessibility.swift:329`, +`Crossmate/Views/Puzzle/GridAccessibility.swift:502`). Reveal is non-undoable, +so a VoiceOver user can accidentally spend a hint and alter the shared puzzle +with no confirmation that sighted and hardware-command users receive. This is +an accessibility parity and destructive-action safety gap, not a limitation of +the grid's synthetic hierarchy. + +Pass a confirmation request into the accessibility modifier (or surface the +same focused `PuzzleActionTarget`) and present the existing alert before +calling the mutator. Add an accessibility/UI regression test that activating +Reveal Square first presents the alert and that cancelling leaves the square +unchanged. + +### Verified non-findings + +- Player cursor state is owned by a `@MainActor` `PlayerSession`, restored + only for a valid non-block cell, and persisted on selection changes; + undo/redo land on their recorded cells and orientations + (`Crossmate/Models/PlayerSession.swift:97`, + `Crossmate/Models/PlayerSession.swift:360`). +- The grid now draws cell content in a single Canvas snapshot while selection, + remote cursor, and recent-change overlays observe their narrower state + slices. Peer cursor updates do not rebuild a per-cell `ForEach` + (`Crossmate/Views/Puzzle/GridView.swift:55`, + `Crossmate/Views/Puzzle/GridView.swift:516`). +- Replay state is view-owned, loading is idempotent, and its play task checks + cancellation between ticks; a replay frame suppresses live grid input and + maps the clue display to the replay cursor + (`Crossmate/Models/ReplayControls.swift:164`, + `Crossmate/Views/Puzzle/SuccessPanel.swift:396`, + `Crossmate/Views/Puzzle/GridView.swift:124`). +- The current VoiceOver grid exposes one element per open cell, keeps focus + and cursor synchronized, supplies clue rotors and values, and coalesces + peer-fill announcements. Its pure description logic is covered by unit tests + (`Crossmate/Views/Puzzle/GridAccessibility.swift:215`, + `Tests/Unit/CellAccessibilityDescriberTests.swift:40`). +- The Game List caches derived section snapshots outside the render path and + uses stable game/object identities. Current environment defaults are stable + optional model/action values; no regression of the historical closure-based + environment invalidation issue was found. + +### Residual risks and test gaps + +- There are no UI tests that enable VoiceOver and drive the Canvas-backed + grid, custom actions, rotors, direct-touch keyboard, alerts, Dynamic Type, + or iPad layout. The existing accessibility tests validate spoken strings + only, not focus, activation, or confirmation wiring. +- The high-frequency Canvas architecture is structurally sound, but no + performance test measures large grids, long clue lists, or a large game + library under live cursor/move traffic. Real-device profiling with VoiceOver + enabled remains needed because it intentionally creates one accessibility + element per open square. +- The large computed SwiftUI sections recorded in `Notes/ViewStructure.md` + remain in PuzzleView, GameListView, and NYTBrowseView. The Game List cache + fixes the hottest collection derivation, but these helpers do not create + independent invalidation boundaries. They are a bounded performance risk, + not a new correctness finding. +- Several announcements and view strings remain English-shaped at runtime, + notably PuzzleView's manual participant list and headings transformed with + `.textCase(.uppercase)` in the clue surfaces + (`Crossmate/Views/Puzzle/PuzzleView.swift:282`, + `Crossmate/Views/Puzzle/ClueList.swift:192`). This is the historical + localization cleanup risk; it does not alter the current solving state + machine. +- M8 remains relevant when scene transitions bracket a puzzle: the app has no + deterministic integration test proving an early APNs callback cannot race + its UI/session startup. + +### Checks run + +- Read all completed Phase 1–5 sections of this report, all named historical + review notes and `DEFERRED.md`, and mapped every non-`Crossmake` change + since 2026-07-01, with focused review of the Game List caching, clue-list + extraction, VoiceOver grid, replay, marketing rendering, hardware input, and + layout changes. +- Static implementation/test cross-check of SwiftUI state ownership, + observation/environment usage, collection identity, navigation, keyboard and + accessibility input, replay/completion flow, Dynamic Type/iPad layout, and + high-frequency grid/list paths using the applicable SwiftUI guidance. +- `bash Scripts/test-unit.sh` (after granting simulator access): passed — + `** TEST SUCCEEDED **` (24.587 seconds). Full output: + `/tmp/crossmate-phase6-tests.log`. + +### Handoff to Phase 7 + +Carry M9 and M10 into release readiness: add the missing mutation and +accessibility UI coverage before treating revocation and reveal safeguards as +verified. Phase 7 should also account for the UI-level coverage gaps above and +retain M8 plus H1–H5 and M1–M3, M5, and M7–M8 as the cross-process issues that +require full-suite and operational validation. + +## Phase 7 — Operational Readiness and Test Gaps + +**Status at audit completion:** Complete, with follow-up required for M11 and +the inherited cross-phase findings. + +### Scope reviewed + +`project.yml`; the app and notification-service entitlements and Info.plists; +the build, unit-test, release/upload, simulator, secret-template, and Wrangler +wrapper scripts; all three Wrangler configurations; worker logging and test +infrastructure; generated-project consistency; and test coverage across the +preceding six phases. `Crossmake/` was excluded. + +### Invariants checked + +- Generated project inputs, signing entitlements, build settings, and the + exported release artifact remain mutually consistent. +- Debug and production APNs/realtime services cannot silently diverge, and a + release attempt neither retains credentials nor damages the caller's signing + environment. +- Worker deployment inputs keep secrets out of version control and have a + deterministic, testable configuration. +- The unit and worker suites exercise the app/extension/worker contracts on + which the earlier phases rely, with remaining real-service boundaries stated + explicitly. + +### Findings + +#### M11 — iOS release signing cleanup can retain identities and clobber the caller's keychain configuration + +`publish-ios.sh` creates, unlocks, and imports the development and +distribution identities into `.asc/build.keychain-db` before it installs its +`EXIT` trap (`Scripts/publish-ios.sh:84`, `Scripts/publish-ios.sh:89`, +`Scripts/publish-ios.sh:105`). With `set -e`, a missing/corrupt certificate, a +failed import, or a failed partition-list update exits before +`cleanup_keychain` runs, leaving the temporary keychain and whichever signing +identity was already imported on disk. + +Even on success, the script replaces the user's keychain search list with the +temporary keychain plus login, then restores only login and resets the default +keychain to login without first saving either prior value +(`Scripts/publish-ios.sh:95`, `Scripts/publish-ios.sh:97`). A developer who +normally uses additional keychains therefore loses those from the search list +after publishing. This makes signing cleanup both credential- retention-prone +on failure and destructive to unrelated local signing setup. + +Install a cleanup trap before creating the keychain; capture the complete +preexisting search list and default keychain, then restore those exact values +on every normal/error exit. Ensure cleanup tolerates a partially created +keychain. Add shell-level tests with a stubbed `security` command that fail at +each setup step and assert no temporary keychain or caller-state change +remains. + +### Verified non-findings + +- `APS_ENVIRONMENT` remains the shared build setting for the app entitlement + and the value reported to `PushClient`; Debug maps to development and + Release to production (`project.yml:104`, + `Crossmate/Crossmate.entitlements:5`, + `Crossmate/Services/PushClient.swift:124`). +- The release script requires an HTTPS push origin and production APNs value + in the exported IPA, and emits the signed app entitlements for inspection + (`Scripts/publish-ios.sh:160`). +- The checked-in worker configuration contains only public identifiers and + Durable Object bindings; its App Attest root and APNs private key are + runtime secrets. `.asc/`, `Generated/`, `cookies.txt`, `private_keys/`, and + Wrangler local state are gitignored (`.gitignore:1`, + `Workers/wrangler.push.toml:7`). +- `xcodegen generate` regenerated the project successfully and produced no + tracked diff. Shell syntax checks and `node --check` passed for every + repository script and worker. +- Unit suites that mutate shared defaults/storage declare targeted + serialization or the project-provided isolation trait; the full randomized, + parallel scheme passed in this phase. The one historically shared friend-key + suite is explicitly serialized + (`Tests/Unit/Sync/EngagementMessageAuthenticatorTests.swift:13`). + +### Residual risks and test gaps + +- The worker suite has 17 tests, all focused on rate-limit/configuration + logic. It does not execute the push worker's App Attest verification, + registration, unregister, credential, publish/APNs paths, the room + WebSocket/register path, or the link worker at all; its Durable Object + storage is a narrow in-memory stub. The real Cloudflare runtime coverage gap + therefore remains. +- There is no committed CI workflow, dependency lockfile, Node-version policy, + or pinned Wrangler version. The wrapper scripts execute whichever global + `wrangler` is installed, so deployment parsing/behavior is not reproducible + from the repository alone. Pin the worker toolchain and add CI that runs + project generation, worker tests, syntax checks, and the iOS unit suite. +- No test drives a generated signed IPA, validates the notification-extension + entitlement/artifact, deploys a Worker preview, verifies a Cloudflare + binding or migration, or performs App Attest/APNs/CloudKit work against real + services. These are required release-validation steps. +- The Phase 5/6 lifecycle, Notification Service Extension, VoiceOver, Dynamic + Type, iPad layout, and multi-device/cross-process scenarios still lack UI or + integration coverage. M9 and M10 should not be marked verified from the + current string/model-level tests. + +### Checks run + +- Read all completed Phase 1–6 sections of this report, the named historical + notes and `DEFERRED.md`, and mapped all non-`Crossmake` changes since + 2026-07-01, with focus on the release, generated-project, worker, and test + infrastructure changes. +- Static cross-check of generated project input/output, entitlements, release + settings, secret handling, worker configuration/logging, and test isolation + against app/extension/worker contracts. +- `xcodegen generate`: passed; no tracked diff. +- `bash -n Scripts/*.sh`: passed. +- `node --check Workers/push-worker.js`, `Workers/room-worker.js`, and + `Workers/link-worker.js`: passed. +- `bash Scripts/test-workers.sh`: passed — 17 tests. +- `bash Scripts/test-unit.sh` (with simulator access): passed — + `** TEST SUCCEEDED **` (27.913 seconds). Full output: + `/tmp/crossmate-phase7-tests.log`. + +### Handoff / current audit status + +All seven planned review phases are complete. Subsequent remediation closed +every finding reported here. The broader worker, integration, real-device, and +performance validation summarised in the post-audit status above remains +outstanding. The original audit itself did not change production code.