# Project Research Summary

**Project:** Dimension47 - TTRPG Campaign Management Platform
**Domain:** Self-hosted PF2e TTRPG companion web app (brownfield milestone)
**Researched:** 2026-04-27
**Confidence:** HIGH

## Executive Summary

Dimension47 is a self-hosted, single-group PF2e TTRPG companion app with a mature existing stack (NestJS 11, React 19, Prisma 7, PostgreSQL, Socket.io 4.8.3, Tailwind v4). The next milestone adds seven distinct capability areas to the live system: PF2e-regelkonform Level-Up, PWA installability with offline read, Web Push notifications, server-authoritative dice rolling with in-app chat, multi-screen battle display, GM live-control panel, and an Obsidian vault read-only browser. Research confirms the existing architectural patterns (NestJS module-per-feature, React feature directories, WebSocket gateway-per-namespace, Prisma migrations-only) scale cleanly to all seven areas with five new backend modules, seven new Prisma migrations, and approximately 40 new files. No fundamental architectural change is needed; all additions are additive and follow documented existing patterns.

Three infrastructure decisions must be made once and cannot be retrofitted: (1) the service worker must use the injectManifest strategy with user-scoped cache keys and logout-triggered cache purge from day one - retrofitting this after multiple phases would require touching every cached route; (2) the battle display screen must be a separate route with server-side sub-room filtering, never a CSS-hidden React variant, because GM data would otherwise be readable in browser DevTools; (3) all dice rolls must be server-authoritative using crypto.randomInt plus @dice-roller/rpg-dice-roller, because WebSocket payloads are fully attacker-controlled and PF2e crit math (doubles dice not modifiers) is easy to implement wrong client-side.

The primary risk in the milestone is Level-Up complexity. PF2e Level-Up has six independent choice axes per level with a prerequisite DSL that covers skill ranks, feat ownership, class membership, ancestry, and deity. The boost-cap-at-18 rule (+1 not +2 if attribute already at 18) and skill-increase-history tracking are the most common implementation bugs. Level-Up cannot be parallelized with other phases because it modifies CharactersService and CharactersGateway, which are touch-points for every other phase. The recommended mitigation is to build Level-Up first (Phase A), treat prerequisite DSL as an explicit sub-deliverable within that phase, and use an escape-hatch for unevaluable prerequisites (show a warning instead of a hard block) so unusual feats do not break the UI for the whole group.

## Key Findings

### Recommended Stack

The existing stack is locked and not re-evaluated. Twelve new libraries are added across the seven phases. The most critical adoption decisions are vite-plugin-pwa with injectManifest strategy (not generateSW - the distinction matters because only injectManifest allows hand-authoring the service worker for custom push handler and user-scoped cache invalidation), @dice-roller/rpg-dice-roller on the server side only, and react-markdown plus @portaljs/remark-wiki-link for the vault renderer. The webdav library is conditional: only needed if the vault lives on a separate machine; if it is filesystem-mounted on the NestJS server, a custom NestJS controller with fs/promises is simpler and lower-latency.

**Stack adoption table by phase:**

| Phase | Client installs | Server installs |
|-------|----------------|-----------------|
| A - Level-Up | none | none (pure NestJS + Prisma) |
| B - PWA | vite-plugin-pwa, workbox-*, idb-keyval, @tanstack/react-query-persist-client, @tanstack/query-sync-storage-persister | none |
| C - Web Push | none | web-push, @types/web-push |
| D - Dice + Chat | none | @dice-roller/rpg-dice-roller |
| E - Battle Ausbau | none | none |
| F - GM Live-Tools | none | none (reuses existing events) |
| G - Vault | react-markdown, remark-gfm, @portaljs/remark-wiki-link, react-shiki, shiki | webdav (conditional) |

**Core new technologies:**
- vite-plugin-pwa v1.2.0: PWA manifest, service worker bundling, Vite 7 support since v1.0.1
- workbox-precaching / workbox-routing / workbox-strategies: Caching primitives for injectManifest SW
- web-push v3.6.7: VAPID-signed server-side push delivery, mature stable API
- @dice-roller/rpg-dice-roller v5.5.1: TS-typed RPG dice parser, supports all PF2e modifiers
- react-markdown v9.x: React 19 compatible markdown renderer, no dangerouslySetInnerHTML
- @portaljs/remark-wiki-link v1.2.0: Obsidian-flavored wikilink AST nodes with shortest-path resolution
- react-shiki v0.9.x: Syntax highlighting, replaces unmaintained react-syntax-highlighter
- idb-keyval v6.x: IndexedDB wrapper for user-scoped offline cache
- webdav v5.x: TS-native WebDAV client for vault transport (conditional)

### Expected Features

Level-Up is the structurally most complex single feature: six choice axes (attribute boost, class feat, skill feat, general feat, skill increase, ancestry feat, class feature), prerequisite DSL covering roughly 200+ feats with non-trivial conditions, per-class progression tables that vary widely (Fighter weapon specialization steps vs Wizard school progressions vs Rogue every-level skill increases), boost-cap arithmetic at 18, and Free Archetype variant (group uses it - table stakes, not optional). Spellcaster slot and cantrip progression is also table stakes because the group has active spellcasters.

**Must have (table stakes for this milestone):**
- Level-Up: all six choice axes, prerequisite validation, auto-recompute, DRAFT/PATCH/COMMIT flow, undo before commit, Free Archetype variant, spellcaster slot progression
- PWA: installable manifest, service worker, Android automatic install prompt, iOS guided Add-to-Home-Screen, cache-first offline read for character sheets
- Web Push: VAPID keys, subscription persistence per device, GM-to-player ping
- Battle Display-Mode: separate read-only route, large initiative tracker, token effects/conditions visible, no GM-only data
- Token effects: per-token named effects with optional duration, WebSocket broadcast
- Dice + Roll Log: server-authoritative PF2e notation, degree-of-success, broadcast to all in campaign/battle
- In-game Chat: per campaign and battle, inline roll embeds, GM ping message
- GM Live-Tools: HP/conditions/items/money on any player character, push-ping UI
- Vault: folder tree, file read, markdown + wikilinks + image embeds, full-text search, last-N offline cached

**Should have (differentiators):**
- German UI for all new feat and class-feature text via existing Claude API translation cache
- Animated GM-ping arrival with vibration + initiative card pop-up
- Battle display cinematic theme (dim off-turn tokens)
- GM request-roll feature (combined push + pre-populated dice button)
- Roll history export to HTML

**Defer to v1.x:**
- Auto-condition application from spells/attacks (heavy data model cost)
- Vault offline FlexSearch full-index
- Vault frontmatter NPC chips in chat/battle
- Level-up history view per character

**Anti-features (deliberately excluded):**
- Bidirectional vault sync, native app wrapper, in-app character creation, fog of war, voice/video, 3D dice, background sync (iOS unsupported), skipWaiting() in service worker

### Architecture Approach

The architecture is additive: five new NestJS modules (LevelingModule, PushModule, DiceModule, ChatModule, VaultModule), five Prisma migrations in dependency order, and approximately 40 new files following the existing module-per-feature pattern. Existing modules (CharactersModule, BattleModule, AuthModule) receive targeted extensions rather than rewrites. The battle gateway gains server-side sub-rooms (battle:id:gm / battle:id:display) to filter GM-only events at the emit layer. The CharactersGateway gains a level_up_committed update type. All WebSocket gateways follow the existing pattern: JWT decoded once on connect, userId cached on socket.data, authorization per event using cached userId.

**Major new components:**
1. LevelingModule - DRAFT/PATCH/COMMIT API for PF2e level-up with prerequisite DSL validator and recompute hooks into CharactersService
2. PushModule - VAPID push delivery service with subscription CRUD, 410-cleanup lifecycle, sendToUser/sendToCampaign helpers
3. DiceModule + DiceGateway - server-side roll with @dice-roller/rpg-dice-roller, persist to DiceRoll table, broadcast via /dice namespace
4. ChatModule + ChatGateway - append-only messages with embedRollId FK for roll embeds, /chat namespace
5. VaultModule - VaultProvider DI interface (FsVaultProvider or WebDAVVaultProvider), wikilink resolver, image proxy, VaultNoteCache in Postgres
6. BattleDisplayPage - separate route /campaigns/:id/battle/display, server-filtered DTO, display-only token with short-lived scoped credential
7. sw/service-worker.ts - Workbox injectManifest custom SW with user-scoped cache keys, logout purge via postMessage, NetworkFirst for API, CacheFirst for assets
8. LevelUpWizard - step-by-step modal (class features -> boosts -> class feat -> skill increases -> general/skill feat -> review) with resume-on-reload via persisted DRAFT

**Prisma migration sequence (must follow this order):**
1. add_level_up_sessions (Phase A)
2. add_push_subscriptions (Phase C)
3. add_dice_and_chat (Phase D)
4. add_battle_effects_and_turn_pointer (Phase E)
5. add_vault_config (Phase G)

### Critical Pitfalls

1. **SW cache leaks user data across logins** - Use IndexedDB keyed by userId for all authenticated API responses, never Cache API for auth-gated routes. On logout, postMessage LOGOUT to SW and call caches.delete() for user-scoped caches. Design this on day one of PWA phase; retrofitting is expensive.

2. **Display screen leaks GM data via unfiltered WebSocket frames** - Server must emit to battle:id:gm and battle:id:display sub-rooms separately. Display token must be a short-lived scoped credential issued by GM. Verify with socket.onAny(console.log) in incognito before shipping.

3. **SW auto-update reloads mid-battle** - Never call skipWaiting() automatically. Gate the update prompt on !isInBattle Zustand state. Only show update banner when no active battle session.

4. **Boost-cap-at-18 bug corrupts character permanently** - Centralize in applyAttributeBoost(current): current >= 18 ? +1 : +2. Unit test this function. Validate that a boost set applies to four different attributes (grey out already-boosted in UI).

5. **VAPID key loss breaks all push subscriptions silently** - Treat VAPID keys as application secrets equivalent to JWT_SECRET. Document in .env.example. Implement 410-Gone cleanup on push send. Implement pushsubscriptionchange listener in SW for browser-rotated subscriptions.

## Implications for Roadmap

Based on combined research, the recommended build order is A -> B -> C -> D -> E -> F -> G with two negotiable parallelizations (D can start after B without waiting for C; G can start after B without waiting for E or F).

### Phase A: Level-Up (regelkonform)

**Rationale:** Level-Up touches CharactersService and CharactersGateway, the most-modified files across the milestone. Starting here validates the pattern of extending existing modules before any new infrastructure (PWA, Push, Dice) is layered on top. No external library additions required. Highest rules-complexity of the milestone; benefits most from dedicated focus without competing priorities.

**Delivers:** Complete PF2e level-up wizard (all six choice axes), DRAFT/PATCH/COMMIT flow, prerequisite DSL evaluator, boost-cap-at-18 correct, Free Archetype variant toggle, spellcaster slot progression, auto-recompute of HP-Max/saves/AC, level_up_committed WebSocket broadcast.

**New modules:** LevelingModule with LevelUpSession Prisma model

**Key files changed:** characters.service.ts (applyLevelUp, recomputeDerivedStats), characters.gateway.ts (level_up_committed type), character-sheet-page.tsx (Stufe-steigen button + wizard mount)

**Stack installs:** None

**Success criteria:** Unit tests pass for boost-cap-at-18 (STR 18 -> 19, not 20), DRAFT can be abandoned without character mutation, commit is atomic Prisma transaction, level_up_committed broadcasts correct new HP-Max and proficiency values, Free Archetype variant shows second feat slot restricted to archetype feats

**Pitfalls owned:** #8 boost cap, #9 recompute side effects, #10 feat retrain orphans, #11 skill increase history

**Research flag:** Needs spike on prerequisite DSL scope (how many feat prereqs are evaluable vs escape-hatch warning) and spellcaster slot tables per tradition.

### Phase B: PWA Foundation

**Rationale:** PWA is the prerequisite for both Web Push (Phase C) and Vault offline cache (Phase G). Cannot ship C or G without B. Has zero backend changes, so it can ship immediately after A without coordinating server migrations. Frontend-only phase with well-documented patterns.

**Delivers:** Installable PWA (manifest, icons, splash), service worker with Workbox injectManifest strategy, user-scoped cache keys, logout-triggered cache purge, offline read for character sheet / equipment / feats / translations, Add-to-Home-Screen flow for iOS (guided) and Android (automatic), use-pwa-update hook with battle-gated update prompt.

**Stack installs:** vite-plugin-pwa, workbox-precaching, workbox-routing, workbox-strategies, workbox-expiration, workbox-cacheable-response, idb-keyval, @tanstack/react-query-persist-client, @tanstack/query-sync-storage-persister

**Success criteria:** Lighthouse PWA audit passes Installable check, app loads on airplane mode showing character sheet (not login error), second login on same device does not see first user data in cache, deploy during active battle does not reload the session

**Pitfalls owned:** #1 SW cache leaks, #2 mid-session SW update, #3 iOS push silent failure (manifest prereqs), #4 VAPID key loss (manifest prereqs), #21 manifest icon traps, #23 offline auth fallback

**Research flag:** Standard patterns, skip research-phase. vite-plugin-pwa + Workbox are well-documented.

### Phase C: Web Push

**Rationale:** Depends on Phase B (service worker must exist for push handler). Unblocks Phase F (GM Live-Tools needs push for the du-bist-dran ping). Can run in parallel with Phase D if team has bandwidth.

**Delivers:** VAPID key generation, PushSubscription Prisma model, PushModule (subscribe/unsubscribe endpoints, sendToUser/sendToCampaign helpers), notification-permission-prompt component, 410-Gone cleanup, pushsubscriptionchange SW listener.

**Stack installs (server):** web-push, @types/web-push

**Success criteria:** Android Chrome receives push when app is not open, iOS (home-screen installed PWA) receives push, 410 response deletes subscription from DB, VAPID keys documented in .env.example

**Pitfalls owned:** #3 iOS push, #4 VAPID key loss

**Research flag:** Standard patterns, skip research-phase. web-push library API is well-documented.

### Phase D: Dice + Chat

**Rationale:** Logically independent of Phase C (can use toast for inline display without push). Depends on Phase B (offline read of roll log via SW cache). Chat + dice together in one phase because they share the DiceRoll/ChatMessage dual-table schema with embedRollId FK. Unblocks Phase F (GM needs chat surface to send targeted ping messages).

**Delivers:** DiceModule + DiceGateway, ChatModule + ChatGateway, server-authoritative dice rolling, DiceRoll and ChatMessage Prisma models with composite indexes on (campaignId, createdAt), dice-roller UI, chat-panel UI, roll embeds in chat, cursor-based pagination, roll log per campaign and per battle, PF2e degree-of-success calculation.

**Stack installs (server):** @dice-roller/rpg-dice-roller

**Success criteria:** Client cannot forge a roll result (server rejects manipulated WebSocket payload), crit doubling applies to dice not modifiers (2d6+4 crit = 4d6+4 not 4d12+8), EXPLAIN ANALYZE on paginated chat query shows index use, roll sent during 30s disconnect appears after reconnect via REST refetch

**Pitfalls owned:** #6 client-side dice tampering, #7 markdown chat XSS, #22 chat/roll index

**Research flag:** Standard patterns. PF2e degree-of-success math and @dice-roller/rpg-dice-roller API well-documented.

### Phase E: Battle Ausbau (Display-Mode, Initiative-Tracker, Effekte)

**Rationale:** Builds on existing battle infrastructure. Benefits from Phase D (dice visible in initiative order) but is not blocked by it. The display-mode security decision (server sub-rooms) must be implemented here; cannot be retrofitted without touching gateway and all downstream broadcast calls.

**Delivers:** BattleDisplayPage separate route with server-filtered DTO, BattleGateway sub-rooms (battle:id:gm / battle:id:display), display-only short-lived token issued by GM, BattleEffect Prisma model, turnTokenId on BattleSession, InitiativeTracker component, EffectPill component, token add/remove WebSocket events (replacing query-invalidate), GM next-turn button.

**Stack installs:** None

**Success criteria:** socket.onAny(console.log) on display incognito shows no GM-only fields, display screen stays live after GM browser refresh, aspect ratio test on actual table screen hardware passes, display token expires when battle ends

**Pitfalls owned:** #5 display screen GM data leak, #15 socket message ordering on reconnect, #18 WebSocket room leaks, #24 display aspect ratio

**Research flag:** Display-mode auth token design needs a spike. The short-lived scoped token issuance pattern (GM clicks -> server issues one-shot URL) is not a standard Socket.io pattern and needs a brief design session before implementation.

### Phase F: GM Live-Tools

**Rationale:** Depends on Phase C (push for du-bist-dran ping) and Phase D (chat surface for targeted messages). No new backend module or Prisma schema required - reuses all existing character WebSocket events. Fastest phase in the milestone.

**Delivers:** gm-control-panel.tsx sidebar in campaign-detail-page listing all player characters with quick-actions (HP+/-, condition set, item give, money adjust, push-ping send), gmMode prop on existing modals, server-side authorization audit of existing character events to enforce GM role check.

**Stack installs:** None

**Success criteria:** GM can modify any player character HP/conditions/items/money from the panel, targeted push-ping reaches specific player, bulk destructive actions require confirmation modal, in-memory undo stack (last 5 actions) works

**Pitfalls owned:** #25 GM bulk-action footgun

**Research flag:** Authorization audit of existing character WebSocket events required before implementation. FEATURES.md flags existing events probably do not enforce is-actor-a-GM check. This is a required spike.

### Phase G: Obsidian Vault

**Rationale:** Depends on Phase B (service worker for offline note cache). Independent of all other phases. Can be parallelized with Phase E or F if team capacity allows. Vault transport decision must be resolved before starting.

**Delivers:** VaultModule with VaultProvider DI interface (FsVaultProvider for same-machine, WebDAVVaultProvider for separate machine), VaultConfig and VaultNoteCache Prisma models, wikilink resolver with Obsidian shortest-path algorithm, basename index for ambiguity detection, image proxy endpoint, vault-browser-page.tsx with folder tree + note reader, note-renderer.tsx (react-markdown + remark-gfm + portaljs/remark-wiki-link), full-text search via Postgres FTS, last-N offline cache via SW StaleWhileRevalidate, path traversal guard.

**Stack installs (client):** react-markdown, remark-gfm, @portaljs/remark-wiki-link, react-shiki, shiki. (server): webdav (conditional on transport decision)

**Success criteria:** Vault path traversal test rejects ../../etc/passwd, cyclic embed (A embeds B embeds A) renders placeholder not stack overflow, ambiguous wikilinks surface to user not silently pick wrong, note cache survives airplane mode after one online read

**Pitfalls owned:** #12 wikilink ambiguity, #13 embed loops, #14 vault path traversal

**Research flag:** Vault transport decision is an open question that must be answered before Phase G starts. See Open Questions below.

### Phase Ordering Rationale

Hard dependencies: B before C (SW required for push handler); B before G (SW required for vault offline cache); C and D both before F (F needs push and chat surface). These cannot be reordered.

Negotiable: D can start immediately after B without waiting for C to complete; G can start after B without waiting for E or F. A is independent of everything and can start immediately.

Anti-ordering to avoid: Push before PWA (SW not yet deployed); GM Live-Tools before Push and Chat (half-functionality, ping missing); Battle display before implementing sub-rooms (security regression difficult to retrofit).

### Research Flags

Phases needing deeper research during planning:
- **Phase A:** Prerequisite DSL scope. Determine which prerequisite patterns are mechanically evaluable (skill rank, feat ownership, level, class) vs which need the escape-hatch warning path. Recommend a spike against Archives of Nethys feat data.
- **Phase E:** Display-mode token issuance design. One-shot scoped token for table display URL needs a design spike (JWT with battle:id claim, short TTL, server-validated on display route).
- **Phase F:** Authorization audit. Existing character WebSocket events need a code audit to verify GM role enforcement before the GM Live-Tools UI exposes them.
- **Phase G:** Vault transport decision. Must be resolved (filesystem mount vs WebDAV) before Phase G implementation begins.

Phases with standard patterns (skip research-phase):
- **Phase B:** vite-plugin-pwa + Workbox are extensively documented. injectManifest strategy has clear examples.
- **Phase C:** web-push library API is stable and well-documented.
- **Phase D:** @dice-roller/rpg-dice-roller API is documented; PF2e degree-of-success math is rules-defined.

## Confidence Assessment

| Area | Confidence | Notes |
|------|------------|-------|
| Stack | HIGH | All versions verified via Context7 and npm; vite-plugin-pwa v1.2.0 confirmed Vite 7 support; react-markdown v9 confirmed React 19 compatibility |
| Features | HIGH | PF2e rules verified against Archives of Nethys; PWA tech verified against 2026 docs; competitor analysis is MEDIUM (UX details inferred) |
| Architecture | HIGH | Based on mapped live codebase; all patterns reference existing gateway/service/module code; no speculative components |
| Pitfalls | HIGH for PWA/Push/Socket.io/Prisma; MEDIUM for Obsidian edge cases | PWA and Socket.io pitfalls verified against official 2026 docs; Obsidian wikilink edge cases from community forum threads |

**Overall confidence:** HIGH

### Gaps to Address

- **Vault transport:** PROJECT.md says protocol is yet to be chosen. Must decide filesystem vs WebDAV before Phase G. Recommendation: default to filesystem mount with WebDAVVaultProvider as swappable DI alternative.
- **Free Archetype scope:** Research confirms it is table stakes. Implementation scope (which archetype feat sources are valid after dedication) should be confirmed with the GM before Phase A begins.
- **Display screen hardware:** Actual table screen aspect ratio and specs unknown. Get specs before Phase E ships.
- **Player iOS versions:** iOS PWA push requires home-screen install and Safari 16.4+. Confirm player device baseline before Phase C ships.
- **Feat prerequisite DSL scope:** Boundary between evaluable and unevaluable prerequisites needs a spike against actual group character feat lists before Phase A implementation.

## Open Questions for User

These require user input before or during the corresponding phase:

1. **Vault transport (before Phase G):** Is the Obsidian vault on the same machine as the NestJS server (filesystem mount, simpler) or on a separate machine like Synology or Nextcloud (WebDAV)? This determines which VaultProvider is implemented first.
2. **Free Archetype scope (before Phase A):** Should the app restrict archetype feat slots strictly to the chosen archetype, or allow any archetype feat after dedication is taken? Pathbuilder allows any archetype feat after dedication; confirm group expectation to avoid a mid-implementation scope change.
3. **Table display hardware (before Phase E):** What is the aspect ratio and resolution of the embedded table screen? 16:9, 4:3, portrait? Needed to avoid Pitfall #24.
4. **Player iOS versions (before Phase C):** What iOS versions are the players running? iOS PWA push requires 16.4+. If any player is below this, push silently fails on their device.
5. **Prerequisite DSL escape-hatch threshold (before Phase A):** For feat prerequisites that cannot be evaluated automatically (deity-specific, spellcasting-tradition edge cases), should the app (a) show a warning and allow the player to proceed, or (b) block and require GM override? Recommendation: option (a) with warning.

## Sources

### Primary (HIGH confidence)
- Context7: /vite-pwa/vite-plugin-pwa - injectManifest strategy, useRegisterSW hook, Vite 7 support
- Context7: /web-push-libs/web-push - generateVAPIDKeys, sendNotification API, 410/404/429 error patterns
- Context7: /remarkjs/react-markdown - remarkPlugins, components prop, v9 React 19 support
- Context7: /shikijs/shiki - Shiki 4.0.2 current, ESM grammars
- Context7: /avgvstvs96/react-shiki - recommended replacement for react-syntax-highlighter
- Context7: /googlechrome/workbox - caching strategies primitives
- Archives of Nethys: Leveling Up, Ability Boosts, Skill Increases, Free Archetype, Archetypes
- Socket.io docs v4: Connection state recovery, delivery guarantees, middlewares
- .planning/codebase/ files: ARCHITECTURE.md, STRUCTURE.md, INTEGRATIONS.md, CONCERNS.md, TESTING.md (live codebase mapping)
- server/prisma/schema.prisma: existing models and relations (live codebase)

### Secondary (MEDIUM confidence)
- MagicBell 2026: PWA iOS limitations and Safari support - iOS push requires home-screen install, Declarative Web Push since Safari 18.4
- Mobiloud 2026: Do Progressive Web Apps Work on iOS - install path requirements
- @portaljs/remark-wiki-link npm: Obsidian-flavored path resolution semantics
- Obsidian Forum threads: wikilink resolution rules, ambiguity handling
- HackerOne: Secure markdown rendering in React - XSS via rehype-raw + dangerouslySetInnerHTML
- Pushpad: Web Push error 410 cleanup pattern, VAPID error codes
- Foundry VTT PF2e Leveler module: proves prerequisite-validating Level-Up is feasible at scale

### Tertiary (LOW confidence / needs validation)
- Community wisdom on Obsidian embed cycle edge cases - verify with deliberate cyclic test vault before shipping
- EU iOS PWA push flakiness by Safari version - verify on actual player devices before Phase C ships
- Per-event JWT verify CPU impact at 20+ events/sec - verify with load test if battle turns out to be high-frequency

---
*Research completed: 2026-04-27*
*Ready for roadmap: yes*