# Scrabble Game — Architecture

Source of truth for the platform architecture, transport, security model and
cross-service contracts. User-visible behaviour per domain lives in
[`FUNCTIONAL.md`](FUNCTIONAL.md). This document always describes the **current**
design, not the history of how it was reached.

## 1. Overview

Three executables plus per-platform side-services:

- **`gateway`** — the only public ingress (module `scrabble/gateway`). Performs
  anti-abuse (rate limiting), authenticates the player against the originating
  platform (or an email/guest session), resolves the internal `user_id`, and
  forwards authenticated traffic to `backend` with an `X-User-ID` header. Serves the
  backend's admin console at `/_gm` on its public listener behind HTTP Basic Auth.
  Bridges live events from `backend` to the
  client. The shared wire contracts (the push proto and the FlatBuffers edge
  payloads) live in `scrabble/pkg`, imported by both `gateway` and `backend`.
- **`backend`** — internal-only service that owns every domain concern:
  identity/sessions, accounts and linking, lobby and matchmaking, the game
  runtime, the robot opponent, chat, notifications, statistics, history, and
  administration. Embeds the **`scrabble-solver`** engine **as a library,
  in-process** — there is no per-game container. The only network consumer of
  `backend` is `gateway` (plus platform side-services over an internal API).
- **`ui`** — pure-HTML5 client (plain Svelte 5 + TypeScript + Vite, static build;
  no SvelteKit). Talks to `backend` only through `gateway` over Connect-RPC +
  FlatBuffers, with the edge TS bindings generated from the **same** `edge.proto`
  and `scrabble.fbs` and committed under `ui/src/gen/`. The client covers auth,
  "my games", auto-match, the board (play/pass/exchange/
  resign), hint, word-check, chat/nudge, the live stream, i18n (en/ru) and a profile
  view, plus the social/account/history surfaces. There is no board on
  the wire — the client **reconstructs the 15×15 board by replaying the move
  journal** (§9.1) and renders board, tiles, premium squares and effects as pure
  CSS + Unicode (no image/font/SVG assets). Tiles are placed by Pointer-Events drag
  or tap; a CSS-token theme is light/dark and Telegram-themeParams-ready; navigation
  is a hash router and the session token is held in memory + IndexedDB. A build-flagged
  in-memory mock transport (`pnpm start`) runs the whole slice with no backend.
  Embeddable in platform webviews; packageable to native (iOS/Android) via Capacitor.
  The client uses a mobile-app shell (a growing nav bar; content pinned to the bottom),
  a one-line **advertising banner** under the nav (server-driven campaigns shown to eligible free
  users, a weighted fair rotation — §10),
  and a client **board-style** setting (bonus-label
  mode). The visual/interaction design system is documented in
  [`UI_DESIGN.md`](UI_DESIGN.md). Inside the Telegram Mini App the client additionally
  tracks Telegram's live theme switch (`themeChanged`), fits the full device safe-area
  insets (the bottom/home-indicator strip taking the bottom bar's colour), exposes
  Telegram's native **Settings** button into the in-app settings, and syncs the
  device-independent display preferences (theme, reduce-motion, board labels — **not** the
  interface language) across the user's Telegram devices via **CloudStorage**.
- **`platform/telegram`** — the Telegram side-service (module
  `scrabble/platform/telegram`), split into two binaries that share the bot token
  (**one bot**, one optional game channel, §3):
  - the **validator** (`cmd/validator`) verifies Mini App initData and Login Widget
    data by HMAC (the bot token is the secret) and **never reaches the Bot API**, so
    it runs on the main host with no VPN. The gateway calls its gRPC API
    (`pkg/proto/telegram/v1`) over the trusted internal network during Telegram auth,
    so **game login is independent of Telegram reachability** (§10).
  - the **bot** (`cmd/bot`) runs the Bot API long-poll (Mini App launch + `/start`
    deep-links) and `sendMessage`, the only component reaching the Telegram Bot API.
    It holds **no inbound port**: it dials the gateway over a reverse **mTLS bot-link**
    (`pkg/proto/botlink/v1`) and executes the send commands the gateway pushes
    (out-of-app push, operator broadcasts), so its egress lives on a host with native
    Telegram access off the main host — a VPN sidecar in the test contour, a separate
    host in prod (§12). Its delivery commands are **platform-agnostic** (keyed by the
    identity `external_id`), so a future VK/MAX bot reuses them; only initData
    validation is Telegram-specific.

```mermaid
flowchart LR
  Client((Client / webview)) -- Connect-RPC + FlatBuffers (h2c) --> Gateway
  Gateway -- REST/JSON, X-User-ID --> Backend
  Backend -- gRPC server-stream (live events) --> Gateway
  Gateway -- in-app stream --> Client
  Backend -- pgx --> Postgres[(Postgres)]
  Backend -. embeds .- Solver[[scrabble-solver library]]
  Gateway -- gRPC (validate initData) --> Validator[Telegram validator]
  Bot[Telegram bot] -. dials, reverse mTLS bot-link .-> Gateway
  Gateway -- send commands (out-of-app push, broadcasts) --> Bot
  Backend -. operator broadcasts (gRPC relay) .-> Gateway
  Bot -- Bot API --> TgCloud((Telegram))
```

The MVP runs `gateway` and `backend` as single-instance processes inside a
trusted network. No Redis is planned (anti-replay crypto was deliberately
dropped). Horizontal scaling is explicit future work.

## 2. Transport

- **client ↔ gateway**: **Connect-RPC + FlatBuffers** over HTTP/2 cleartext
  (`h2c`). Binary payloads, server-streaming for the in-app live channel,
  first-class JS clients (`@connectrpc/connect-web` + the `flatbuffers` npm
  package). The contract is kept minimal: a single `Gateway` service (defined in
  `gateway/proto/edge/v1`) with `Execute(message_type, payload, request_id)` for
  unary operations and `Subscribe` for the live stream. The proto envelope is a
  thin carrier; the real request/response and event bodies are **FlatBuffers**
  tables (`pkg/fbs`, the `scrabblefb` namespace) inside the `payload` bytes, which
  the gateway transcodes to and from the backend's JSON. The session token rides
  in the `Authorization: Bearer` header (there is no per-request signing, §3);
  auth operations are unauthenticated and return the minted token. A unary
  operation's domain outcome rides back in `ExecuteResponse.result_code` (HTTP
  200); only edge failures (rate limit, missing session, unknown type, internal)
  surface as Connect error codes. The client treats connectivity as **state, not a per-call toast**,
  via a single **net-state machine** — a pure reducer (`ui/src/lib/netstate.ts`) behind a reactive
  store (`netstate.svelte.ts`); `connection`/`offline` are thin derived shims over it. It has four
  states: `online`; `connecting` (a call or probe is failing but still inside the anti-flap window —
  the header **"Connecting…"** spinner, chrome stays online); `offlineNoNetwork` (sustained loss — blue
  chrome, a local-only lobby, the transport **kill switch**); and `offlineVersionLocked` (the gateway is
  reachable but the build is below the hard minimum — the *update* notice, the client-version gate below). **Offline is
  implicit** — detected from failed calls, a reachability probe, a **live-stream heartbeat watchdog**
  (a silence past ~25 s of the gateway's 10 s keep-alive `heartbeat` means a silently-dead stream — e.g.
  the radio was cut with no stream error; `ui/src/lib/streamwatchdog.ts`, background-aware) and the OS hint
  (`navigator` / `@capacitor/network`), **never a user toggle** — with **hysteresis** so a brief blip lives entirely in
  `connecting` (K consecutive probe failures *or* a debounce window trips `offlineNoNetwork`; the first
  probe/call success heals back, with a toast). The transport **auto-retries with capped exponential
  backoff** — every op on a rate-limit (the gateway rejected it before processing, so it is safe), but
  only **read-only** ops on `unavailable` (a mutation is never blindly re-sent, to avoid double-applying
  one whose response was lost — its button is disabled while offline and re-issued on reconnect). A
  reachability watcher (a lightweight `profile.get` probe; a session-less native guest reconciles a
  server guest instead — that reconcile IS the probe) drives recovery, and the live `Subscribe` stream's
  drop/recovery feeds the same machine. **Telegram/VK are exempt from the offline-*play* model** —
  `offlineMode.active` is hard-gated on `offlineCapable()` (false in those mini-apps), so nothing — not
  even a version lock — puts them in offline mode: no blue chrome, no local lobby, no transport kill
  switch and no device-local create paths. The machine still tracks reachability there, though: a
  connection lost **inside an online game** is surfaced on **every platform**, driven off the machine's
  confirmed-offline state (`netState.offline`, not `offlineMode.active`) — the game screen shows a
  *connection lost* banner, **freezes** the tray and move controls, and hides the resign, add-friend/block
  and chat/dictionary controls (a started move stays a draft, re-committed by the player on reconnect), and
  the word-check tool falls back to the game's pinned on-device dictionary (`ui/src/lib/dict/check.ts`,
  exact when that dawg is cached, else *unavailable*) with its network-only complaint and external
  look-up hidden.
  **Edge hardening:** every request body on the public listener is capped at
  `GATEWAY_MAX_BODY_BYTES` (default 1 MiB — far above any legitimate payload), both at the HTTP
  layer (`http.MaxBytesReader`) and as the Connect per-message read limit, so an oversized
  `Execute` is refused (`resource_exhausted`) without buffering. The h2c server carries explicit
  sizing: `MaxConcurrentStreams` 250 (the x/net default made visible — a real client holds one
  `Subscribe` stream plus a few unary calls) and a 3-minute connection `IdleTimeout` (a live
  `Subscribe` stream keeps its connection active, so only abandoned connections are reaped); the
  `http.Server` sets only `ReadHeaderTimeout` (10 s) — Read/WriteTimeout would kill the stream.
- **Alphabet on the wire**: live play exchanges **alphabet indices**, not
  concrete letters. The rack (`StateView.rack`), the `SubmitPlay`/`Evaluate` tiles, the
  `Exchange` tiles and the `CheckWord` word are `ubyte` indices into the variant's alphabet
  (a blank is the sentinel index **255**). The client is **alphabet-agnostic**: on a
  per-variant cache miss it sets `StateRequest.include_alphabet`, and the backend embeds the
  variant's `(index, letter, value)` table (`engine.AlphabetTable`, derived from the solver
  ruleset — no dictionary) for display; the client caches it by variant and renders the rack
  and the blank chooser from it. The backend maps index↔letter at its REST edge, so the
  gateway forwards indices **verbatim** (it holds no alphabet table) and the engine's
  letter-based domain API — shared with the robot — is unchanged. The table is pinned by the
  solver version, so it cannot drift from the running backend. The **move journal, history
  and GCG are unaffected** (they stay decoded concrete characters, §9.1).
- **gateway ↔ backend (sync)**: plain HTTP REST/JSON. The gateway injects
  `X-User-ID` (and the session's trusted `X-Platform`, §3) for authenticated
  requests, plus the caller's real IP as **`X-Forwarded-For`** on **every** call
  (carried on the request context), so the backend records the real caller — the
  account's last-login IP, chat/feedback moderation, the access log — rather than
  the gateway's own connection address; `backend` never re-derives identity or platform from the body. Because every sync call targets the one backend host, the
  gateway's REST client widens its keep-alive pool well past the stdlib default
  of 2 idle connections per host; otherwise the per-request connection churn
  exhausts ephemeral ports and burns gateway CPU under load (see
  [`../loadtest/REPORT.md`](../loadtest/REPORT.md)).
- **backend → gateway (live)**: a single gRPC server-stream carries live events
  (your-turn, opponent-moved, chat, nudge). The gateway bridges them to the
  client's in-app stream while the app is open. Out-of-app delivery uses
  platform-native push via the platform side-service.
- **Client-version gate — two tiers** (native long-tail protection): a bundled native install (§13) can be
  arbitrarily old, so the client stamps its build into an **`X-Client-Version`** request header (the app
  version from `pkg/version` / `__APP_VERSION__`), read by the gateway **before it decodes the FlatBuffers
  payload**. The version rides the **outermost stable layer** — an HTTP header, never the FBS payload —
  because protobuf envelopes and headers are version-tolerant by design while the FBS payload is the layer
  that breaks. Enforcement is **per-call, not a Hello RPC** (`Execute`/`Subscribe` check it at the top,
  before registry lookup / auth / decode), so the first online call is already gated. Both tiers **fail
  open** (an absent or unparseable header passes) and are **dormant** until deliberately configured (both
  vars empty ⇒ off, web behaviour unchanged).
  - **Hard (critical) — `GATEWAY_MIN_CLIENT_VERSION`**: `version < min` ⇒ the domain envelope
    `result_code = "update_required"` (HTTP 200) on `Execute` and `CodeFailedPrecondition` on `Subscribe`;
    the client makes **zero** successful requests. Its reaction is **graceful, not terminal**: it enters
    `offlineVersionLocked` (offline mode) and shows a **dismissable notice** — **"Update"** (native → the
    store listing `VITE_RUSTORE_URL`, web → reload) or **"Play offline"** (dismiss → keep playing local
    vs_ai / hotseat). The lock is sticky until an actual update on the next launch.
  - **Soft (recommended) — `GATEWAY_RECOMMENDED_CLIENT_VERSION`**: `min ≤ version < recommended` ⇒ the
    gateway sets an **additive** `X-Update-Recommended: 1` **response header** on the served `Execute`
    response (the call still succeeds); a transport interceptor turns it into a **dismissable "update
    available" nudge** in the lobby — play continues, nothing blocks. `recommended` must be **≥ `min`**
    (validated at config load); empty ⇒ the soft tier is off.
- **Frozen wire contract** (so any build, however old, can always recognise "update required"): three
  things are permanent — (1) the protobuf envelope field numbers in `edge.proto` are never renumbered or
  reused; (2) the `update_required` sentinel — the `result_code` string **and** the `FailedPrecondition`
  code — never changes; (3) the FBS schema stays **additive** (append trailing fields; `(deprecated)`, never
  delete — deleting shifts field IDs and breaks older readers). A breaking wire change happens only *inside*
  the FBS payload, which is exactly what the gate guards. **Deploy discipline:** the production rollout that
  ships an incompatible wire change **also** bumps `GATEWAY_MIN_CLIENT_VERSION` to that release, in the same
  rollout (see [`../deploy/README.md`](../deploy/README.md)).
- **Gate × offline** (UX rule): offline is the net-state machine's `offlineNoNetwork` / `offlineVersionLocked`
  (implicit — no toggle) and its kill switch refuses calls, so the hard gate never fires *while already
  offline* — an old install always plays local vs_ai / hotseat. A hard `update_required` on a
  **user-initiated online call** degrades to `offlineVersionLocked` + the dismissable notice (above); the
  silent background guest reconciliation (§3) **swallows** an `update_required` and stays a local guest
  rather than interrupting local play.

## 3. Authentication & sessions

Platform-native, deliberately simple: **no Ed25519 client keys, no per-request
signing, no anti-replay crypto** (these were considered and dropped — players
arrive from a platform rather than completing a mandatory registration).

- The gateway validates the originating credential **once** — Telegram `initData`
  (delegated to the **validator's** `ValidateInitData` RPC, which holds the bot token —
  the HMAC secret — so it never reaches the gateway), a **VK Mini App** launch (verified
  **in-process** by `gateway/internal/vkauth`: HMAC-SHA256 over the signed `vk_*` params
  under the VK app's protected key `GATEWAY_VK_APP_SECRET`, base64url — a pure offline check,
  as VK signing needs no API round-trip, so no side-service), an email-code login, or a guest
  bootstrap — then mints a **thin opaque server session token** (`session_id`). First
  Telegram/VK contact seeds the new account's language (Telegram's `language_code` / VK's
  `vk_language`) and display name (§4; VK omits the name from the signed params, so the client
  reads it via `VKWebAppGetUserInfo` as an unsigned, cosmetic seed). The validator runs on the
  main host and never reaches the Bot API, so login does not depend on Telegram or the remote
  bot being up (§10, §12). VK launch params carry no built-in expiry (unlike Telegram's
  `auth_date`), so freshness is not enforced — the minted session is the short-lived credential,
  and a replay only re-authenticates the same `vk_user_id`.
- **Single bot.** The platform side-service runs **one bot** (one token + one optional
  game channel), split into a home **validator** and a remote **bot** that share the
  token. `ValidateInitData` (the validator) validates `initData` against that single
  token, **rejects a bot user** (the signed `is_bot` flag), and returns only the Telegram
  user identity — there is no per-bot "service
  language" and no supported-languages set on the wire. The bot's chat messages and
  out-of-app push are
  rendered in the recipient's **interface language** (`preferred_language`, en/ru), not in
  any bot-scoped language, and the friend-invite **share link** (and its caption) point at
  that one bot. First Telegram contact seeds the new account's `preferred_language` from the
  launch `language_code` (§4), but the **interface language follows the device** — the system
  guess, or an explicit Settings choice saved locally — and the bot never dictates the UI.
  `preferred_language` is then **reconciled to the active interface locale on every session
  adopt** (not only on a Settings change; a no-op for guests and when already equal), so the
  server-rendered language surfaces — this push and the ad banner — always match the UI rather
  than stranding a user who never opened Settings on the creation-time seed.
- **Variant preferences (New Game gating).** Which variants a player may be matched into is a
  per-user **profile** setting — `variant_preferences`, a set of `engine.Variant` labels
  (`scrabble_en`, `scrabble_ru`, `erudit_ru`) edited on the Settings/Profile screen. New
  accounts default to **Erudit only** (a DB column default); at least one variant must stay
  selected. The picker is ordered **Erudit-first** everywhere. The preference gates the New
  Game picker on every create path the player **initiates** — auto-match, vs-AI and a friend
  invitation the player **creates** — and the backend **enforces** it on those paths (a chosen
  variant outside the caller's preferences is rejected with HTTP 400). An **invited** friend
  may still **accept** an invitation in **any** variant (accepting is never gated), and opening
  or playing existing games of any variant is unrestricted. This replaces the former
  login-language variant gating; it is a per-account product affordance plus a server-side
  create-path check, distinct from `preferred_language` (the interface language) and from a
  game's variant language.
- The client holds `session_id` in memory for the app session (browser/OS
  storage is optional and may be unavailable; losing it means re-login).
- The gateway caches `session → user_id` and injects `X-User-ID`. Session
  records live in `backend`, which stores only a **SHA-256 hash** of the opaque
  token (never the plaintext), keeps a warmed in-memory cache for fast
  resolution, and treats sessions as **revoke-only** — they have no TTL and live
  until explicitly revoked (`status` → `revoked`). A revoke can target one token or,
  on an account merge (§4), **every** session of the retired account
  (`RevokeAllForAccount`, which also evicts them from the warm cache).
- **Trusted execution platform.** Each session also records a `platform` — a `kind`
  (`vk`/`telegram`/`direct`) plus a device `subtype` (`ios`/`android`/`web`) — captured
  at creation, so a store-compliance gate has an unforgeable execution context
  (`docs/PAYMENTS.md` §8). `kind` is derived server-side from the validated establish
  path (the VK launch, the Telegram initData, or a web-native session), **never a client
  field**; the `subtype` is trusted only for VK (it rides inside the signed `vk_platform`
  parameter) and is client-reported best-effort for telegram/direct. VK and Telegram
  re-mint (and so re-validate and re-capture) on every cold start; a direct session
  captures it once. The gateway injects the resolved platform as **`X-Platform`**
  (`<kind>/<subtype>`) alongside `X-User-ID`, sourced from the session and never the
  request body; an absent or unrecorded platform — a session predating the feature, or
  one the gateway could not attribute — is **untrusted**, treated as view-only (no
  spends). Consistent with the revoke-only model, platform freshness carries no separate
  TTL; a stale untrusted session recovers on its next VK/TG cold-start re-mint, or (for a
  reused direct/email session) on re-login.
- **Guest** = ephemeral web session (no platform, no email). A guest is backed by
  a durable `accounts` row flagged `is_guest` and carrying **no identity** — the
  row is a technical necessity (the `sessions` and `game_players` foreign keys
  require one, the same way the robot pool is durable), not a profile: no
  friends, statistics or history are kept for it, and it is restricted to
  auto-match. A background **guest reaper** deletes an abandoned guest — flagged
  `is_guest`, holding no game seat, older than `BACKEND_GUEST_RETENTION` — on a
  `BACKEND_GUEST_REAP_INTERVAL` sweep, so transient guest rows do not accumulate.
  Platform and email users are auto-provisioned **durable** accounts with an
  identity.
- **Local guest vs. server guest** (native offline-first, §13). The **native** app splits the local-play
  identity from the server account. A **local guest** is device-local with **no DB row**: a device-generated
  id + the localized default name (*Guest* / *Гость*) persisted on the device, existing from the very first
  launch with no network. It fills the human seat in a local vs_ai game and is the "you" for device-local
  games; a purely-offline user never consumes a server row. The **server guest** is the durable `is_guest`
  row above — minted **lazily** via `auth.guest` the first time the app reaches the network, its session
  cached and reused (exactly one per device, guarded by the cached session so it never double-mints). It
  unlocks online features (matchmaking, friends). **Reconciliation:** on gaining network with no server
  session the native app silently `auth.guest`s in the background and adopts it — best-effort, swallowing an
  `update_required` to stay a local guest rather than interrupting play (the gate × offline rule, §2).
  **Local games stay device-only** (both local vs_ai and hotseat persist only on the device); an identity
  transition never migrates them. Web/PWA/Telegram/VK are unchanged — they have no local guest and keep the
  prior online-session rule.

> **Decision (2026-06-20) — single bot, preference-based variant gating.** The former
> two-bot model (one bot per service language, with `accounts.service_language`, a
> `supported_languages` set on the `Session` wire and game-language push routing) was
> collapsed into **one unified bot**: it renders chat and out-of-app push in the
> recipient's interface language (`preferred_language`), with no per-bot routing. New
> Game variant gating moved off the login language onto a per-user profile setting
> `variant_preferences` (default Erudit only, server-enforced on the caller's create
> paths; an invited friend may still accept any variant, and a Telegram **promo deep-link** seeds extra variants — e.g. English Scrabble — onto a brand-new account via the validated `start_param`). The per-bot env vars and
> `GATEWAY_DEFAULT_SUPPORTED_LANGUAGES` were removed; the wire dropped
> `service_language`/`supported_languages` and the push `language` routing field, and
> gained `variant_preferences` on Profile/UpdateProfile.

## 4. Accounts, identities, linking & merge

- One internal account may carry several **platform identities**
  (`telegram`, `vk`, …) plus an optional **email** identity. First contact from
  a platform auto-provisions a durable account bound to that platform identity.
  Concretely, platform and email identities share one `identities` table keyed by
  a unique `(kind, external_id)`; email is an identity with `kind=email` and a
  `confirmed` flag. A synthetic `kind='robot'` identity backs each pooled
  robot opponent (§7). The **email confirm-code flow** binds an email to the
  authenticated account: a 6-digit code (stored only as a SHA-256 hash, 15-minute
  TTL, ≤ 5 attempts) is sent as a **branded HTML + plain-text email** through a
  `Mailer` seam (go-mail over the shared relay — TLS chosen by port, implicit on
  465 and STARTTLS otherwise, or forced by config for a non-standard port; the
  server certificate validated against the system roots, no client certificate; a
  development log mailer when no relay is configured) and, once
  verified, attaches a confirmed email identity. Sends are throttled per recipient
  (a 60-second cooldown and a five-per-hour cap). Links in the email are built from
  a configured public base URL, never a request Host header (anti-injection). The email
  also carries a **one-tap confirm deeplink** (`/app/#/confirm/<token>`, an opaque
  256-bit token stored only as its SHA-256, 12-hour TTL): a login mints a session in the
  browser that opens it (magic-link), a link confirms the identity and emits a `notify`
  profile-refresh to the in-app session, a change switches the account's email in place,
  and a would-be merge is deferred to the interactive flow. A login requested from an installed **PWA** omits the deeplink
  entirely — its email carries the code only, typed in the same window, since the link would open
  in a separate browser whose minted session cannot reach the PWA. The confirm runs on load; the token rides the URL fragment (never
  sent to the server), so a plain link prefetch cannot reach it, and the manual
  six-digit code is the fallback if an aggressive scanner runs the page. An
  **email-login** account is created flagged `is_guest` and stays reapable until the
  code is confirmed — so an abandoned, never-confirmed login frees its reserved
  address — with confirming clearing the flag. Accounts and identities use
  application-generated
  **UUIDv7** primary keys. A service flag `paid_account` (lifetime one-time
  payment; no purchase flow yet) is carried on the account and ORed on a merge.
- **Linking** is initiated from an authenticated profile and proves
  control of the identity before attaching it: **email** through the confirm-code
  flow, **Telegram** through the web **Login Widget** (validated by the validator,
  HMAC under `SHA-256(bot_token)` — distinct from Mini App initData; the gateway
  passes the trusted `external_id` to the backend, as for `auth.telegram`), and **VK**
  through **VK ID web login** (`gateway/internal/vkid`): the browser runs the VK ID raw
  OAuth 2.1 flow with PKCE — a full-page redirect to VK's hosted login, no `@vkid/sdk` —
  and the gateway completes the **confidential code exchange** server-side under the VK
  "Web" app's protected key (a distinct VK app from the Mini App) to obtain the trusted
  `external_id`. A browser has no signed launch parameters, so unlike the VK Mini App
  (offline HMAC, §12) this makes an outbound call to `id.vk.com`, and it is web-only — a
  full-page redirect would strand a native Mini App webview. The
  request step **always** sends/accepts the proof (no pre-send "already taken"
  signal, so a probe cannot enumerate registered addresses); a required **merge**
  is revealed **only after** the proof is verified and is performed behind an
  explicit, irreversible confirmation (for VK, whose authorization code is single-use,
  the merge re-authorizes for a fresh code). A free identity is simply attached (and a
  guest is promoted to durable, clearing `is_guest`).
- **Unlink** detaches a platform identity (`telegram`/`vk`) from the profile. The
  backend **refuses removing the last identity** (`ErrLastIdentity`), so an account
  never becomes unreachable; the UI mirrors the guard by hiding Unlink when only one
  method remains. **Email is never unlinked — it is changed.**
- **Change email** mails a confirm-code (`purpose=change`) to the new address on the
  authenticated account and, on confirm (code or one-tap deeplink), **atomically
  replaces** the account's email identity with the new one, freeing the old address. A
  new address already confirmed by **another** account is refused **without disclosure**
  (a neutral "check the address or contact support") and **never merged** — the anti-
  enumeration check is only reachable by someone who controls the new mailbox.
- **Deletion is legal retention, not erasure** (`internal/accountdelete`). The account row
  survives as a **tombstone** (`accounts.deleted_at`) — its chat/complaint foreign keys
  have no cascade, so a hard delete is impossible. `AnonymizeAndTombstone` **journals**
  every live identity into an append-only **`retained_identities`** log (the legal dossier)
  then removes it, freeing the `(kind, external_id)` for a new account to reuse; it scrubs
  the live `display_name` → **`[Deleted]`** (an unspoofable sentinel — the editable-name
  rule forbids brackets) while snapshotting the real name into `deleted_display_name`,
  anonymises the game-seat snapshots, and drops the account's own friendships / blocks /
  invitations / friend-codes / drafts / pending-codes. **Chat, feedback and complaints are
  kept.** The orchestration a layer up resigns the account's active games (so opponents are
  not stranded), **drops its all-robot games** (no human opponent; children cascade), and
  revokes its sessions. Every credential detachment — **unlink, email change, delete and a
  merge collision** — writes a `retained_identities` row (`reason`), so the dossier keeps the
  full timeline. (A merge that would otherwise leave the survivor with two identities of one
  kind — e.g. each account held a confirmed email — keeps the primary's and journals the
  secondary's with `reason=merge` before dropping it.)
  Step-up is a mailed code (`purpose=delete`, no deeplink — a stray click must not delete;
  `ConfirmByToken` refuses a delete token) for an email account, else a typed phrase.
  `last_login_at` / `last_login_ip` are stamped on the cold-load profile fetch (throttled
  to once an hour). A **two-year TTL reaper** (from the event) purges the whole dossier —
  the journal, plus a deleted account's feedback thread and dossier PII — while the chat and
  the tombstone row stay.
- **Merge** retires the account that owns the linked identity into the **current**
  account, in a single transaction (`internal/accountmerge`): statistics summed
  (counters incl. moves/hints added, max points kept, and the per-variant best moves
  merged keeping the higher-scoring play), the hint wallet summed, `paid_account` ORed, identities
  repointed, games / chat / complaints transferred, friends and blocks
  de-duplicated (friendships keep the strongest status accepted>pending>declined),
  pending invitations/codes dropped, and the secondary kept as an **audit
  tombstone** (`accounts.merged_into`/`merged_at`) so a shared **finished** game's
  no-cascade foreign keys stay valid — its seat there is left untouched. A merge is
  **refused** only when the two share an **active** game. The current account is the
  primary, **except** when the initiator is a **guest** and the linked identity
  already has a **durable** owner: then the durable account wins, the guest's active
  games move into it, the guest is retired, and a **fresh session** is minted for the
  durable account (the client switches to it). The secondary's sessions are revoked
  (§3). High blast-radius; isolated and well-tested.

## 5. Game engine integration (`scrabble-solver`)

`backend` embeds the solver library in-process behind `internal/engine`, the
only package that imports `scrabble-solver` (see [`CLAUDE.md`](../CLAUDE.md) for
the solver's public API and constraints). The engine is a self-contained rules
library — no persistence, transport or scheduling; the game domain drives it.
Key points:

- Variants at launch: **English Scrabble**, **Russian Scrabble**, **Эрудит**
  (`engine.Variant`, mapping to `rules.English()` / `RussianScrabble()` /
  `Erudit()`). Эрудит's specifics (non-doubling centre, `ё` with no tiles, 3
  blanks, a 15-point bonus) live entirely in the solver ruleset, so the engine
  treats every variant uniformly.
- **Dictionaries** are committed DAWGs loaded with `dawg.Load` from the
  directory `BACKEND_DICT_DIR`; `backend` loads the `engine.Registry` at startup
  as a hard dependency (like migrations), so a missing dictionary fails the boot.
  The registry holds dictionaries in memory addressed by `(variant,
  dict_version)`, tracking the latest version per variant, and answers the
  word-check tool through `Registry.Lookup`.
- **Dictionary versioning — pin per game, update through the console.** A game
  records the `dict_version` it started on and finishes on it; new games pin the
  **active version**, the single source of truth persisted in the
  `dictionary_state` singleton and restored on boot, so an operator's choice
  survives a restart. Multiple versions are resident at once. `BACKEND_DICT_DIR` is
  a writable named volume seeded from the image on first boot (its nonroot
  ownership is inherited from the image, so the runtime can write it); the flat
  directory is the seed version, labelled `BACKEND_DICT_VERSION` — set from the
  build's `DICT_VERSION`, so the resident label equals the release tag — and each
  uploaded version lives in a `BACKEND_DICT_DIR/<version>/` subdirectory. The admin
  console **updates** a dictionary online (`internal/dictadmin`): an operator
  uploads the `scrabble-dawg-vX.Y.Z.tar.gz` release archive, previews the
  per-variant words added/removed against the active dictionary (`engine.DiffWords`
  enumerates both DAWGs and decodes only the differences), and confirms — the
  backend extracts the archive into the version subdirectory (hardened against
  path-traversal, symlink and decompression-bomb attacks), loads it via
  `Registry.LoadAvailable`, and makes it the active version. Versions are immutable
  (re-uploading a resident tag is rejected), so in-flight games keep their pinned
  version while new games use the new one. A restart re-loads every resident
  version via `engine.OpenWithVersions` (the flat seed plus each subdirectory,
  skipping the `.staging/` upload area) and restores the active pointer from
  `dictionary_state`. The volume preserves uploaded versions across redeploys, so
  in-flight games keep replaying on the version they pin. The build's
  `BACKEND_DICT_VERSION` is **deploy-authoritative**: on boot `engine.DeliverVersion`
  copies the image's DAWGs — kept unshadowed at `BACKEND_DICT_SEED_DIR`, since the
  volume mount hides the image's `BACKEND_DICT_DIR` copy — into
  `BACKEND_DICT_DIR/<version>/` when that version is not already resident (**add-only**:
  the flat seed and prior uploads are never touched), and `InitActiveVersion` makes it
  the active version. So a redeploy that bumped `DICT_VERSION` moves new games to the
  new dictionary automatically, while old games stay on theirs — no console step. The
  one exception: a version installed out-of-band through the console that is **newer**
  than the build version and still resident is kept, so a restart on an older image never
  downgrades a hotfix (versions compare numerically). Because the flat DAWGs carry no
  embedded version, `OpenWithVersions` records the version the flat directory was first
  seeded at in a `.seed_version` marker and treats it as authoritative for **that flat
  directory** (the **seed-drift guard**): a later `BACKEND_DICT_VERSION` never relabels
  the already-seeded flat bytes — it is delivered as a **new subdirectory** instead — so
  the guard still prevents mis-serving a game pinned to the flat label. The console
  remains the way to update a dictionary **without** a redeploy (an out-of-band upload).
  Set `DICT_VERSION` per contour from the deploy's `TEST_`/`PROD_DICT_VERSION`. (The
  dictionaries ship as a versioned **release artifact** from the `scrabble-dictionary`
  repo.)
- Move generation/validation/scoring use `Solver.GenerateMoves` (ranked),
  `Solver.ValidatePlay` and `Solver.ScorePlay`; board mutation uses
  `scrabble.Apply`. The engine adds its own deterministic, seeded tile **bag**
  that can return tiles (an exchange needs this; the solver's self-play bag
  cannot).
- **`engine.Game`** is the in-memory match state and the pure rules engine: it
  deals racks, applies legal plays / passes / exchanges / resignations, refills
  from the bag, keeps the scores and whose turn it is, and **detects the end of
  the game** — empty bag with an empty rack, or six consecutive scoreless turns,
  applying the end-game rack-value adjustment, or a resignation. On a
  **resignation the resigner keeps their accumulated score (no rack adjustment)
  and never wins**: the win goes to the highest score among the remaining seats,
  unconditionally the other player in a two-player game. A player may resign **on the
  opponent's turn** (a forfeit is not a turn-scoped move): `engine.ResignSeat(seat)`
  resigns that player's own seat whoever is to move, and the game domain skips the turn
  check for resign. The engine exposes a
  decoded, solver-free API (`SubmitPlay`/`SubmitExchange`/`EvaluatePlay`/
  `HintView`/`Hand`) so `internal/game` drives it without importing the solver.
  A play's **orientation (H/V) is inferred from the placed tiles and the board**,
  not supplied by the caller: two or more tiles fix it by the line they share; a
  lone tile takes the axis along which it abuts existing tiles (the longer word
  winning, horizontal on a tie), so a single tile extending an existing word
  vertically is accepted. Journal replay instead trusts the **stored** direction
  (`SubmitPlayDir`, §9.1) to reproduce a committed game exactly rather than
  re-deriving it.
- The **game domain** (`internal/game`) owns everything the engine does not —
  persistence, turn scheduling, the configurable turn timeout / auto-resign, the
  hint budget, word-check complaints, history and GCG — and is the engine's only
  consumer. Timeout auto-resign reuses `engine.Resign`, recording the move as a
  timeout, so it inherits the resignation win/loss.
- History is dictionary-independent (§9.1): the engine emits decoded
  `MoveRecord`s and reconstructs the board from them with `engine.ReplayBoard`
  (alphabet only, no dictionary).
- The **client mirrors this validation path for an instant move preview** (the
  local eval, `ui/src/lib/dict`): the dawg reader and the
  validate/score/direction slice are ported to TypeScript, byte-for-byte against
  this engine and pinned by the `conformance` CI job. Composing a move is scored
  on-device with no round trip. It is an **advisory accelerator only** —
  `submit_play` re-validates on the server, so a wrong or spoofed local result
  cannot corrupt a game. The pinned per-game dictionary blob is fetched once
  through a **session-gated `GET /dict/{variant}/{version}`** edge route
  (immutable; cached in IndexedDB best-effort) and reused across sessions; any
  miss, storage eviction or a bad-connection breaker falls back to the network
  `evaluate`. The warm-up overlay is `docs/UI_DESIGN.md`. The **on-board rendering** of a staged
  play — the legality tint, the cells a formed word covers, and where the score badge anchors — is a
  separate **pure geometry helper** (`ui/src/lib/formed.ts`) derived from the board and the staged
  tiles, so it works regardless of which eval path produced the preview (the badge's number still
  comes from the preview's score): legality and score stay with the eval, geometry with the board.

## 6. Game rules

- **Word legality: validate-at-submit.** An illegal play is rejected by
  `Solver.ValidatePlay`; there is no challenge phase.
- **Multiple words per turn (Russian games).** Russian variants carry a per-game
  **single-word rule**, chosen on New Game (default **off** = single word; on = standard
  Scrabble). Off, only the **main word** along the play direction is validated and scored —
  perpendicular cross-words are ignored, including in robot move generation and the
  unlimited move preview; on, every cross-word must be a real word and is scored. The main
  word must still run **through an existing tile along its own line** to connect: a play that
  forms no word along the direction it is laid — touching the board only perpendicular to
  itself — is illegal even though its cross-word is never checked, and for a single tile that
  abuts the board on both axes the engine plays the higher-scoring legal orientation. The
  single-word rule is therefore **not a superset** of the standard rule: it forbids parallel
  plays the standard rule allows and admits in-line plays whose cross-words are invalid. The
  engine threads it as `scrabble.PlayOptions{IgnoreCrossWords}` (solver `v1.1.1`); the
  first-move centre rule is unaffected. The "Russian-only" limit is a **UI affordance**:
  the backend and engine are variant-agnostic about the flag, and English games always send
  it on (standard). For auto-match the rule is part of the matchmaking key, so only players
  who chose the same rule are paired (the rule field rides every create/enqueue request, so
  matchmaking stays one uniform path).
- **End of game**: the bag is empty **and** a player empties their rack, **or**
  **6 consecutive scoreless turns** (passes/exchanges), **or** a resignation, or
  a missed turn. The **per-game turn timeout** is chosen at creation
  (5/10/15/30 min, 1/2/3/6/12/24 h; default 24 h); a turn not made within it
  becomes an automatic resignation, applied by a background sweeper. The sweeper
  honours each player's **away window** — a daily local-time sleep interval on the
  account (default 00:00–07:00, midnight-cross aware) — so a player is never
  timed out while asleep. A game whose journal can no longer be replayed — a
  committed move made illegal by a later rule change — is instead closed as a
  **draw** (`aborted`) on the next open, never left unopenable (§9.1).
- **First move (who goes first)**: decided by the official draw — each seated player
  draws one tile and the tile closest to "A" leads (a **blank supersedes all letters**);
  players tied for the best tile re-draw until a single leader remains. Each draw uses
  **fresh entropy** (`crypto/rand`), **not** the deterministic bag `seed`, so the draw is
  genuinely random and its **record** — not a seed — is the only account of the outcome. The
  leader takes **seat 0** (which moves first), the rest keeping their seating order, so the
  engine and journal replay are unchanged (seat 0 always leads). A directly-seated game
  (friend/AI) draws at creation. **Auto-match** draws when the game **opens**, with the
  not-yet-arrived opponent as a synthetic placeholder (`uuid.Nil`, whose draw rows are
  back-filled to the real opponent on join), so the opener's seat is fixed up front and they
  may make their opening move while waiting with no later reseating. The draw is **recorded**
  with the game (`game_setup_draws`, §9) and surfaced only in the admin console's
  step-by-step game replay — **not shown to players** today; it is kept for future
  tournaments, where it becomes a manual, per-tile external call. It is modelled as a discrete
  "player N draws a tile" step so that API is a thin driver over the same component.
- **Players**: auto-match is always 2 players; friend games are 2–4 players.
  `backend` owns turn order and the bag for any player count. A resignation or
  timeout in a two-player game ends it with the other player winning. In a game
  with **three or more seats** a resignation or timeout **drops that seat and the
  rest play on** — the engine skips the resigned seat in the turn rotation and
  excludes it from the win, finishing the game (the sole survivor wins) only once
  one active seat remains, or by the ordinary end conditions among the active
  seats. A per-game **drop-out tile disposition**, chosen at creation
  (`dropout_tiles`: `remove` from play — the default — or `return` to the bag),
  governs the leaver's rack, which is **never revealed** to the remaining players;
  it is recorded for deterministic journal replay. (Two-player games end on the
  first drop-out, so the disposition does not affect them.)
- **Hint**: governed by two per-game settings — whether hints are allowed and the
  starting per-player allowance — plus a per-account hint **wallet**
  (`hint_balance`, spent after the allowance; top-ups are a later feature). A hint
  reveals the top-1 ranked move (`GenerateMoves[0]`). The lobby/tournament caller
  picks the per-game defaults (e.g. one in casual random games, none in
  tournaments). The client **lays the hinted tiles onto the board** as a pending
  placement and leaves the commit to the player. When the rack has no legal move the
  service spends **nothing** and returns `ErrNoHintAvailable` — surfaced as the distinct
  result code `no_hint_available` (separate from `hint_unavailable`) so the UI can say
  "no options" rather than "no hints left". The hint count shown to the player is the
  per-game allowance remaining **plus** the global wallet; because the wallet is global,
  `game.state`/`game.hint` carry it as a separate `wallet_balance` field beside the combined
  `hints_remaining`, so the client derives the per-game allowance (`hints_remaining -
  wallet_balance`, which it may cache per game) and reads the wallet **live** from the
  profile — otherwise a wallet hint spent in one game would leave a stale, too-high count
  cached on every other game.
- **Word-check tool**: unlimited dictionary lookups against the game's pinned
  dictionary; each result offers a **complaint** (complainant, game, variant,
  dict_version, word, the disputed result, an optional note) that lands in the admin
  review queue. An operator resolves it (`open → resolved`) with a **disposition** —
  reject, accept-add or accept-remove; the accepted ones form a derived
  **pending-changes** list that feeds the offline dictionary rebuild and is marked
  applied once the rebuilt version is installed through the console (§5, §12).

## 7. Robot opponent

Substitutes for a human in 2-player auto-match: the matchmaking reaper seats it in an
open game's empty opponent slot when no human has joined within the wait window (§8).
It lives in `internal/robot` and plays as an ordinary seated account through the game
service, so only `internal/engine` imports the solver. In the random/auto-match path it is
designed to be indistinguishable from a person.

The same robot serves **two quick-game modes**, chosen by the player on New Game and recorded
on the game as **`games.vs_ai`**: the **random** path above (disguised as a person) and an
**honest-AI** path the player knowingly picks (shown as **🤖**). The mode is a per-game flag,
never derived from the opponent account, so the disguised path is never revealed. In an
honest-AI game the robot keeps its per-game strength (`playToWin`) and margin band but **moves
at once** — no sampled delay, no sleep window, no proactive nudge; chat and nudge are disabled,
the opponent is shown as 🤖 everywhere, and the game records **no statistics** for either seat
(practice, like a guest game). The fast reply is **event-driven**: committing a move (or
creating the game) triggers `robot.DriveGame` immediately via the game service's after-commit /
after-create hook (`game.Service.SetAITrigger`, a func value so the game package never imports
the robot package), with the periodic driver as the fallback. There is **no short move
timeout**; instead the game is created with `turn_timeout_secs = AIInactivityTimeout` (**7
days**) and the existing turn-timeout sweeper resigns the overdue seat — since the robot moves
at once, only the human is ever on the clock, so the per-turn timeout doubles as the
"abandoned after 7 days of inactivity → loss" rule with no new column or sweeper. An AI
game emits **no `your_turn`** (the instant reply makes it redundant; `opponent_moved`
still advances the UI), its **GCG export labels the robot seat "AI"**, and the
games-started / -abandoned metrics carry a **`vs_ai`** attribute so AI and human games
chart separately (the admin `/games` list and game card also show the AI flag). A finished
honest-AI game the player **left** — `end_reason` `resign` or `timeout` — is also dropped from
that player's own *finished* lobby list by `game.Service.ListForLobby` (a lobby-only filter over
`ListForAccount`); the admin console and the account-merge count keep the full set, and a
normally finished AI game stays. The filter keys on the game's end reason, not on which seat
left, so it extends to any player should the robot ever resign.

The robot keeps **no per-game state**: every choice is derived deterministically
from the game's bag `seed` (a restart-stable FNV-1a mix), so a background driver
(`robot.Service.Run`, mirroring the turn-timeout sweeper) recomputes the same
behaviour on every scan and after a restart — the same philosophy as journal
replay. A pool of durable accounts — each a `kind='robot'` identity (§4), keyed
`robot-<lang>-<index>` and provisioned at startup with **chat blocked but friend
requests open** — a request to a robot is accepted as pending and expires unanswered
(the robot never responds), mirroring a human who ignores it; the chat
block backs the human-like names (there is no DM surface; chat is per-game).

**Per-game names.** A seated player's display name is **snapshotted on the seat**
(`game_players.display_name`) when the seat is taken — a human's then-current name, or a
disguised robot's freshly composed name — so the name an opponent sees is frozen for the life
of the game (a later rename never rewrites past games) and a small pool of accounts presents
as an ever-changing crowd. A reader falls back to the account's current name when a seat
carries no snapshot (a pre-migration row). Each durable robot account still seeds a stable
fallback name (32 composed per language), but the disguised reaper stamps a **fresh per-game
name** drawn from a wide composed corpus: Western first/surname pools per locale (English,
German, Spanish, Italian, French, Portuguese) in one of three forms (first only / first +
surname initial / first + full surname), native Japanese/Chinese names, a gender-agreed
Russian pool, and human-style handles (a stem, an optional `.`/`_`, an optional trailing
number). Selection is **variant-aware**: a Russian game (Russian Scrabble or Эрудит) draws a
Cyrillic name or handle with at most ~20% Latin analogue and **never a CJK script**; an
English game draws the full international corpus. Every composed name stays within the
editable display-name format (`account.ValidateDisplayName`) — which now admits a **trailing
run of up to five digits** (so "Player2007"-style handles are valid for humans too) — so the
disguised robot stays indistinguishable from a person.

- **Balance**: at game start it decides once whether to play to win, with
  `P(play-to-win) ≈ 0.40` (so the human wins ≈ 60%), derived from the seed.
  Adaptive difficulty is post-MVP.
- **Margin targeting**: each turn it picks from the ranked candidates
  (`engine.Candidates`) the move whose resulting lead (playing to win) or deficit
  (playing to lose) is closest to a small band (**1–30 points**), rather than
  always the maximum; with no legal play it exchanges a full rack when the bag can
  refill it, else passes. On **≈20%** of moves through the opening and midgame it
  **deviates** — playing that single move toward the *opposite* band (a winning
  robot eases off, a losing one surges ahead), so the chosen strategy may not pan
  out, which favours the human; the deviation chance tapers linearly to **0 over
  the last 14 tiles in the bag** and is **0 once the bag is empty**, so the endgame
  follows the per-game intent strictly. It is **deterministic from the seed**
  (`mix(seed,"deviate",moveCount)`), a per-move wobble that leaves the per-game
  play-to-win intent (and the admin card) unchanged.
- **Timing**: the per-move delay is **move-number-aware** — a right-skewed sample
  (exponent k=4, short delays frequent) from a band that interpolates from
  **[3, 10] min** at the first move to **[10, 90] min** by ~28 moves, so openings are
  quick and the endgame can run long, clamped to **[1, 90] minutes**; it
  **sleeps 00:00–07:00** anchored to the **opponent's** profile timezone with a
  per-game drift of **±3 h** (fallback UTC), so its night overlaps the human's
  rather than running anti-phase; on a daytime nudge it replies near the move's lower
  band; it proactively nudges the idle human on a **sparse, randomized schedule** — every nudge
  waits a uniform random **9-12 h** (the first measured from the turn start, each later one from
  the previous nudge), so a neglected turn gets only a handful of widely-spaced reminders. A nudge
  that would fall inside the sleep window is skipped and fires at the first scan after wake.
- **Dead-endgame timing**: once the **two most recent moves are both passes**, the board and the
  robot's rack are frozen and it is bound to pass again, so the robot drops the long late-game
  think time and answers on a **shortened schedule scaled to the human's own last (pass) think
  time** — a uniform sample in **[0.8, 1.5]×** of it, clamped to **[30 s, 8 min]** and taken as a
  **min** with the normal delay, so it never slows down. A slow human collapses to the 8-min cap (a
  decided game is not dragged out); a fast human is tracked, with the floor keeping the robot from
  passing suspiciously instantly. The anchor (the gap between the last two journal entries) reads the
  move journal only — no schema change — stays deterministic from the seed, and still defers to the
  sleep window.
- **Observability**: robot accounts accrue ordinary statistics (§9) — the
  authoritative balance metric (target ≈ 40% robot wins) — and a
  `robot_games_finished_total` OTel counter plus a per-finish log give a live view.
  The **admin game card** surfaces each robot seat's per-game play-to-win intent (from
  the seed) and, on the robot's turn, its deterministic **next-move ETA** (the normal-schedule
  upper bound — a dead-endgame pass may land sooner).

## 8. Lobby & social

- **Matchmaking**: a quick game offers **two opponents** on New Game — an **honest AI** (the
  default) or a **random** human (§7). The AI choice (`vs_ai` on `POST /lobby/enqueue`) takes the
  `Matchmaker.StartVsAI` path, which picks a pooled robot and creates a game **already seated and
  active** (`vs_ai`, random seat order); it never enters the open pool, so the reaper below never
  touches it. The random choice drops the player **straight into a real game and lets
  them wait inside it**: `Enqueue` (`POST /lobby/enqueue`) opens a game seating the
  caller with an **empty opponent seat** (status `open`, §9), or — when another player
  is already waiting for the same `variant` and per-turn rule — seats the caller into
  that open game and starts it; which seat the caller takes is randomised for
  first-move fairness, and a re-enqueue while already waiting opens **another** game
  (or joins a different player's) rather than returning the caller's own, so choosing
  "random opponent" again always starts a new search (bounded by the simultaneous
  quick-game cap, §9). Matchmaking state is therefore the **open games in the database** (not
  an in-memory pool), so it survives a restart and stays anonymous beyond one filter — a
player is never paired into a game whose waiting opponent they have a per-user block with,
in either direction (the enqueue excludes the caller's `BlockedWith` set);
  concurrent enqueues for one bucket are serialised by a transaction-scoped advisory
  lock so two callers pair rather than each opening a game. A background **reaper**
  seats a pooled robot (§7) in any open game whose wait window — a fixed **90 s** plus
  a random **0–90 s** (so **90–180 s** total) — has elapsed, guaranteeing every game
  gets an opponent. When a human or a robot takes the seat, the waiting starter
  receives an **opponent-joined** notification (§10) that fills the opponent card and
  re-enables resign and chat **in place** — the starter never leaves the game. While a
  game is `open` the starter may move on their turn, but resign, chat and nudge are
  refused (no opponent yet) and the lobby and opponent card show a "searching for
  opponent" placeholder.
- **Active-game caps (per tier, per kind)**: a player's simultaneous unfinished games are
  capped per **kind** — `vs_ai`, `random` (quick auto-match), `friends` — with independent
  **guest** and **durable-account** tiers. The caps live in the single-row `backend.config`
  table (`-1` = unlimited), read once at boot into an in-memory cache (`internal/gamelimits`)
  and refreshed in place when an operator edits them in the admin (`/_gm/limits`) — so a
  login or game-create never queries the table. The seeded defaults are guest **1 vs_ai / 1
  random / 0 friends** and durable **10 / 10 / 10** (this replaced the earlier flat
  `MaxActiveQuickGames`=10 combined cap). Each game is tagged with `games.game_kind` on
  creation (0 = an untagged game, never gated). `game.Service.AtGameLimit` resolves the
  account's tier, then counts its `active`/`open` games of the kind (hidden games still
  occupy a slot) against the cap. The gate (`Server.ensureUnderGameLimit`) refuses
  `POST /lobby/enqueue` — random or vs_ai per the request — with **409 `game_limit_reached`**;
  the `POST /invitations` (friends) path enforces the durable friends cap the same way and
  refuses a **guest** outright with **403** (guests cannot use friends — the same guest gate
  covers friend requests and friend-code redemption). **Accepting** an invitation
  (`POST /invitations/:id/accept`) is never gated, so friend games are capped only at
  initiation. The client counts its lobby games per kind against the per-tier caps it reads on
  the profile (`Profile.game_limits`, carrying `GameView.kind`), and locks a capped kind's
  New-Game start — a 🔒 outline button that opens a prompt instead of a game: a sign-in funnel
  for a guest, a "finish a current game first" notice for a durable account (native inside
  Telegram, an in-app modal elsewhere), lifting when the profile is re-fetched after a
  guest→durable upgrade. The `games.list` `at_game_limit` boolean (now the random-kind cap) is
  still carried but no longer drives the UI — the per-kind start lock superseded the old
  New-Game-tab disable.
- **Friends**: two add paths over one `friendships` table. A **one-time
  code** the to-be-added player issues (a `friend_codes` row: 6-digit numeric,
  SHA-256-hashed, **12 h** TTL, one live code per issuer, single-use, redeem
  rate-limited) is redeemed by the other player to become friends immediately. It is shared as
  a Telegram `startapp` deep-link to the single bot (with a matching caption),
  redeemed by the recipient's Mini App on launch; a **spent or expired** code is not
  surfaced as an error there but lands the visitor in the lobby with a gentle pointer to the
  bot, since the shared link outlives the single-use code.
  Alternatively a **request → accept** is sent to someone you **share a game with**
  (active or finished); the recipient may accept, ignore (the pending row lazily
  expires after **30 days** and may be re-sent), or **decline** — a decline is
  remembered (`status='declined'`) and blocks further requests from that sender,
  unless they hand them a code, which overrides it. The requester's own cancel still
  deletes the row. A request sent in-game to an **auto-match opponent who is secretly a
  pooled robot** is recorded instead in a separate **`robot_friend_requests`** table, keyed on
  the requester + game + seat with the seen name snapshotted (`RequestInGame`): the shared robot
  account is never put in `friendships` (so it is not befriended/awaited under its other per-game
  names and the in-game 🤝 stays pinned to that seat as *sent*), the robot never accepts, the row
  is not surfaced in Settings → Friends, and a background reaper deletes it once its game has been
  finished for **7 days**. (Discovery by friend list or platform deep-link is future work.)
- **Block**: two independent **global** account toggles (`block_chat`,
  `block_friend_requests`) **plus** a **per-user block list**. A per-user block is
  **asymmetric and non-destructive**: the blocker stops receiving everything **from** the
  blocked user — chat, nudges, friend requests, game invitations, and auto-match (§6) never
  pairs them — while the blocked user notices nothing. Their sends still persist by the normal
  rules but are never delivered or surfaced to the blocker (a directional `blockExists` check
  drives this: the blocker filters/refuses, the blocked is silently suppressed and born-read),
  and applying a block also marks read any unread the blocked user had left for the blocker. It
  **overrides but does not delete** a friendship (an unblock cleanly restores it), so a pair may
  be friends **and** blocked at once; the admin user card shows the full truth (blocks,
  blocked-by, friends) regardless of this suppression. Block/unblock emit a `user_blocked` /
  `user_unblocked` notification to the blocker only (§10). Blocking an auto-match opponent who
  is secretly a pooled robot is recorded instead in a separate **`robot_blocks`** table, keyed on
  the blocker + game + seat with the seen name snapshotted (`BlockInGame`): the shared robot
  account is never put in `blocks` (so the matchmaker keeps it free and it is not blocked under
  its other per-game names), while the blocked list and the in-game card still show it by joining
  that table; an unblock deletes the row.
- **Friend games**: formed by **invitation → accept** (an `game_invitations`
  record with one row per invitee). The 2–4 player game starts once **every**
  invitee accepts; any decline cancels the invitation, and a pending invitation
  expires after 7 days (enforced lazily on access).
- **Chat**: per-game, persisted (kept with the game's archive), **≤ 60 runes**,
  and **validated on input** — links, email addresses and phone numbers (including
  lightly obfuscated forms) are rejected, since the chat is for quick reactions,
  not contact exchange. Chat is allowed **only on the sender's own turn** and
  **at most once per turn** (the turn boundary is the move-driven turn start; the
  opponent's-turn control is the nudge); the backend enforces both and the client
  mirrors them by hiding the field. Each message stores the sender's IP (forwarded by the
  gateway) for moderation. A sender who has disabled chat cannot post,
  and messages from a blocked sender are hidden from the viewer. The operator console
  has a **Messages** section that lists posted messages (nudges excluded)
  newest-first with the sender's resolved name, **source** (guest / robot / oldest
  identity kind), IP and game, searchable by sender name / external-id glob masks and
  pinnable to one game or sender (linked from the game and user cards). It also offers an
  **unread-only filter** and a read/unread column, and each message has a detail card with
  the **per-seat read breakdown** (sender / read / unread).
- **Nudge**: folded into the chat as a `nudge` message kind. The player awaiting
  the opponent may nudge **once per hour per game**; it is not allowed on one's own
  turn. The platform-native delivery runs through the gateway and the platform
  side-service.
- **Read receipts**: each `chat_messages` row carries an `unread_seats` bitmask — a set
  bit per recipient seat that has **not** yet read it (the sender's own bit is never set).
  A text message seeds the bits of every seated recipient; a nudge seeds only the awaited
  player's. A **disguised robot opponent's bit is never set** — it never opens the chat, so
  a message to it is **born read** (it would otherwise linger unread forever); a nudge to a
  robot instead clears when the robot answers by moving, as for a human. A seat's bit clears when that player **opens the move history or the chat**
  (`POST /games/:id/chat/read`, which the client sends only when it holds unread, so a
  history open is not a constant backend call), and a **nudge additionally clears when its
  recipient answers by moving** (the move path calls a wired `NudgeClearer`); and **every nudge in
  a game is marked read when that game finishes** — on any completion path (a closing move, a
  resignation, a forfeit or a turn-timeout, all funnelling through the shared `commit`), since the
  nudge badge is stale once the game is over (a wired `NudgeExpirer`; chat messages stay unread and
  this expiry records no read latency). The mask is
  inverted so "anything unread" is a plain `unread_seats <> 0`, which the per-viewer
  `unread_chat` game-view flag (seeding the lobby and in-game unread **dot**), the admin
  unread filter and the unread gauge all use. A second per-viewer flag, **`unread_messages`**
  (`unread_seats <> 0 AND kind = 'message'`), reports whether any unread entry is a real
  message rather than only a nudge, so the **dot is coloured**: the regular danger colour
  when a message is unread, a softer amber when only nudges are. Both flags share the
  REST-seed-then-event-bump lifecycle (the live-event game-view leaves them false; a nudge
  event raises only `unread_chat`, a message event raises both). The lobby additionally
  **floats games with any unread entry to the top** of the your-turn and opponent-turn
  sections (the finished section keeps its activity order). On each player-driven clear the
  publish-to-read latency is recorded — the completion expiry records none (it is not a read);
  the read time itself is not retained.
- **Profile**: `preferred_language` (en/ru; tracks the interface language — §4), display name, email
  (confirm-code binding, see §4), **timezone**, the daily **away window**, the
  **variant preferences** (`variant_preferences`, the matchable-variant set that gates New
  Game — §3, defaulting to Erudit only, at least one enforced) and the
  block toggles — all editable through `account.UpdateProfile`, which validates them:
  a display name is Unicode letters joined by single ` `/`.`/`_`
  separators (no leading/trailing/adjacent separators, ≤ 32 runes); the timezone is a
  fixed `±HH:MM` **UTC offset** (or a legacy IANA name) resolved by `account.ResolveZone`
  for the sweeper and the robot's sleep (a fixed offset trades DST for a simple
  picker), and is **seeded at account creation** from the client's detected offset — sent
  on the Telegram / guest / email first-contact request — so the robot's sleep and the
  away-window sweeper are anchored to the player's real zone from the first game rather
  than the `UTC` default (an undetected or malformed offset keeps the default); the away
  window is at most **12 h** (midnight-wrap aware). Linked platform
  accounts and merge are covered in §4.

## 9. Persistence

- Single Postgres database, schema `backend`; `backend` is the only writer. The
  "pgx pool" is a `database/sql` handle backed by the pgx stdlib driver and
  instrumented with otelsql; type-safe queries use **go-jet** (code generated
  into `internal/postgres/jet` and committed, regenerated by `cmd/jetgen`).
  Migrations are embedded SQL applied with `pressly/goose/v3` at startup. Primary
  keys are application-generated **UUIDv7**.
- Tables: `accounts` (durable internal accounts, carrying the away-window
  columns `away_start`/`away_end`, the hint wallet `hint_balance` (spent after a
  game's per-seat allowance; an operator tops it up with an additive, raise-only
  grant from the admin console), the `is_guest`
  flag for ephemeral guest rows, the `notifications_in_app_only` out-of-app push
  toggle, the `paid_account` service flag and the merge-tombstone columns
  `merged_into`/`merged_at`),
  `identities` (platform/email/robot identities, unique `(kind, external_id)`,
  the `kind` admitting `robot`),
  `sessions` (revoke-only opaque-token hashes), the game tables
  `games` (carrying the `dropout_tiles` disposition column), `game_players`,
  `game_moves` (the move journal), `game_setup_draws` (the first-move draw record, §6),
  `complaints`, `account_stats` and
  `account_best_move`, and the
  social/lobby tables `friendships` (the request/accept graph, its status admitting
  `declined`), `blocks`
  (per-user blocks), `chat_messages` (per-game chat and nudges, carrying the per-message
  `unread_seats` read bitmask), `email_confirmations`
  (pending confirm-codes), `game_invitations` / `game_invitation_invitees`
  (friend-game invitations), `friend_codes` (one-time add-a-friend codes),
  `game_drafts` (a player's in-progress rack order + board composition per
  game) and `game_hidden` (`(account_id, game_id)` rows that drop a finished game from one
  account's own lobby list, leaving it visible to the other players — finished-only and
  irreversible by design, so there is no un-hide).
  Auto-match has no separate store: a game **awaiting an opponent** is an ordinary
  `games` row with status `open` and a single seated `game_players` row (the empty
  opponent seat is a null `account_id`, filled when a human or robot joins), plus an
  `open_deadline_at` stamp the reaper scans for robot substitution. The **first-move draw**
  (§6) is recorded in `game_setup_draws` when the game opens; the synthetic opponent's rows
  carry a NULL `account_id` until a real opponent joins and back-fills them. The record is
  dictionary-independent — a decoded letter, a blank flag and the numeric draw rank — like
  the journal (§9.1), so it never depends on a dictionary or the solver's encoding.
- **Active games are event-sourced.** A game is a `games` row (pinned
  `variant`/`dict_version`, bag `seed`, the per-game settings, and a denormalised
  turn cursor) plus an append-only, decoded move journal (`game_moves`); the live
  position is an `engine.Game` held in an in-memory cache (≈24 h idle TTL) and
  rebuilt by replaying the journal on a miss, which the seeded bag makes exact.
  Each game is serialised by a per-game lock; a persistence failure evicts the
  live game so the next access rebuilds from the journal. `game_players` records
  each seat's account (**null for an open game's still-empty opponent seat**),
  running score, hints used and winner flag.
- **Statistics** (`account_stats`, recomputed on each finish for durable
  non-guest accounts only — the finish-time recompute skips any `is_guest`
  seat): wins, losses, **draws**, max points in a game, and
  max points for a single **move** (which already folds in every word the move
  formed plus the all-tiles bonus); plus two summed counters — `moves` (the player's
  plays, i.e. tile placements; passes and exchanges do not count) and `hints_used`
  (every hint taken, allowance + wallet) — from which the screen derives the **hint
  share** = hints_used / moves. A tie increments draws only; a resignation or
  timeout is a loss for the acting player. A companion table **`account_best_move`**
  (keyed by account **and variant**) keeps the highest-scoring single play **per
  variant** with the word itself: its main word as an ordered JSON array of tiles
  (letter, tile value, blank flag — value 0 for a blank), so the statistics screen
  renders it as game tiles without the variant's alphabet table. Blank flags are
  taken from every blank ever placed in the game (equivalent to reading the final
  board, since a placed tile never moves). It is replaced only by a strictly
  higher-scoring play, written in the same finish transaction, and skipped for
  guest/honest-AI games exactly like `account_stats`. It is filled forward only —
  plays finished before the table existed are not back-populated (the aggregate
  `max_word_points` still covers them numerically).

### 9.1 History invariant (must hold forever)

Archived games must replay **independently of any dictionary and of the
solver's internal encoding** — at least visually. Therefore the move journal
persists only **decoded concrete values**: action kind (play / pass / exchange /
resign / timeout), acting player, per-move score and running total, timestamp,
and — in a per-move JSON payload — the acting player's rack before the move (with
`?` for a blank), and for a play its direction, main-word anchor, placed tiles
(letter as text, coordinate, blank flag) and the words formed; for an exchange,
the swapped tiles. This is exactly what is needed both to **replay the game
through the engine** (a cache miss; replay trusts the stored direction rather than
re-deriving it, so the rebuild matches the committed game) and to render history or emit GCG **without a
dictionary**: the board for visual replay is reconstructed by applying placements
onto an empty grid, since moves were validated at play time and scores are
stored. `variant` and `dict_version` are kept as **metadata only** (audit,
complaint review), never as a replay dependency. Engine replay re-validates each
move, so a committed move that **later becomes illegal** under a tightened rule
(e.g. the single-word connectivity rule) would make the rebuild fail; rather than
leave the game unopenable, the next open closes it gracefully as a **draw**
(`engine.EndAborted` → `end_reason='aborted'`, all seats marked non-winners),
preserves the journal intact, and surfaces an impersonal **organizer note** at the
end of the history and in the GCG export (a free-text `#note`). **GCG export** is derived from
the same rows and is likewise self-contained — we ship our own writer (the solver
exposes none): the standard Poslfit dialect (UTF-8, `#player`/`#lexicon`
pragmas, `8G`/`H8` coordinates, lower-case blanks, `.` pass-throughs, `-TILES`
exchanges), plus `#note` lines for resignations and timeouts, which the standard
does not cover. **Export is offered only on a finished game** (`game.ErrGameActive`
otherwise), so an in-progress journal is never leaked mid-play.

**Export delivery — the signed download URL.** Both export artifacts — the `.gcg` text
and the rendered **PNG of the final position** — travel one uniform route on every
platform: the client calls the authenticated `game.export_url` op, the backend mints a
**relative, HMAC-signed, short-lived path**
(`/dl/{game}/{kind}?e=<expiry>&…&s=<HMAC-SHA256>`, 10-minute TTL,
`BACKEND_EXPORT_SIGN_KEY`), and the client resolves it against its **own origin** (no
service ever needs to know the public host) and hands it to the best affordance
each platform has: Telegram Android/desktop `downloadFile` (Bot API 8.0; the
chooser there is the native showPopup — activation-free bridge calls end to end),
Telegram iOS the OS share sheet with the fetched file (the app-modal click supplies
the user activation a popup callback lacks), VK iOS `VKWebAppDownloadFile` for both
formats, VK Android the native image viewer (PNG) + the clipboard (GCG) — its
DownloadFile hangs regardless of Content-Length/Range — the OS share sheet on a
mobile browser, or a plain anchor download (desktop browsers and the VK desktop
iframe). The gateway serves the bytes via
`http.ServeContent` (Content-Length + Range/206 — Android's system DownloadManager
hangs on chunked bodies of unknown length). The GET is the gateway's
**only unauthenticated data route** (`/dl/*` — in the caddy `@gateway` matcher and the
per-IP public rate limiter): the native download calls carry no cookies or headers, so
the URL's signature — verified by the backend on its `/api/v1/public` group in constant
time, every failure a uniform 404 — is the whole grant, and minting requires an
authenticated caller on a finished game. The PNG is rasterized on demand by the
**`renderer` sidecar** (internal-only Node + skia-canvas running the same
`ui/src/lib/gameimage.ts` the web project unit-tests — one renderer, no drift;
`renderer/README.md`); the backend rebuilds the render payload from the journal +
`engine.AlphabetTable`, and the client's date locale, IANA time zone and UI-localized
non-play labels ride the signed URL so the server render matches the player's
presentation. Nothing is stored: the artifact is re-derived from the immutable
finished journal on each GET. The only degraded platform is a legacy Telegram client
predating `downloadFile`, where the GCG falls back to the old clipboard copy and the
image option is not offered.

The same sidecar also serves the **public legal pages** — the offer at `/offer/`, the privacy
policy at `/privacy/` and the EULA at `/eula/` — the edge-exposed routes on it (caddy routes them
here; its `/render` stays internal). All three reuse the shared `ui/src/lib/offer.ts`
`renderLegalHtml` (one renderer, no drift) over their owner-edited `ui/legal/*_{ru,en}.md` — each is
**bilingual (RU + EN)** with a client-side language + theme switcher (default Russian, persisted;
the offer's EN view transliterates the Russian product names) — baked into the image. `/privacy/`
and `/eula/` are static; the offer additionally splices in the **live price list** (§4.4) into both
languages: it
fetches the two catalog tables as markdown from the backend's internal
`/api/v1/internal/offer/pricing` at the `<#pricing_template#>` marker, then renders the page. The
backend projects the tables from the active catalog through `payments.Money` (no float reaches the
page) and caches them in memory — built at boot, reprojected on any catalog edit — so a served
render issues no query in the steady state and the page always reflects the current catalog without
a redeploy. A backend outage degrades `/offer/` to a 502 rather than a stale price list. Packs are
ordered by ascending rouble price; values are grouped by what they grant — hints only, then no-ads
only, then no-ads + hints, then (reserved, empty today) products carrying the **tournament** atom,
which becomes a fourth group once the tournament economy makes them sellable — and ordered by
ascending chip price within each group. Product titles are admin input, so the projection escapes
them (HTML entities + markdown metacharacters) before they reach the un-sanitised renderer.

The alphabet-on-the-wire transport does **not** touch this invariant: the live edge
exchanges alphabet indices, but the persisted journal (and everything derived from it —
replay, history, GCG) keeps the decoded concrete letters described above, so an archived
game still replays with the variant's `rules.Alphabet` alone, independent of any dictionary.

## 10. Notifications

Two channels: the **in-app live stream** and
**platform-native push** (out-of-app, via the platform side-service).
The backend emits notification intents through an in-process hub
(`internal/notify`, a `Publisher` seam installed on the game, social and lobby
services); a single backend→gateway **gRPC server-stream** (`Push.Subscribe`,
`pkg/proto/push/v1`) carries every event, and the gateway fans them out by
`user_id` to each client's Connect `Subscribe` stream while the app is open. The
catalog is **your-turn** and **opponent-moved** (emitted from the game commit, so
robot-driver and timeout-sweeper moves emit too; opponent-moved goes to **every seat,
including the mover**, so the mover's own other devices and their lobby refresh — it is
in-app only, so the actor gets no out-of-app push for their own move), **chat-message** and **nudge**
(from the social service), **opponent-joined** (from the matchmaker, §8), and **notify**
(a lightweight "re-poll" signal carrying a sub-kind: friend-request,
friend-added, friend-declined, invitation, invitation-update or game-started; emitted on a friend-request,
on answering one (accept → friend-added, decline → friend-declined — to the original
requester, so a game screen watching that opponent re-derives its "add to friends" state),
and on an invitation create (**invitation**) or any later change to it (**invitation-update**: an
updated response, a decline, a cancel, or its game start — to every participant)). **game-over** is emitted to every
seat from the same game commit when a game finishes — any path: a closing play, all-pass,
resign or timeout — and **your-turn** is enriched so the out-of-app push reads in full: it
also carries the mover's display name, their last action and the main word of a scoring play,
and a **recipient-first** running score line (e.g. `120:95:80`, the reader's score first).
The in-app stream is a **delta channel** so the client renders from the event
without a follow-up `game.state`: **opponent-moved** carries the committed move plus the post-move
summary (per-seat scores, whose turn, move count, status) and the bag size, which the client
applies to its per-game cache keyed on the **move count** — idempotent (a re-delivered or own-move
echo is a no-op) and gap-safe (a missed move falls back to a `game.state` + `game.history`
refetch); **your-turn** carries that move count as a consistency check; the **game-started**
notify carries the recipient's full **initial `StateView`** so opening a freshly started game is
instant, and **opponent-joined** carries the waiting starter's refreshed `StateView` so the
opponent card and the resign/chat controls update **in place**; **game-over** carries the final summary; the lobby **notify** sub-kinds
carry the changed account / invitation, so the client patches its lobby lists in place: **invitation**
and **invitation-update** carry the full invitation, and the client upserts a still-pending one and
drops a terminal one (started, declined, cancelled, expired) — the invitations list is a delta channel
too, fresh from any screen without a refetch. The **user-blocked** / **user-unblocked** sub-kinds
confirm a per-user block change to the **blocker only** (never the blocked user), carrying the other
account, so the blocker's open game screens re-derive the block / add-friend controls and the struck
name in place across sessions. The move-commit **response** (`submit_play` / `pass` /
`exchange` / `resign`) likewise returns the actor's own refilled rack and bag size, so the mover
renders the next turn without a self-refetch. Beyond that event-driven warming, the lobby
**preloads** the player's ongoing games — each one's `game.state`, `game.history` and saved
**draft** — into that same per-game cache, so opening one from the lobby is instant and a saved
composition paints already on the board (no rack→board step). The `notify` package owns the FlatBuffers encoding
(fed wire-agnostic input structs by the domain services) and the gateway forwards every payload
verbatim. Auto-match needs no match poll — `Enqueue` returns the game the player enters
synchronously, and an opponent later taking the open seat arrives as the in-app **opponent-joined**
event. Unlike a move, that event has no follow-up delta to trigger the move-count gap recovery, so
the waiting game screen recovers a missed join itself: it **polls `game.state` while the stream is
down, refetches once on stream reconnect, and resyncs on a foreground regain that did not drop the
stream** (covering an event shed from a full hub buffer while suspended), so a join missed during an
outage or while the app was backgrounded still resolves the open game in place. For the lobby **notification badge** (incoming friend requests + open
invitations) the client re-polls on the `notify` event and on lobby open / focus, covering a push
missed while the app was hidden. **Out-of-app platform push** is a fallback
the **gateway** routes from the same firehose: for an event whose recipient has **no
live in-app stream** it resolves the backend `/internal/push-target` (their Telegram
`external_id`, the recipient's **interface language** (`preferred_language`) as the render
language, and the `notifications_in_app_only` flag). It then pushes a deliver command over
the **bot-link** to the remote **bot** — **fire-and-forget, best-effort** (dropped, with a
metric, when no bot is connected) — only when the recipient has a Telegram identity and has
not confined notifications to the app, so the two channels never duplicate. The bot renders a
localized message with a Mini App deep-link button in that language; there is no per-bot
routing. The out-of-app set is
your-turn, game-over, nudge and the **invitation** (a new invitation) / friend-request notify sub-kinds;
the bot renders the message and skips the rest — so in-app-only sub-kinds like
**invitation-update** (a response/withdrawal lobby sync) and **user-blocked/-unblocked** (a
block-state sync to the blocker) never become a platform push. Operator broadcasts
(`SendToUser` / `SendToGameChannel`, §10 admin) render in an **operator-chosen** language in
the console; the backend calls them on the **gateway's bot-link relay**, which forwards them
to the bot and **awaits its delivery ack** (so the console still reports delivered/not). Beyond
messages the same bot-link carries a **chat-gate control path** — a `ChatGate` command sets a user's
write access in the moderated discussion chat and the bot's unary `ResolveChatEligibility` resolves a
joiner's eligibility (neither renders a message; see *Moderated discussion chat* below). It also
carries the **Telegram Stars payment path** (§payments): a `CreateInvoice` command has the bot mint a
`createInvoiceLink` (XTR) and return it in the Ack; the bot's unary `ValidatePreCheckout` gates a
`pre_checkout_query` against the intake (declining an already-paid reusable invoice before the
charge); and its unary `ForwardPayment` delivers a completed `successful_payment` — durably queued in
a bot-side **SQLite outbox** (`platform/telegram/internal/outbox`, re-driven on restart) — which the
gateway proxies to the backend intake, credited once (idempotent on `telegram_payment_charge_id`). An optional
**standalone promo bot** runs in the bot container (`TELEGRAM_PROMO_BOT_TOKEN`): a second bot
answering `/start` with a URL button into the **main** bot's Mini App (`?startapp`, since a `web_app`
button would sign initData with the promo token); it is self-contained — no bot-link, no gateway.
Session-revocation events and cursor-based stream resume stay deferred (single-instance MVP).

Because the bot **exports no telemetry of its own** (the OTel collector is on the main host,
unreachable from the bot host), it reports its **Bot API health** up the same stream: a periodic
`Health` message (`platform/telegram/internal/health`) carries delta counts of connect failures (the
getUpdates long-poll), other API errors and 429s, plus the wall-clock second of its last successful
Bot API call. The bot observes these **centrally by wrapping the Bot API HTTP client** — one place,
no per-call-site instrumentation — and there also honours a 429's `Retry-After` (bounded) so it backs
off rather than hammering (a 429 should therefore stay ~0). The gateway folds each report into its
own metrics (`bot_tg_errors_total{kind}`, the `bot_tg_last_ok_unix` liveness gauge). The remote bot
is thus monitored from the main host's Grafana with three layered signals: the **connection gauge**
(`botlink_connected_bots`) catches a link/host/process outage, the **liveness gauge** catches a
silently wedged bot (its stamp stays fresh even when idle, since getUpdates returns every poll), and
**`botlink_commands_total{result}`** catches gateway sends failing to reach Telegram. Alerts route to
the operator email, which does not depend on the bot.

A separate **advertising-banner** channel feeds the client's one-line strip (UI_DESIGN.md),
server-driven by `internal/ads`. An operator manages **campaigns** (each one placement order) in
the admin console (`/_gm/banners`): a campaign has a show **weight** (integer percent 1..100), an
optional validity **window**, an `enabled` flag and one or more **bilingual messages** (en + ru,
both mandatory, minimal markdown). A single perpetual **default** campaign fills the unsold
remainder up to 100% and is undeletable. Eligibility — who sees a banner at all — is
`!paid_account && hint_balance == 0 && !has(no_banner)` (the `no_banner` account role suppresses
it unconditionally); guests qualify. The eligible viewer's banner block rides the **`profile.get`**
response (the one bootstrap every client fetches on open, authed or guest — no separate request,
nothing distinct for an advanced user to filter): the backend resolves each message to the viewer's
**interface language** (`preferred_language`) and
computes the active set — window-filtered campaigns, the default's effective weight
(`max(0, 100 − Σ active timed weights)`, dropped at 0), GCD-reduced. The **client** rotates that set
with a smooth weighted round-robin (deterministic, fair: each campaign gets its weight share per
cycle), round-robining a campaign's messages within its slots; the global display **timings** (hold,
edge-pause, scroll speed, and the fade-out → gap → fade-in transition) are operator-set
(`/_gm/banner-settings`, clamped) and ride the same block. When an operator changes a viewer's
eligibility inputs (grants hints, grants/revokes `no_banner`; a future payment flow sets
`paid_account`), the backend emits a `notify` **`banner`** sub-kind (a payload-free re-poll signal),
and the open client re-fetches `profile.get` to show or hide the banner in place. Operator *content*
edits take effect on the next `profile.get` (open/reconnect/foreground), not mid-session. A
non-default campaign may also carry an optional **colour override** (background, text, link) in two
sets — one for every theme, one for the dark theme only — plus an **urgent** flag. The colours ride
the same block (six optional strings appended to each `BannerCampaign` on the wire — trailing,
backward-compatible); the client resolves the cascade (dark ← dark ?? all, light ← all) and derives
the strip's border from the background in JS (no CSS `color-mix`, for the old-WebView floor).
**Urgent is resolved entirely server-side, with no wire field**: while any enabled, in-window urgent
campaign exists, `computeActiveSet` returns *only* the urgent campaigns (preempting the timed set and
the default remainder) and `bannerFor` **skips the eligibility gate** for that feed, so an urgent
notice reaches every viewer — paid, hint-holding or `no_banner` included. There is no instant
broadcast on an urgent toggle (the notify path is per-user); an urgent campaign appears on each
viewer's next `profile.get`. The default campaign stays plain (no colours, never urgent), enforced by
both the service and a DB CHECK. The same
mechanism carries a **`profile`** sub-kind — a payload-free re-fetch signal emitted when a viewer's
own account changed out of band (an email confirmed through the one-tap deeplink opened in another
browser), so an open in-app session reflects it at once. The live stream is single-shot with no
replay, so a **backgrounded Mini App** — suspended while the user is in their mail app tapping the
link — misses the event; while an add-email confirmation is pending the client therefore also
**polls `profile.get`** (and on foreground regain) as a fallback until the address lands.

> A single `app.load` bootstrap aggregator (collapsing `profile.get` + lobby + badge fetches into
> one round-trip) was **considered and deferred**: client↔gateway is HTTP/2 (h2c), so the bootstrap
> RPCs already multiplex over one reused connection — the saving would be per-request fixed overhead,
> not connections, and it is a high-blast-radius cross-cutting refactor. Revisit only with evidence
> (`edge_request_duration`); if ever built, as a gateway-side fan-out/merge that keeps the per-domain
> backend handlers intact.

## 11. Observability

- Structured logging with `go.uber.org/zap` (JSON). OpenTelemetry tracer and
  meter providers are wired in **all services** (backend, gateway, the Telegram
  validator and bot) through a shared `pkg/telemetry` bootstrap, env-gated per
  service by `{BACKEND,GATEWAY,TELEGRAM}_OTEL_{TRACES,METRICS}_EXPORTER` with a
  default of `none` (so no collector is required locally or in CI). `stdout` is
  available for debugging; **`otlp`** (gRPC, endpoint from the standard
  `OTEL_EXPORTER_OTLP_*` environment) exports to a collector. The Postgres pool is
  instrumented with otelsql and `otelgrpc` traces the backend↔gateway push stream
  and the gateway↔validator and bot-link calls; the gateway also exports
  `botlink_connected_bots` and `botlink_commands_total` (by result) for the bot-link, plus the remote
  bot's own Bot API health it relays over the stream — `bot_tg_errors_total` (by kind) and the
  `bot_tg_last_ok_unix` liveness gauge (see the bot-link section). The OTLP **Collector** (OTLP/gRPC → Prometheus
  metrics + Tempo traces), **Prometheus** (15d), **Tempo** (72h) and **Grafana**
  (provisioned datasources + dashboards, behind the caddy `/_gm/grafana` Basic-Auth)
  are stood up with the deploy (`deploy/`); the default exporter stays
  `none`, so CI needs no collector. The collector also runs a **`docker_stats`**
  receiver (per-container CPU/memory/network read from the Docker API and exported
  through its Prometheus endpoint), and the contour runs **postgres_exporter**
  (connections, cache-hit ratio, transactions, db size, scraped directly by Prometheus);
  both are surfaced on the **Scrabble — Resources** Grafana dashboard, which captures the
  stress-run resource profile. (`docker_stats` replaced cAdvisor, which on the contour
  host resolved only the root cgroup — a separate-XFS `/var/lib/docker`.)
- **Alerting.** Grafana emails infra alerts through the shared relay (its own SMTP on the
  STARTTLS port) to `SERVICE_EMAIL`, from provisioned rules
  (`deploy/grafana/provisioning/alerting/`): a scrape-target down, the gateway's
  internal-error rate and p99 latency (`edge_request_*`), host memory/disk/CPU
  (node_exporter), Postgres connection saturation (postgres_exporter), and **TLS certificate
  expiry < 20 days** — a Caddy ACME-renewal-failure signal from a **`blackbox_exporter`**
  probe of the edge caddy (`probe_ssl_earliest_cert_expiry`). Every rule is `noDataState=OK`,
  so an absent metric never false-alerts — notably the cert probe, which has no TLS target on
  the HTTP-only contour caddy. Separately, the backend's **admin-alert worker**
  (`internal/adminalert`, started from `main`) emails the operator (`ADMIN_EMAIL`, from a
  distinct `SMTP_RELAY_ADMIN_FROM`) when new player feedback or word complaints arrive,
  **coalescing** a burst into one digest per interval; both recipients may be several
  comma-separated addresses. The digest carries **no admin-console link** — an admin URL must
  never travel in an email, where a mail provider could cache or index it; the operator opens the
  console directly. Both paths are inert unless configured.
- Per-request server-side timing via gin middleware from day one (the access log
  carries method, route, status, latency and the active trace id). A
  client-measured RTT piggybacked on the next request is a later enhancement.
- Domain/operational metrics, recorded through the meter and invisible
  until an exporter is configured: histograms `game_replay_duration` (journal
  rebuild on a cache miss), `game_move_validate_duration` and `game_move_duration`
  (a seat's think time per committed move, attributed by `variant` and a
  `phase` of opening/middle/endgame; it aggregates **all** seats including robots,
  whose synthetic timing dominates the tail, so per-human analysis lives in the admin
  console, below); counters `games_started_total`, `games_abandoned_total` (a
  turn-timeout seat drop), `chat_messages_total` (`kind` = message/nudge) and
  `robot_games_finished_total`; a histogram `chat_read_duration` (chat publish-to-read
  latency by `kind`); observable gauges `game_cache_active` and `chat_unread_messages`
  (chat entries with `unread_seats <> 0`, both chat metrics surfaced on the **Scrabble —
  Messages** dashboard); the gateway
  `edge_request_duration` (the UI-perceived roundtrip, by `message_type`/`result`);
  and Go runtime/heap metrics. Game-scoped metrics carry a `variant` attribute
  (scrabble_en/scrabble_ru/erudit_ru).
- Per-user move-time analytics are **offline**, derived in the admin
  console from the move journal (`game_moves.created_at` deltas, the first move from
  the game's creation), not Prometheus labels (which an `account_id` would explode):
  the user list shows each account's min/avg/max think time, and the user-detail page
  draws a zero-JS inline-SVG chart of min/mean/max by the player's move number.
- User metrics: a backend counter `accounts_created_total` (`kind` =
  telegram/email/guest; robots are a provisioned pool, not users, and are excluded)
  and a gateway **in-memory** observable gauge `active_users` (`window` = 24h/7d) —
  distinct accounts that performed an authenticated edge action in the window. The
  gauge is single-process by design (single-instance MVP, §10): it is correct for one
  gateway, resets on restart, and is a live operational figure, not a billing count.
- **Local-preview adoption (client-reported):** three gateway counters measure uptake of
  the local move-preview accelerator (§5), fed by a small, best-effort client beacon
  (`POST /metrics/local-eval`, session-gated) that batches counter deltas and posts them
  on a 60 s timer and when the app is backgrounded — **never on the gameplay path** (the
  in-app counters are plain in-memory increments; only the periodic flush touches the
  network, fire-and-forget). `local_eval_cold_start_total` counts app cold starts (the
  adoption denominator); `local_eval_dict_load_total` (`result` = fetched/cache_hit/miss)
  splits a dawg download that fills IndexedDB from a cache hit and from a warm-up that
  failed or timed out; `local_eval_preview_total` (`path` = local/network) is the share of
  previews computed on-device versus the network `evaluate` fallback — the backend load
  shed. It answers the owner's adoption question — how often the app opens versus how often
  it fills IndexedDB — and is surfaced on the **Scrabble — Users** dashboard. Lossy by
  design (a dropped beacon just retries its batch on the next flush); it is telemetry, never
  a game input.
- **Unsupported-engine screens (client-reported):** the ES5 boot guard in `index.html` (§13)
  shows a full-screen "your device's OS or browser can't run the app" screen — instead of a white
  screen — when the engine lacks an unpolyfillable essential (`BigInt`, the 64-bit FlatBuffers
  decode; or `Proxy`, Svelte 5 runes) or an uncaught error aborts boot; the effective floor is
  Chrome 67. It then fires one fire-and-forget beacon (`POST /telemetry/unsupported` —
  unauthenticated, since the client never booted, but per-IP public-limited and body-capped),
  deduped in `localStorage` by app version + reason + Chromium so a user reopening the app is one
  report. The gateway folds it into `unsupported_engine_total` (`reason` =
  no_bigint/no_proxy/boot_error/other; `chromium` = the major version reduced to a bounded range so
  a spoofed beacon cannot inflate cardinality) and logs the full user agent (not a label). Surfaced
  on the **Scrabble — Users** dashboard beside app opens.
- **Rate-limit observability:** every limiter rejection increments the gateway
  counter `gateway_rate_limited_total` (`class` = user/public/email/admin — aggregate
  only, honouring the no-per-user-label discipline above) and logs one **Debug** line;
  a gateway reporter drains the per-key rejection tracker every 30 s, emits one **Warn**
  summary per throttled key and posts the report to the backend
  (`POST /api/v1/internal/ratelimit/report`, network-trusted like `sessions/resolve`).
  The backend's `ratewatch` keeps a bounded in-memory episode window (single-instance,
  resets on restart, like `active_users`) surfaced on the admin console's **Throttled**
  page next to the flagged-account review queue, and applies the **conservative
  auto-flag**: an account sustaining `BACKEND_HIGHRATE_FLAG_THRESHOLD` rejected calls
  (default 1000) within `BACKEND_HIGHRATE_FLAG_WINDOW` (default 10 min) gets the soft,
  reversible `accounts.flagged_high_rate_at` marker — set once, shown in the user
  list/detail, cleared by the operator, **never an automatic ban** and never a request
  gate. The Edge/UX dashboard graphs the aggregate request rate against the rejection
  rate by class.
- **Temporary IP ban (prod-only):** with `GATEWAY_ABUSE_BAN_ENABLED` set, the gateway
  enforces a fail2ban-style block keyed by client IP, fed by three signals: an IP that
  sustains `GATEWAY_ABUSE_BAN_THRESHOLD` rate-limiter rejections within
  `GATEWAY_ABUSE_BAN_WINDOW` (the IP-keyed public/email/admin classes — the user class
  stays the soft-flag's concern, never the ban's), a **honeypot** decoy-path hit, and a
  **honeytoken** (a planted bearer no real client holds, `GATEWAY_HONEYTOKEN`). A banned
  IP is refused with **429** by an edge middleware (`abuseGuard`) before any work —
  covering the Connect edge, the live stream and the static SPA/landing the per-op limiter
  never gated. A rejection ban lasts `GATEWAY_ABUSE_BAN_DURATION`; a tripwire/honeytoken
  hit is near-zero-false-positive and earns a longer fixed ban (1 h / 24 h). The ban is
  **in-memory, single-instance and resets on restart**, like `ratewatch`; each ban
  increments `gateway_abuse_banned_total` (`reason` = rejections/tripwire/honeytoken). The
  decoy paths live only in the contour **caddy**, which tags them with `X-Scrabble-Honeypot`
  (stripping any client-supplied value) and routes them to the gateway. It is **off by
  default and only enabled in prod**: the ban keys by real client IP, which the shared-NAT
  test contour does not expose (every client arrives as one address), so a ban there would
  be self-inflicted — the honeypot/honeytoken still **log** in the contour, only the ban
  *action* is gated. Operators see the active bans and lift them on the admin console's
  **Throttled** page; the gateway syncs its active set to the backend every 30 s
  (`POST /api/v1/internal/bans/sync`, network-trusted like the rejection report) and applies
  the operator unbans the response returns, so a manual unban takes effect within the sync
  interval.
- **Community IP blocklist (prod-only):** with `GATEWAY_BLOCKLIST_ENABLED` set, the gateway also
  refuses a client whose IP is in a curated CIDR feed (Spamhaus DROP, `GATEWAY_BLOCKLIST_URL`) with
  **403** in the same `abuseGuard`, before the fail2ban list. A background refresher re-fetches the
  feed every few hours (bounded fetch + size cap) into a sorted-range matcher (`ratelimit.Blocklist`,
  binary search, IPv4 only — an IPv6 client is never blocked here). It is **fault-tolerant and
  fail-open**: a failed fetch keeps the last-good feed, and once the feed is older than
  `GATEWAY_BLOCKLIST_MAX_STALENESS` it is **dropped** rather than block a legitimate client on a
  frozen list; a `GATEWAY_BLOCKLIST_ALLOW` allowlist (own infra, monitoring) is never blocked. Off by
  default and prod-only for the same real-client-IP reason as the ban. It is a **separate** static
  CIDR set, not the per-IP fail2ban store (a bulk CIDR feed cannot expand into per-IP entries).
  Observability: `gateway_blocklist_blocked_total`, the `gateway_blocklist_entries` size gauge and the
  `gateway_blocklist_age_seconds` staleness gauge (a Grafana alert warns before the feed is dropped).
- Unauthenticated `GET /healthz` (liveness) and `GET /readyz` (readiness — the
  database answers a bounded ping and the session cache is warmed).
- The backend serves a **second listener** — a gRPC server
  (`BACKEND_GRPC_ADDR`, default `:9090`) for the live-event push stream to the
  gateway — alongside the HTTP listener; both start together and stop on signal.

## 12. Security boundaries

| Concern | Enforced by |
| --- | --- |
| Public rate limiting / anti-abuse | gateway (per-IP public/email/admin classes, per-user authenticated class; a request body cap of `GATEWAY_MAX_BODY_BYTES`; rejections are metered, summarised to the backend and surfaced in the admin console with a conservative reversible auto-flag — §11). In prod a **temporary IP ban** (`GATEWAY_ABUSE_BAN_ENABLED`) blocks an IP that sustains rejections or trips a **honeypot** decoy path / **honeytoken**, refused with 429 before any work; operators lift bans from the console. Off in the shared-NAT test contour, where the client IP is not real (§11) |
| Telegram initData validation (bot-token HMAC) | the Telegram **validator**; the gateway delegates it over gRPC, so the bot token (the HMAC secret) lives only in the validator and the bot, never in the gateway. The validator also **rejects a bot principal** (the signed `is_bot` flag) before any account is provisioned |
| Session minting; email-code / guest validation | gateway (with backend) |
| Session → `user_id` + trusted platform resolution, `X-User-ID` / `X-Platform` injection | gateway (platform sourced from the session, never the request body — §3) |
| Authorisation, ownership, state transitions | backend (`X-User-ID` is the sole identity input) |
| Manual account block (suspension) | backend: a per-request gate refuses a blocked account on every `/api/v1/user/*` route except the block-status probe with **403 `account_blocked`**; the operator blocks/unblocks from the admin console (§11) |
| User feedback gate | backend rejects a guest or a `feedback_banned` account from submitting; the **gateway** also rejects a guest's `feedback.submit` (the `Op.NonGuest` flag + `is_guest` from session resolve) with **`guest_forbidden`** before any backend call; attachments are served `nosniff` with a download disposition for non-images (§15) |
| Admin authentication | a single Basic-Auth gate on `/_gm/*`, forwarded **verbatim** to the backend's server-rendered admin console (and, in the deployed contour, routing `/_gm/grafana/*` to Grafana). In the deploy the **caddy** owns this gate (§13); a local non-caddy run uses the gateway's own `GATEWAY_ADMIN_*` proxy, which the per-IP admin limiter class guards ahead of its Basic-Auth — the caddy-fronted path has no limiter (stock caddy), an accepted gap. The backend trusts the proxy (no admin principal) and guards its state-changing POSTs with a **same-origin** check — the console's CSRF defence. No operator identity is tracked |
| backend ↔ gateway ↔ validator trust | the network (only gateway may reach backend; the validator and the gateway's admin bot-link relay serve unauthenticated gRPC on the trusted internal segment) |
| remote bot ↔ gateway (bot-link) | **mutual TLS**: a private CA signs the gateway server cert and the bot client cert, and each verifies the other. The bot dials out (no inbound port, no static IP), so the channel is guarded solely by mTLS — the bot client key is as sensitive as the token (§13) |

This is an explicit, accepted MVP risk: compromise of the gateway↔backend
network segment defeats backend authentication. Mitigated by network isolation;
mutual auth is a future hardening step. The **bot-link** is the exception — it
already uses mutual TLS, because it is the one inter-service link that leaves the
trusted segment (the remote bot lives off the main host).

**Manual account block (suspension).** Beyond the soft, reversible high-rate flag (§11, never a
gate), an operator can hard-block an account from the admin console — permanently or until a
date, with an optional reason chosen from an editable en+ru picklist. A block is a row in
`account_suspensions` (the chosen reason's text is **snapshotted**, so editing or deleting a
picklist entry never changes what an already-blocked player is shown); it is named *suspension*
to stay distinct from the peer-to-peer `blocks` table. Enforcement is a backend middleware gate
after `X-User-ID`: every `/api/v1/user/*` route except the block-status probe refuses a blocked
account with **HTTP 403 + code `account_blocked`**, which threads through the gateway unchanged as
the Execute `result_code`, so the UI detects the block from *any* call and replaces every screen
with a terminal "blocked" screen, stopping all push/poll. The one exempt route,
`GET /api/v1/user/block-status`, returns the expiry and the reason resolved to the account's
language so the blocked client can render the message. Sessions are **not** revoked on block (a
revoked token would fail session resolution at the gateway *before* the gate, sending the UI to
login instead of the blocked screen). A block instantly **forfeits** every active game the player
is in (the opponent wins, exactly as a resignation — the engine resigns off-turn) and cancels
their open matchmaking games; a temporary block lapses automatically once its expiry passes (no
sweeper for the gate — it recomputes against `now`). No operator identity is recorded (shared
Basic-Auth).

**Moderated discussion chat.** A channel's linked discussion group is gated by the Telegram bot
(`TELEGRAM_CHAT_ID`). The group **allows sending by default** and the bot only **restricts**: Telegram
intersects the chat default with each user's permission, so a per-user grant can never exceed a
deny-by-default group — the gate must mute the ineligible, not grant the eligible. A user may write
while they are **registered and neither admin-suspended nor holding the chat-only `chat_muted` role**
(`eligible = registered AND NOT suspended AND NOT chat_muted` — the game suspension dominates); the bot
**mutes** an ineligible member and **un-mutes** an eligible one it had muted, leaving an already-allowed
eligible member untouched (it acts only when the current state differs, so it is idempotent and never
loops on its own change). A single backend resolver behind `POST /api/v1/internal/chat-access` answers
both directions: the bot's `ResolveChatEligibility` on a `chat_member` event (over the mTLS bot-link),
and a `chat_access_changed` event — emitted on a block/unblock, a `chat_muted` grant/revoke, a first
Telegram registration, or a temporary block lapsing (a dedicated `account.SuspensionSweeper`, since no
request fires then) — drives a `ChatGate` command the gateway pushes to the bot. The bot applies it
only to a member currently in the chat (a per-user `getChatMember` probe, since bots cannot list
members); the signal is idempotent and is never an in-app or out-of-app message. `chat_muted` is an
`account_roles` entry (an operator toggle in the console), so it needs no schema change. The bot
must be an administrator in the group with the **restrict-members** right and `chat_member` in its
allowed updates.

**Short numeric codes** (email confirm-codes and friend codes) are stored
only as SHA-256 hashes and are short-lived and single-use. The unauthenticated
email path carries a tight per-IP sub-limit (5 / 10 min); the **friend-code redeem**
is authenticated, so it rides the per-user limit (300 / min) and is further bounded
by the code's 12 h TTL, single use, and **one live code per issuer** (which caps the
valid-code population). Brute-forcing a 6-digit friend code within these limits is an
accepted MVP risk with low blast radius (an unwanted friendship is removable/blockable);
a dedicated redeem sub-limit or a longer code is the hardening step if abuse appears.

**Client source exposure.** The production UI build ships **no sourcemaps**
(`vite.config.ts` gates `build.sourcemap` off when `mode === 'production'`), so the gateway
and landing images serve only minified JS and the full TypeScript/Svelte source is not
recoverable from a served `.map` at the edge. Dev and the `mock` e2e build (`vite build
--mode mock`) keep maps for debugging. This is surface reduction, not secrecy — the single
minified bundle still carries all platform code paths and is reversible with effort.

## 13. Deployment (informational)

Single public origin, path-routed. The Vite build has two entries: a lightweight
**landing page** and the game **SPA**. The gateway **embeds** the SPA build
(`go:embed`, baked in by a node stage in `gateway/Dockerfile`) and serves it at
`/app/` (web), `/telegram/` (the Telegram Mini App; on that path without sign-in data
— no `initData` — the client renders a compact, shareable launch-diagnostic screen instead
of redirecting away) and `/vk/` (the VK Mini App; the client reads the signed `vk_*` launch
parameters from the URL and the gateway verifies them in-process — §12; on that path without a
signed launch the client renders the same shareable launch-diagnostic screen rather than starting
a throwaway guest); a stray hit on the
gateway's `/` 308-redirects to `/app/`. The **landing** ships in its own static container: the
`landing` target of `gateway/Dockerfile` (caddy:2-alpine + the same Vite build,
`deploy/landing/Caddyfile`) serves it at `/`, so stray public traffic is absorbed by
static file serving and never reaches the Go edge. The landing shell carries the site's
static SEO head — Russian title/description, the Open Graph card (Telegram/VK link
previews), JSON-LD and a canonical link — with absolute URLs pinned to the production
origin, so the test contour canonicalises to production instead of being indexed as a
separate site; the SPA shell is `noindex`, and the landing always boots in Russian (no
browser-language detection — crawlers render with arbitrary languages). The favicon set,
`og-image.png` and `robots.txt` ship unhashed from `ui/public/` (generated by
`assets/icons/`, same tile design as the VK loader). On the web (`/app/`) the SPA is an installable **PWA**:
`manifest.webmanifest` ships unhashed from `ui/public/`, while the **service worker** (`sw.js`) is
built from `ui/src/sw.ts` by **vite-plugin-pwa** (injectManifest strategy) — the client registers it
**web-only**, never inside a Mini App or the mock build (the plugin is disabled there entirely). It
**precaches the app shell and the hashed assets** (Workbox) so an installed PWA cold-launches with
no network. Shell **navigations are network-first** — the fresh `no-cache` shell is fetched from the
server (so a new deploy is picked up on the very next launch, not the one after), falling back to the
precached shell only when the network is unreachable or too slow; the immutable hashed assets stay
cache-first, and the hash router resolves the route client-side. This navigation route is registered
**before** the precache route on purpose: Workbox matches in registration order, and the precache
route (its `directoryIndex` is `index.html`) would otherwise shadow it and serve the shell
cache-first — the old build's version until a second load. The landing page and the conditional
polyfill bundle are excluded, and the Connect stream and runtime API POSTs are never precached nor
intercepted, so the live app is never served stale. This satisfies Chromium's installability requirement (a registered SW, needed
for install on Android) and powers **offline play**. Offline is **implicit** — the net-state machine
(§2) detects lost connectivity and tints the header blue with an *Offline* chip; there is **no toggle**
(the old deliberate Settings switch and its cold-start dialog are gone). The **unified lobby** merges the
device-local games (active — reconstructed by replaying the IndexedDB move journal) with the last-cached
**server games shown greyed** (un-openable, a tap toasts *offline*); the Stats tab is disabled and
invitations are hidden while offline. On offline-capable channels (native / plain web) New Game's *with
friends* carries an **online/offline segmented control** — online = a friend invite, offline = **local
pass-and-play (hotseat)** — with the online segment disabled and offline forced when there is no network;
the online-only Telegram/VK mini-apps skip the segment and show the remote invite alone. A `vs_ai` or
hotseat create is guarded when
the chosen variant's dictionary is not available offline. A device-local `vs_ai` game is created through
the in-browser engine and driven by the same game screen, the robot replying locally.
A hotseat game is a device-local **2-4 human** game built through the same engine (`hotseat` +
per-seat/host PIN locks on the record; the seats carry names but no accounts). A **mandatory host
(referee) PIN** gates the roster at creation and, in-game, the referee overrides — **skip** the
current turn, **exclude** a player (a forced seat resign, `resignSeat`) or **terminate** the game
(deleted, no winner/loser); each seat may also carry its own **optional PIN** that withholds its rack
(the board stays visible — only the tray is gated) until it is entered, so players pass the device
around freely and lock a seat only against a distrusted tablemate. The unlock is **per turn**
(re-locks on advance). PINs are a **social lock, not cryptography** — a salted SHA-256 kept only in
the device record (`lib/pin`); a 4-digit PIN is trivially brute-forceable and the racks live in
client memory regardless, so they defeat a casual glance, not a determined peek. A hotseat game is
**not `vs_ai`**: hints (the freed control becomes the host button) and chat/self-resign are gone, and
its lobby card — active *and* finished — is master-PIN-gated to delete (so the last mover cannot
instantly wipe it); a naturally finished game is saved with its result like any local game. A `vs_ai`
hint (online and offline alike) is unlimited and wallet-free but idle-gated
(unlocked ~30 min into a stuck turn), and counts toward no hint statistic. The **source** reports the
**seconds left** (`hint_unlock_left_seconds` on the game view) — computed by the **backend from the
server clock** online (which also enforces the gate, returning `hint_locked` for an early request),
and by the offline source from the device clock (persisted, capped at the window). The **client
anchors a MONOTONIC countdown** (`performance.now()`, `lib/hints`) to that seconds-left when it lands
(on load, and to the full window when the robot moves), so a client clock change cannot skew it, and
a relaunch re-reads a fresh value so the wait is not forgotten. To have data ready before the switch, the **Profile advertises the current dictionary
version per variant** (`dict_versions`,
filled from the registry on the existing cold-start profile request — no extra round-trip), and an
eligible installed PWA (standalone web + confirmed email) **background-preloads** those dictionaries
— on lobby entry and on a variant-preference change — through the same three-tier loader, retried
with backoff and honouring the session miss-breaker; the move generator, the loader and the preload
orchestration stay in lazy chunks. A first-lobby preload failure shows a *poor-connection* notice in
the ad-banner slot. A **cold launch with no network** boots offline-first from the persisted session +
profile (saved on every online adopt/refresh) — `bootstrap` skips the session adoption and profile fetch
that would otherwise hang, and lands straight in the offline lobby; a native launch with no server
session enters as a device-local guest (§3), and any stale pre-redesign offline-preference key is cleared
on boot. Connectivity is **detected, not chosen** (the net-state machine, §2): a cold `navigator.onLine
=== false` or a failed cold reachability probe (a bounded `profile.get`) enters offline, and mid-session
the `navigator` / `@capacitor/network` `online`/`offline` events plus the probe watcher drive it — with
**hysteresis** so a brief blip lives in `connecting` and never flips the chrome, and **self-heal** (a
back-online toast) when the network returns. Offline is a real **transport kill switch** (every gateway
call is refused, so no traffic leaks); the reachability probe is the one call exempt from it (it is the
return-to-online mechanism). The gateway registers the `.webmanifest` MIME type
in-process (the distroless image has no `/etc/mime.types`). Hash-named `/assets/*` are served
`immutable` (a relaunch is a cache hit, not a re-download); the HTML shells are
`no-cache` so a new deploy is picked up — both containers apply the same caching. An
in-compose **caddy** is the contour's edge: it owns a single `/_gm` Basic-Auth and
routes `/_gm/grafana/*` to **Grafana** (anonymous-admin, so the one shared login gates
it with no per-user Grafana accounts) and the rest of `/_gm/*` to the backend-rendered
**admin console**; `/app/`, `/telegram/`, `/vk/` and the Connect path go to the gateway; the public
legal pages `/offer/`, `/privacy/` and `/eula/` (rendered by the `renderer` sidecar — the offer with
the live catalog price list spliced in) go to the render sidecar; and the catch-all — notably the
landing at `/` — goes to the landing
container. The
**Telegram validator** runs as a separate container with **no public ingress**,
answering only internal gRPC (HMAC, no Telegram egress). The **Telegram bot** holds
no inbound port either: it dials the gateway's **bot-link** (mTLS) and egresses to
Telegram — through a VPN sidecar in the test contour, from a separate host in prod.
The gateway exposes the bot-link on a dedicated mTLS gRPC listener
(`GATEWAY_BOTLINK_ADDR`, internal-only in the test contour, published in prod) plus a
plaintext relay (`GATEWAY_BOTLINK_RELAY_ADDR`) the backend admin console calls.

The full contour (`deploy/docker-compose.yml`) runs one `gateway`, one `backend`,
one Postgres, the static `landing`, the Telegram `validator` and `bot` (+ the bot's VPN
sidecar — the `bot`+`vpn` pair is gated to a `telegram-local` compose profile so the prod
main host can omit them) and the **observability stack** —
OTel Collector (OTLP/gRPC ingest → Prometheus metrics + Tempo traces), a `node_exporter`
for host CPU/memory (the prod main host's OOM signal), and Grafana
with provisioned datasources and dashboards. All services export OTLP to the
collector; the bot shares the VPN sidecar's netns, so its `AWG_CONF` must not
carry a `DNS=` directive (that would hijack resolv.conf and stop it resolving
`otelcol` / `gateway`; without it the netns uses Docker's resolver, which resolves
`otelcol`, `gateway` and `api.telegram.org`). Inter-service traffic uses a private `internal`
network (project-scoped DNS); only caddy joins the shared external `edge` network
(alias `scrabble`).

Two contours, two secret/variable prefixes (`TEST_` / `PROD_`):
- **Test**: auto-deploys on a PR into — or a push to — `development`
  (`.gitea/workflows/ci.yaml` → `docker compose up -d --build` on the Gitea runner
  host, then `GET /` + `GET /app/` probes through caddy — the landing container and
  the gateway). The host caddy terminates TLS and
  forwards the domain to `scrabble:80`, so the in-compose caddy serves plain HTTP
  (`CADDY_SITE_ADDRESS=:80`). The in-compose caddy **trusts X-Forwarded-For from
  private-range upstreams** (`trusted_proxies private_ranges`), so the real client IP —
  used for the gateway's per-IP rate limiting and, forwarded on to the backend, the account's
  last-login IP, chat/feedback moderation and the access log — survives the
  host-caddy hop; in prod (no host caddy) public clients are untrusted and Caddy uses the
  real peer, so the single config is correct and spoof-safe in both contours. The
  **bot-link mTLS material** (a private CA + gateway/bot leaves, CN=`gateway`) is
  generated by `deploy/gen-certs.sh` before `compose up`; the bot keeps its VPN sidecar
  for Telegram egress and dials the gateway by its internal name, so the bot-link stays
  on the internal network.
- **Prod**: a **manual** rollout — `.gitea/workflows/prod-deploy.yaml`, `workflow_dispatch`
  only (from `master`, `confirm=deploy`), run after `development → master` is merged green.
  It builds and pushes the images to the registry (`docker.iliadenisov.ru`), then deploys
  over SSH onto **two hosts** provisioned by `deploy/ansible/` (docker, a non-sudo `deploy`
  service account holding a dedicated CI key, key-only sshd, default-deny ufw, fail2ban):
  the **main host** runs the full stack (`docker-compose.yml` + `docker-compose.prod.yml`),
  the **bot host** runs only the bot (`docker-compose.bot.yml`, no VPN — native Bot API
  egress, telemetry off). There is no host caddy, so the contour caddy terminates TLS —
  `CADDY_SITE_ADDRESS` is the domain and caddy does its own ACME. Caddy advertises HTTP/3 by default, but UDP/443 is not exposed (the
  compose maps only TCP and ufw opens 443/tcp), so the edge emits `Alt-Svc: clear` to keep
  clients on h2/h1 rather than stall on a dead QUIC path — see [`EDGE_HTTP3.md`](EDGE_HTTP3.md).
  The gateway **publishes**
  the bot-link `:9443`; the remote bot dials it over mTLS (certs from `PROD_BOTLINK_*`,
  ServerName `gateway`, so TLS validation is independent of the public dial address), holds
  no inbound port, and login is unaffected if that host or the link is down.
  `deploy/prod-deploy.sh` rolls the main stack **one service at a time in dependency order**
  (postgres → backend → gateway → landing → validator → caddy), health-checking after each;
  any failure **rolls the whole stack back to the previous image tag**. A **schema migration**
  adds a maintenance window: the backend (the sole writer) is stopped for a consistent
  `pg_dump` before the new backend migrates forward — image rollback stays DB-safe under the
  expand-contract migration rule, and the dump is kept for a manual restore. The workflow runs
  four visible jobs (build → deploy-main → deploy-bot → verify). Releases are git tags
  `vX.Y.Z`; the version is stamped into the image tag, every binary (`-ldflags` → `pkg/version`
  → the `service.version` telemetry attribute) and the SPA About screen. A separate manual
  **`prod-rollback`** workflow re-deploys any prior release tag (blank input = the previous
  deployed version, tracked on the host) over the same rolling, health-gated path — image-only,
  no DB migration. The main host is
  intentionally **launch-sized** (2 vCPU / 1.9 GiB): the prod overlay trims the baseline limits
  (`GOMAXPROCS=2`, smaller caps, 7d Prometheus retention) and a **node_exporter** feeds
  host-memory metrics to Grafana so it can be resized reactively as players arrive.
  `GATEWAY_ABUSE_BAN_ENABLED=true` in prod (the per-IP ban is meaningful only with real
  client IPs). The `vpn`+`bot` pair is gated to a `telegram-local` compose profile the test
  contour activates; the prod main host omits it.

**Native Android build (Capacitor).** The SPA is also packaged as a standalone **Android app** (Capacitor 8,
appId `ru.eruditgame.app`, "Эрудит"; minSdk 24, compile/targetSdk 36, JDK 21), first for **RuStore**. It is
a **bundle** model — the WebView loads the packaged `dist/` from app assets (**no `server.url`**), so there
is no OTA: updates ship through the store, and the **client-version gate** (§2) turns away a build too old to
speak the current wire contract. Because the packaged origin is `file://`, the native build talks to an
**absolute** gateway origin (`VITE_GATEWAY_URL` = the production origin; `lib/origin.ts` centralises how absolute URLs are built), and the service worker is skipped (the bundle is
the cache). It is **offline-first**:
`ui/scripts/bundle-dicts.mjs` copies the versioned `scrabble-dictionary` release DAWGs into
`dist/dict/<variant>@<version>.dawg` (keyed on `VITE_DICT_VERSION`), so a cold first launch with no network
enters as a **local guest** (§3) and plays local vs_ai / hotseat from the bundled dictionaries. Purchases are
hidden in the MVP (`VITE_PAYMENTS_DISABLED`). The APK is built by a **manual** `workflow_dispatch` workflow
(`.gitea/workflows/android-build.yaml`, from `master`, `confirm=build`) that builds the native SPA, bundles
the dicts, and assembles a **release APK** artifact — signed when the `ANDROID_KEYSTORE_*` secrets are
present, unsigned otherwise. `versionCode`/`versionName` are deterministic from the release tag `vMA.MI.PA`
(`versionCode = MA*1_000_000 + MI*1_000 + PA`, `versionName = "MA.MI.PA"`). The runner is a host-executor
with a host-provisioned Android SDK; RuStore upload is manual. Runbook:
[`../deploy/README.md`](../deploy/README.md).

## 14. CI & branches

- **Two long-lived branches**: **`development`** is the integration
  trunk and **`master`** the production trunk; `feature/*` branches are cut from
  `development` and PR back into it (the genesis commit necessarily landed on
  `master`). A commit to a `feature/*` branch triggers nothing.
- A single `.gitea/workflows/ci.yaml` (Gitea has no cross-workflow `needs`) runs the
  suite on a PR into `development`/`master` and on a push to `development`. Its
  `unit` (gofmt/vet/build/unit-test), `integration` (Postgres-backed `integration`
  tag, testcontainers `postgres:17-alpine`, Ryuk off, serial) and `ui`
  (check/unit/build/bundle-budget/e2e) jobs are **path-conditional** (a
  `changes` job filters by changed paths), and an always-running **`gate`** job
  aggregates them (passing when each succeeded or was **skipped**) and is the single
  branch-protection required check (`CI / gate`), so a path-skipped job never blocks
  a merge.
- A gated **`deploy`** job auto-rolls the **test contour** on a PR into — or a push
  to — `development` (it generates the bot-link certs, then `docker compose up -d
  --build` on the runner host), then probes the gateway (`GET /`) **and the Telegram
  validator's and bot's liveness** (via `docker inspect`: running, not restarting,
  stable restart count, with a VPN-handshake grace period, since neither has public
  ingress and a crash-loop is otherwise invisible). A PR into `master` is test-only; the prod
  deploy is the manual workflow. Secrets/variables are prefixed
  `TEST_`/`PROD_` per contour.
- The engine consumes `scrabble-solver` as a **published, versioned module**
  (`gitea.iliadenisov.ru/developer/scrabble-solver`, pinned in `backend/go.mod`); both Go
  workflows set `GOPRIVATE=gitea.iliadenisov.ru/*` so go fetches it directly from this Gitea
  (no public proxy/checksum DB, no sibling clone). The dictionaries ship as a **release
  artifact** from the `scrabble-dictionary` repo; the workflows download
  `scrabble-dawg-<DICT_VERSION>.tar.gz` and point the engine tests at it via
  `BACKEND_DICT_DIR`.
- After any push, the run is watched to green before a stage is declared done
  (`python3 ~/.claude/bin/gitea-ci-watch.py`).

## 15. User feedback & account roles

Players reach the operators through a **Feedback** screen (Settings → Info, registered accounts
only). A message (≤1024 runes) plus an optional single attachment is stored in
`feedback_messages`; the sender's IP (gateway-forwarded, as for chat), the submitting
**channel** (telegram/ios/android/web, client-reported and validated), the **client app version**
(`__APP_VERSION__`, the build a report was sent from), the client's **detected UTC offset** at
submit (`browser_tz`, `±HH:MM`) and a snapshot of the sender's interface language are recorded. The domain is `internal/feedback` (store + service), modelled on the admin
chat-moderation surface.

**Anti-spam.** A player with an unreviewed message (`read_at IS NULL`) cannot submit another; the
gate is server-side. Because the operator must act before the next message, this is itself the
rate limit — there is no separate per-user feedback limiter.

**Operator review** happens in the server-rendered console (`/_gm/feedback`): an
unread / read / archived queue with per-user search (the `/users` glob masks), a detail card
(user content rendered as auto-escaped `html/template` text; it shows the channel, interface
language and app version, and the filed time in three zones — UTC, the browser offset detected at
submit, and the sender's saved profile zone, each `N/A` when not known), and the read /
reply / archive / delete / delete-all actions — each marks the message read; merely opening the
detail does not.
The attachment is served from `/_gm/feedback/:id/attachment` with `X-Content-Type-Options:
nosniff`: images inline (loaded only via `<img>`, which never executes — a renamed non-image is
inert), everything else as an `application/octet-stream` download. The UI gates the attachment by
file extension (the allow-list is not shown to the user) and the backend mirrors that allow-list
plus the ≤1,000,000-byte size cap as the trust boundary; file *content* is not inspected. The
1,000,000-byte cap keeps the whole `feedback.submit` request under the gateway's 1 MiB edge body
cap (§12) without weakening it.

**Reply delivery.** The operator's reply lives on the message row and is shown back on the
feedback screen ("Ответ на ваше последнее сообщение") for the player's most recent replied
message. It becomes "read by the player" the instant the screen fetches it (delivery = read) and
is hidden one week after. A Settings → Info badge — folded into the lobby ⚙️ badge together with
the friend-request count — signals an undelivered reply; it rides the existing `NotificationEvent`
with a new `admin_reply` sub-kind (no new push schema) plus an authoritative poll on lobby load.

**Account roles.** `account_roles` (account_id, role) is the project's first per-account role
table — the reusable replacement for per-feature boolean flags. The first role, `feedback_banned`,
blocks **only** feedback submission (unlike a suspension, the whole-account block of §12). It is
granted from the feedback section (the delete-with-block checkbox) and granted/revoked from the
`/users` console card. Roles are validated against a known set in Go, so adding one needs no
migration.

**Telegram support relay.** Separate from the in-app Feedback above, the bot offers a direct
support channel for users who message it on Telegram. Any message other than `/start` is relayed
into a private **forum supergroup** (`TELEGRAM_SUPPORT_CHAT_ID`): a user's first message opens a
dedicated **forum topic** whose first message is an info card (the name is a tappable profile
mention via a `text_mention` entity — which also keeps a name beginning with `/` from being read as
a command — plus @username, language, premium, id) carrying a Block/Unblock toggle and a Clear
button; every message is then
copied into that topic (`copyMessage`, so any content — text, media, voice, files — carries over).
Any **administrator** of the support chat who writes in a user's topic has their message copied
back to that user; non-admins and the bot's own posts are ignored (the loop guard). Block drops the
user's incoming messages (the topic stays); Clear deletes the relayed messages, keeping the info
card; a topic the operators delete is reopened on the next message. State (user→topic map, block
list, relayed message ids) is a small JSON file on a writable volume — the bot host has no database
and cannot reach Postgres. The relay is **bot-local**: it touches neither the backend, the gateway,
nor `feedback_messages`. The bot must be an administrator in the forum group with the manage-topics
and delete-messages rights.

> **Decision (2026-06-23) — bot-local support relay over forum topics.** The direct Telegram
> support channel lives entirely in the bot (no backend, no bot-link command), because the bot host
> has no database. One forum **topic per user** (not a reply-to-header thread) gives native per-user
> separation and survives message deletion; the topic id, not a fragile header message, anchors the
> mapping. Operators are the support chat's administrators (no separate owner id); messages relay
> both ways with `copyMessage`. State persists as JSON on a dedicated volume.
