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:
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.