# Scrabble Game — implementation plan

Living plan and **stage tracker**. Each stage is implemented in its own session;
the rules for starting and finishing a stage are in [`CLAUDE.md`](CLAUDE.md).
The architecture/decision record is [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md);
behaviour is [`docs/FUNCTIONAL.md`](docs/FUNCTIONAL.md). When a stage produces a
decision, bake it back here **and** into the affected docs/code in the same PR.

## Context

Greenfield multiplatform Scrabble. Players arrive from a platform (Telegram
first; later VK/MAX/iOS/Android) or standalone web (email / guest). Three
executables — `gateway`, `backend`, `ui` — plus per-platform side-services.
Deliberately simpler than the sibling `../galaxy-game` (idea donor, not a
template). The `../scrabble-solver` engine is embedded in-process as a library.

## Locked decisions (recap — full record in docs/ARCHITECTURE.md)

Stack: `go.work` monorepo, modules `scrabble/<name>`, Go 1.26.x, backend
gin+pgx+Postgres(schema `backend`)+goose+zap+OTel (deps added when first used).
Wire: Connect-RPC + FlatBuffers (client↔gateway), REST/JSON + `X-User-ID`
(gateway↔backend), gRPC server-stream for live events. Auth: platform-native,
thin opaque session token, no Ed25519/signing, likely no Redis. UI: pure
HTML5/CSS, plain Svelte + Vite, Capacitor for native. MVP surfaces: Telegram +
web (email + ephemeral guest) + link/merge. Variants: ru/en/Эрудит.
Legality: validate-at-submit. End: empty bag+rack / 6 scoreless / 24h timeout.
Hint: top-1. Word-check: unlimited + complaint. Robot: P(win)≈0.40, margin
targeting, [2,90]min skewed timing, sleep 00:00–07:00 opp-tz, nudge logic.
Dictionary: pin per game. History: structured + GCG export, dictionary-
independent (see ARCHITECTURE §9.1).

## Stage tracker

| # | Stage | Status |
|---|-------|--------|
| 0 | Scaffolding (go.work, backend skeleton, docs, CI) | **done** |
| 1 | Backend foundation (config, server, Postgres+goose, sessions, accounts) | **done** |
| 2 | Engine package over scrabble-solver | **done** |
| 3 | Game domain (lifecycle, rules, hint, word-check, history+GCG, stats) | **done** |
| 4 | Lobby & social (matchmaking, friends, block, chat, profile, nudge) | **done** |
| 5 | Robot opponent | **done** |
| 6 | Gateway edge (Connect/FB, platform auth, sessions, push bridge, admin) | **done** |
| 7 | UI — playable slice + UX polish (Svelte+Vite, board, lobby, chat, hint/word-check, i18n) | **done** |
| 8 | UI — social/account/history (friends, blocks, invitations, profile edit, stats, history/GCG) | **done** |
| 9 | Telegram integration (bot side-service, deep-link, push) | **done** |
| 10 | Admin & dictionary ops (complaint review, version reload) | **done** |
| 11 | Account linking & merge | **done** |
| 12 | Observability & performance (telemetry, metrics, guest GC) | **done** |
| 13 | Alphabet on the wire (UI alphabet-agnostic) | **done** |
| 14 | Solver & dictionary split (publish solver + scrabble-dictionary repo/artifact) | **done** |
| 15 | Dual Telegram bots & language-gated variants | **done** |
| 16 | Deploy infra & test contour (Dockerfiles, gateway static UI, compose, observability) | **done** |
| 17 | Test-contour verification & defect fixes | **done** |
| 18 | Prod contour deploy (SSH export/import, manual after merge) | todo |
| 19 | User feedback (in-app submit + attachment, admin review/reply, account roles) | **done** |

Scaffolding is incremental: `go.work` lists only existing modules; each stage
adds the modules it needs.

## Stages

Each stage: read this plan + relevant docs, **interview the owner on the open
details below**, implement within scope, then update plan/docs/code and get CI
green before marking done.

### Stage 0 — Scaffolding *(done)*
Scope: `go.work` (Go 1.26.3, `use ./backend`); minimal runnable `backend`
(gin, zap, `/healthz`, `/readyz`, env config); docs skeleton; `PLAN.md`;
`CLAUDE.md`; `.gitea/workflows/go-unit.yaml`; README; `.gitignore`.
Acceptance: `go build ./backend/...` + `go vet` + gofmt clean +
`go test ./backend/...` green; CI green on push.

### Stage 1 — Backend foundation
Scope: config/server route groups (`/api/v1/{public,user,internal,admin}`,
probes), Postgres (pgx) + embedded goose migrations + schema `backend`,
telemetry (OTel) wiring, in-memory cache scaffolding, thin sessions + accounts +
platform identities.
Open details: Postgres version + DSN/`search_path` convention; jet vs
sqlc/sqlx (default jet); migration naming; exact session-token shape (opaque
random length, TTL, revocation); account/identity table shape; whether the
admin bootstrap lands here or in Stage 10.

### Stage 2 — Engine package
Scope: `backend/internal/engine` over scrabble-solver — versioned DAWG
load/registry, GenerateMoves/ValidatePlay/ScorePlay wrappers, bag/rack, the
**dictionary-independent** game-state model + decode helpers. Add
`replace scrabble-solver => ../scrabble-solver` to `go.work` here and solve the
CI sibling-checkout (clone `gitea.iliadenisov.ru/.../scrabble-solver`).
Open details: how CI obtains the solver (clone sibling vs publish/tag the
solver module); in-memory game-state representation; how blanks and exchanges
are modelled; Эрудит specifics to verify against the solver.

### Stage 3 — Game domain
Scope: create/join, turn order, submit play/pass/exchange/resign,
validate-at-submit, scoring, end-conditions, 24h timeout/auto-resign, hint,
word-check + complaint capture, structured history + GCG writer, stats on
finish.
Open details: GCG dialect details (blanks, exchanges, notation); exact stats
edge cases; turn-timeout scheduler mechanism (cron vs per-game timer);
complaint payload shape.

### Stage 4 — Lobby & social
Scope: matchmaking pool, friends, block, per-game chat, profile + email
confirm-code, nudge.
Open details: pool fairness/keying confirmation; deep-link format per platform;
chat length limit + retention; friend-request lifecycle; email-code provider
(SMTP relay choice).

### Stage 5 — Robot opponent
Scope: human-like player — balance ~0.40, margin targeting, skewed [2,90]min
timing + sleep + nudge logic, friend/DM blocking, name pool.
Open details: exact delay distribution + parameters; margin band; name pool
source; how the scheduler drives robot moves; metrics for tuning balance.

### Stage 6 — Gateway edge
Scope: Connect/gRPC-Web (h2c), Telegram initData validation → session →
`X-User-ID`, in-memory rate-limit, admin Basic-Auth passthrough, FlatBuffers
transcoding, in-app push stream bridging backend `push` gRPC stream, email +
ephemeral-guest paths.
Open details: FlatBuffers schema layout + message_type catalog; rate-limit
classes/limits; admin surface routing; session cache shape at the gateway.

### Stage 7 — UI
Scope: plain Svelte + Vite static; Connect-web + FlatBuffers client; lobby (my
games, profile tabs); board (HTML5/CSS grid, drag-n-drop, no assets); chat;
hint/word-check; in-app stream; i18n en/ru; in-memory session (+IndexedDB if
available); Capacitor-ready structure.
Open details: detailed game-board UX (deferred by the owner to this stage);
client routing; offline/refresh behaviour; design system / theming.

#### Suggested layouts (lobby + game screen)

User note:
> Detailed interview about UI/UX is **strongly** required.
> Too much to discuss.

```text
     ┌────────────────────┐                   
     │ Display_Name      =│- Profile          
     ├────────────────────┤- Settings         
     │ Invitations        │- About            
     │ - list             │                   
     ├────────────────────┤                   
     │ Active games       │                   
     │ - list             │                   
     ├────────────────────┤                   
     │ Finished games     │                   
     │ - list             │                   
     │                    │                   
     │                    │                   
     │                    │                   
     │                    │                   
     │                    │                   
     │                    │                   
     │                    │                   
     ├────────────────────┤                   
     │ ┌───┐  ┌───┐  ┌───┐│                   
     │ New │  Stats  Tourn│                   
     │ └───┘  └───┘  └───┘│                   
     └────────────────────┘                   
     ┌────────────────────┐                   
Lobby│◄                 ==│- History          
     ├────────────────────┤- Chat             
     │You  Ann  Kaya  Rick│- Check word       
     │136  700   179    39│- Drop game        
     ├────────────────────┤                   
     │                    │                   
     │                    │                   
     │                    │                   
     │        c           │                   
     │      words         │                   
     │        o           │                   
     │        s           │                   
     │        s           │                   
     │                    │                   
     │                    │                   
     ├──┬──┬──┬──┬──┬──┬──┤ ┌──┐              
     │A │Q │Z │* │N │I │W │◄│  │MakeMove/Reset
     ├──┴──┴──┴──┴──┴──┴──┤ └──┘              
     │  ┌───┐ ┌───┐ ┌───┐ │                   
     │  Draw│ Skip│ Shfl│ │                   
     │  └───┘ └───┘ └───┘ │                   
     └────────────────────┘                   
```

### Stage 8 — UI: social, account & history surfaces
Scope: the UI surfaces deferred from Stage 7's playable slice, wiring the matching
backend/gateway operations as each screen needs them (the Stage 6 vertical-slice
pattern): friends (request/accept/decline/list), per-user blocks, friend-game
invitations (create 2–4 player, accept/decline, invitations list), profile **editing**
(`account.UpdateProfile` + the email confirm-code binding UI), the statistics screen,
and the history viewer with GCG export/download.
Open details: friends/invitations UX; stats presentation; history/GCG viewer + download
mechanics; any new validation the profile-editing forms need.

### Stage 9 — Telegram integration
Scope: bot side-service, deep-link invites, platform push (your-turn / nudge),
Mini App launch/auth; backend↔platform internal API.
Open details: bot framework/library; deep-link scheme; push message templates;
internal API contract; Mini App hosting/origin.

### Stage 10 — Admin & dictionary ops
Scope: admin endpoints (users, games, complaint review queue, dictionary
versions + reload), complaint→dictionary update pipeline.
Open details: whether a server-rendered console is wanted or JSON-only; the
dictionary rebuild/deploy pipeline; complaint resolution workflow.

### Stage 11 — Account linking & merge
Scope: link-via-confirm; merge-into-A (stats sum, transfer games/friends,
dedupe). High blast-radius — focused regression tests.
Open details: conflict resolution (active games on both, duplicate friends,
display-name collisions); irreversibility/audit; confirm-flow per platform.

### Stage 12 — Observability & performance
Scope: wire a configurable **OTLP** exporter (alongside `none`/`stdout`), shared in a
new `pkg/telemetry`; add telemetry to the **gateway** and the **Telegram connector**
(providers + `otelgrpc` on the gRPC hops) for parity with the backend; add
domain/operational **metrics** close to the business (game replay/validate timings,
started/abandoned games, live-cache size, chat/nudge counts, the edge roundtrip, Go
runtime metrics); discharge **TODO-3** (abandoned-guest GC). The OTLP collector and
dashboards are stood up with the deploy (Stage 15); the default exporter stays `none`,
so CI needs no collector. Performance is operational-metric instrumentation, not
speculative optimisation (the standing "evidence first" rule — no measured hotspot yet).
Open details: exporter default and whether a collector is stood up now; the metric set
and its attributes; the guest-reaper trigger given revoke-only sessions.

### Stage 13 — Alphabet on the wire (TODO-4)
Scope: make the UI **alphabet-agnostic**. On game-screen load the client receives the
variant's alphabet table `(letter, index, value)` for **display only**, caches it in
memory by variant (a request flag gates whether the table is included, so it is not
resent on every state poll); live play then exchanges **letter indices** both ways, and
**word-check** sends indices, constraining input to the variant's alphabet. The engine
already works in alphabet-index bytes, so the wire does *less* decoding in live play; the
durable journal / history / GCG stay decoded concrete characters (the §9.1
dictionary-independent invariant is untouched). The alphabet comes from the **solver's
rules** (not the DAWG), so the wire table is pinned by the solver version. **Index-drift
caveat:** the running solver, the DAWGs (built against it — Stage 14 / TODO-2) and the
wire table must agree, or letter indexing silently corrupts. Blast radius: `pkg/fbs`
(a new Alphabet table; index fields in `StateView`/rack and in
`SubmitPlay`/`Exchange`/`check_word`) → backend DTO encode/decode → UI
`codec.ts`/`premiums.ts` → board/rack render, the move/exchange/word-check senders, the
mock transport and the Vitest tests.
Open details: the fbs shape and `include_alphabet` flag placement; whether to keep
concrete-letter fields during the transition; whether tile exchange moves fully to
indices; the premiums.ts parity-test rework.

### Stage 14 — Solver & dictionary split (TODO-1 + TODO-2)
Re-scoped from the original "CI & deploy": that was several sessions of work, so the
deploy + observability + the two-bots idea were split into **Stages 15–18** below and this
stage took only the dependency/artifact split that everything else builds on. Scope: publish
`scrabble-solver` as a versioned Gitea module and split the dictionary build into a new
`scrabble-dictionary` repo delivering a **release artifact**, then make `scrabble-game` consume
both — discharging **TODO-1** and **TODO-2**.

- **TODO-1 — solver published.** `scrabble-solver` renamed to module
  `gitea.iliadenisov.ru/developer/scrabble-solver`, tagged **v1.0.0**; `wordlist`/`dictdawg`
  de-internalised to public packages (the dict repo imports them); `cmd/builddict`/`dictprep`/the
  `dictionaries` submodule moved out; `internal/dict` repointed at the committed `dawg/*.dawg`
  fixtures. `backend/go.mod` pins `v1.0.0`; the `go.work` replace and the CI sibling-clone are
  gone; `GOPRIVATE=gitea.iliadenisov.ru/*` makes go fetch it directly (no public proxy/checksum DB).
- **TODO-2 — dictionary artifacts.** New repo `developer/scrabble-dictionary` holds the word-list
  sources + `cmd/builddict` and builds the three DAWGs against the **published solver + pinned
  `dafsa`/`alphabet` v1.1.0**, so they are byte-identical to the solver's fixtures (no index drift).
  Released as `scrabble-dawg-vX.Y.Z.tar.gz` (flat, one semver per set); the Go workflows download it
  and point `BACKEND_DICT_DIR` at it. The runtime contract is unchanged (additive
  `BACKEND_DICT_DIR/<version>/`, `engine.OpenWithVersions`, per-game `dict_version` pin; a version is
  safe to retire once no active game pins it).

### Stage 15 — Dual Telegram bots & language-gated variants *(done)*
Re-framed at its start to be **service-agnostic**: the sign-in service returns, with the user identity, a
**set of supported game languages** (subset of `{en, ru}`, ≥ 1) that gates the New Game variant choice.
Built: the connector hosts **two bots in one container** (one per service language, each its own token +
game channel; the same Telegram user id spans both); `ValidateInitData` tries each token in turn and
returns the validating bot's **`service_language`** + **`supported_languages`** set. The set rides the
`Session` (FlatBuffers, session-scoped, not persisted) and the UI offers only the matching variants on New
Game (en → English; ru → Russian + Эрудит) — gating **only** the start of a new game (auto-match + friend
invite); existing games of any language are unrestricted and the backend does not enforce. The service
language is persisted (`accounts.service_language`, migration `00010`, written on every login —
last-login-wins) and routes the user-facing out-of-app push (`Notify`) back through the right bot (falls
back to `preferred_language`). Non-Telegram logins (web/email/guest) carry the gateway default set
(`GATEWAY_DEFAULT_SUPPORTED_LANGUAGES`, all variants). Admin broadcasts (`SendToUser`/`SendToGameChannel`)
pick the bot by an **operator-chosen** language in the console — unrelated to `ValidateInitData`.

> **Superseded (2026-06-20, pre-release phase SB):** the two bots collapsed into **one** and the
> language-gated variant choice moved to a per-user **`variant_preferences`** profile set (default Erudit
> only). `accounts.service_language`, `supported_languages`, the `*_EN`/`*_RU` env vars,
> `GATEWAY_DEFAULT_SUPPORTED_LANGUAGES` and game-language push routing are gone; the single bot renders in
> the recipient's `preferred_language`. Current state: `docs/ARCHITECTURE.md` §3/§10, `PRERELEASE.md` row SB.

### Stage 16 — Deploy infra & test contour *(done)*
Scope: the deploy machinery + the **test contour** (the bulk of the original Stage 14). Backend +
gateway **Dockerfiles** (multi-stage distroless, mirroring the Stage 9 connector image); the gateway
gains **static UI serving** — **embedded** via `go:embed` (a node build stage in the gateway image),
SPA served at both `/` (web) and `/telegram/` (Mini App), the §13 single-origin model; prod UI build
vars (`VITE_TELEGRAM_BOT_ID`, `VITE_TELEGRAM_LINK`, `VITE_GATEWAY_URL`) as image build-args; a root
`deploy/docker-compose.yml` (backend + gateway + Postgres + connector + VPN sidecar + the **full
observability stack** — OTel Collector + Prometheus + Tempo + Grafana with provisioned dashboards) on
the external `edge` network behind the host caddy (VPN sidecar only for the connector); the backend
image pulls the DAWG release artifact (Stage 14). **The test contour deploys automatically on push to
a feature branch** (`docker compose up -d --build` on the local host where the gitea runner lives),
with a post-deploy probe (`GET /` on the gateway). Test-contour secrets use the **`TEST_`** prefix
(see Stage 16).
Open details (re-interview at start): the dashboard set; the gateway static-serving hook (before the
h2c wrap — `/` + `/telegram/` mounts; a committed `dist` placeholder so `go build` works without a UI
build); Postgres healthcheck/volume; whether the connector-scoped compose is retired for the root one;
collector/Tempo/Prometheus retention.

### Stage 17 — Test-contour verification & defect fixes *(done)*
Scope: exercise the deployed **test contour** end-to-end and fix the defects it surfaces — the
"does it actually work in the contour" pass before prod. Bring up the `development` deploy, then
verify each piece against a real run: the gateway serves the SPA at `/` and `/telegram/`; the admin
console and Grafana sit behind the single `/_gm` Basic-Auth; the Telegram **bots** start (test
environment) and the Mini App launches/authenticates; a game can be created and played through (web
+ Mini App); the **observability** stack receives data (Prometheus targets up, the dashboards
populate incl. `accounts_created_total`/`active_users`, traces reach Tempo); the out-of-app push
works. Fix the defects found and harden where the run exposes gaps — notably a CI **connector
liveness check** (the deploy probe only hits the gateway today, so a crash-looping connector is
invisible — that is how the Stage 16 test-env miss went unnoticed) and **path-conditional CI** (skip
the jobs whose code did not change, behind a single always-running gate job so branch-protection
required checks stay satisfiable — a skipped required check otherwise blocks the merge).
Open details (interview at start): the verification checklist + pass bar; which discovered defects
are in-scope vs deferred; the changed-paths design + the aggregate gate job; the connector
liveness-check grace period (the VPN sidecar handshake lets the connector restart a few times before
it settles).

#### Found caveats (all resolved in Stage 17 — see *Refinements → Stage 17*)

The owner's collected caveats below were classified (fix-now / verify-then-fix / discuss),
discussed where they were forks, and resolved in one session with tests where practical. The
per-item outcomes are recorded under *Refinements logged during implementation → Stage 17*; the
raw list is kept here as the record of what the first contour run surfaced.

- /_gm/grafana/ требует повторного ввода пароля basic auth, хотя до этого я уже зашёл в /_gm/
  Такого быть не должно: графана живёт под /_gm/ и ей не нужен свой auth.

- нужна ещё метрика "продолжительность хода" - сколько игроки тратят на каждый ход,
  скорее всего, понадобится новое поле last_move_ts если ещё нет, так же нужно будет завести
  метрику в графане как общую, так и и по конкретному пользователю (можно ли? дорого ли?),
  а так же с привязкой к номеру хода и без номера хода. Всё это понадобится для анализа
  способностей игроков, чтобы подогнать под них роботоа. А так же - выявлять читеров.

- регистрация пользователя из телеграм (как и других коннекторов):
  пытаться очистить имя от посторонних символов, аналогично проверке при вводе имени в профиле.
  если после очистки ничего не осталось, поставить имя Player/Игрок-XXXXX (5 рандомных цифр),
  язык в зависимости от внешнего коннектора.

- game - chat - nudge. Когда мой ход и я жму nudge, появляется сообщение "сейчас не ваш ход".
  Думаю, опечатка - "не" лишняя, проверь на всех языках.

- если открыли игру через telegram, надо в настройках вообще полностью скрыть переключатель темы "авто/светлая/темная",
  т.к. тему задаёт сам телеграм (уточни, в какой проперти её можно забрать, и нужно ли, сейчас оно уже нормально работает
  на самих стилях)

- возможно, к предыдущему пункту: запускаю мини апп на macos/telegram desktop. в самой macos у меня темная тема.
  когда я включаю тему "авто" в настройках mini app, а в самом телеграме - светлую, всё ломается, nav bar и tab bar
  рисуются темным фоном, список игр и меню - светлым, поле игры - тёмное, вокруг него светлоая рамка.
  Провернул тот же трюк на ios - всё чётко, в режиме "авто" он полностью держит ту настройку, которая в
  самом телеграме задана. Проверь, можно ли это починить для desktop-версии тг, скорее всего там
  системные настройки как-то в браузер протекают. Ну если не получится понять причину, тогда и черт с ним.

- не знаю, ошибка это или by design - если у меня открыта игра сразу в desktop telegram и на ios,
  то когда я делаю ход, в другом окне не обновляется ничего - ни само игровое поле, ни лобби.
  интересно, как ходят уведомления через gateway - по последнему активному push-каналу, что ли?
  если так, стоит ли чинить, чтобы у пользователя все пуш-каналы поддерживались или это дорого?
  нужен твой анализ и совет.

- надо подкрутить тайминг автоматического хода работа. идея такая: сейчас, насколько я помню, время хода
  выбирается от 2 до 90 минут с перекосом ближе к 2 минутам (поправь если что). я предлагаю этот интервал
  сделать динамическим в зависимости от хода. Например, средяя партия это 25-30 ходов, предположительно.
  На первом ходу интервал должен быть 1..5 минут, на последнем - 10..90 минут, всё так же с перекосом в меньшую сторону.
  А то я сейчас поиграл, роботы на первых ходах по 15 минут думают.
  Сможешь такую хитрую формулу составить? Цифры ориентировочные. Потом после набора реальной статистики подкрутим цифры.
  Заодно напомни, как работает формула "перекоса", можно ли её "заставить" косить почаще в меньшую сторону, как бы имитируя
  активного игрока. Этот пункт требует тщательного обсуждения, пожалуй.

- при навигации между лобби и игрой есть задержка едва заметная на глаз, думаю, связанная с тем, что UI все данные по игре перезапрашивает
  каждый раз. Кроме этого, когда я в лобби возвращаюсь, глаз ловит перерисовку экрана, довольно быстро, но есть какое-то
  неприятное ощущение, что туда что-то подгружается. А мы можем внутри UI наполнять кэш этими данными и экраны не рисовать
  каждый раз, а просто подменять? не знаю, как это работает, если честно. Но вот информацию по игре, в которую пользователь
  проваливался 1 раз, совершенно точно можно положить в кэш и обновлять его когда с сервера приходит новый ход и т.п.

- при запуске в telegram, надо бы цвет фона nav bar сделать фоном телеграма, а то он "выпадает" из общего дизайна.

- а вот фон рекламной строчки под nav bar наоборот, сделать бы чуть светлее (в тёмной теме) или темнее (в светлой),
  чтобы был акцентирован, но не ярко. что-то там есть в стилях телеграма такое готовое?
  ну и для собственного дефолтного стиля тоже надо выбрать соответствующие.

- Переключаюсь в ios в другое приложение, по возвращении ловлю "проблема соединения, повторяем".
  Вроде бы в телеграм-бандле есть обработчики всяких событий, в том числе background in/out, или как там оно зовётся.
  Посмотри, можно ли что-то с этим сделать? Если да, то именно в случаях когда приложение уходит в фон - не надо рисовать
  плашку с ошибкой, просто молча пытаться соединиться, то есть плашка появится когда приложение на в фоне на следующем retry.

- при использовании подсказки в игре ато зум ведёт в лево-верх, а не туда, где была поставлена подсказка.

- В русских партиях нужны русские имена для роботов, но можно вперемешку с латинскими именами, только чтобы латинских имён
  было не больше 20%.

- Сделать анимацию переходов между экранами: наезд справа если из лобби куда-то переходим и наоборот, уезжание вправо и открытие лобби, когда нажимаем back в навигации.

- Цвет и размер плашки с игроками над доской: давай сделаем не "кнопками" самих игроков, а просто поделим это пространство
  поровну между игроками, а активного игрока будем показывать за счёт "поднятия" его плашки, за счёт теней слева и справа, чтобы
  остальные игроки были как бы "утоплены" внутрь.

- В игре клик/тач по плашке с именами игроков открывает/закрывает историю.

- В истории ходов странное выравнивание колонки со словами, они буквально скачут влево-вправо.

- В многословных партиях надо в истории показывать основное слово + дополнительное (если это ещё не сделано, надо проверить)

- При открытии истории нижнюю границу таблицы ("тень") сразу прибивать к доске, а не растягивать вслед за таблицей.

- Баг. Открыл игру через ru-телеграм бота, пытаюсь сделать "new -> русский" (это скрэбл с русским алфавитом), появляется красная плашка
  "что-то пошло не так". при этом "new -> эрудит" работает. Попробуй посмотреть в логах сейчас, может что-то есть. Или как-то иначе проанализируй, или давай вместе будем смотреть, если не получится.

### Stage 18 — Prod contour deploy
Scope: the **production contour** on a remote host over SSH. Deploy by **container export/import**
(`docker save` → `scp`/ssh → `docker load` → `docker compose up` on the remote), the SSH key + host IP
in Gitea secrets; **strictly manual** (`workflow_dispatch`) after `development` is merged to `master`
(the Stage 16 branch model: `feature/* → development → master`, merge gated green). Two-contour config
uses **`TEST_`/`PROD_` secret/variable prefixes** — Gitea 1.26 has no deployment environments (verified:
the `environments` API 404s), so a flat prefixed namespace is the convention.
Reuses the Stage 16 `deploy/docker-compose.yml` as-is, mapping the **`PROD_`** set onto the same
unprefixed compose vars. **No host caddy on prod**, so the contour's own caddy terminates TLS — set
`CADDY_SITE_ADDRESS` to the prod domain so caddy does its own ACME (the Caddyfile is already
parameterised for this; the test contour leaves it `:80` behind the host caddy).
Open details (re-interview): export/import vs a registry trade-off; prod domain/cert source (ACME vs a
provided cert) at the contour caddy; prod VPN; rollback.

### Stage 19 — User feedback *(done)*
A user→operator feedback channel, sequenced after the numbered stages but shipped **before** the Stage 18
prod deploy. Scope: a **Feedback** screen under Settings → Info where a registered player sends a message
(≤1024 runes) with an optional single attachment; the operator reviews and replies in the server-rendered
**admin console**; the reply is delivered back in-app with a Settings/Info badge. Introduces the project's
**first per-account role** (`account_roles`), the reusable replacement for per-feature flags: the
`feedback_banned` role blocks **only** feedback submission (not the whole account, unlike a suspension),
granted from the feedback section's delete-with-block and granted/revoked from `/users`.

- **Data:** migration `00004_feedback` — `feedback_messages` (body, attachment `bytea`, sender_ip, channel,
  read/archived/reply/reply-read timestamps) + `account_roles` (account_id, role). New backend domain
  `internal/feedback` (store + service), modelled on `internal/social` + the admin chat-moderation surface.
- **Anti-spam gate:** a player with an unreviewed message cannot send another ("Ожидаем рассмотрения вашего
  последнего обращения"); enforced server-side. This *is* the rate limit — no separate one.
- **Reply lifecycle:** the reply lives on the message row; shown on the feedback screen ("Ответ на ваше
  последнее сообщение") for the player's latest replied message; it becomes read the moment the screen
  fetches it (delivery = read) and is hidden one week after. The badge (lobby ⚙️, combined with friend
  requests; Settings → Info "1") rides the existing `NotificationEvent` with a new `admin_reply` sub-kind —
  no wire-schema change for the push.
- **Wire:** new FlatBuffers `feedback.submit` / `feedback.get` / `feedback.unread`; `feedback.submit` is
  **guest-gated at the gateway** via a new `Op.NonGuest` flag (the session resolve now carries `is_guest`),
  so a guest's submission never reaches the backend; the backend rejects guests too.
- **Security:** body + attachment limits enforced server-side (the UI limits are UX only); attachments
  served with `X-Content-Type-Options: nosniff`, images inline-previewed via `<img>` (which never
  executes), everything else download-only; the UI gates the attachment by extension (allowed types not
  listed) and the backend mirrors the allow-list + size as the trust boundary; the console renders user
  content as auto-escaped text (`html/template`).
Open details settled at planning: attachment ≤1,000,000 B (keeps the 1 MiB edge cap); allowed types
(images + pdf/txt/log/doc/docx/rtf/zip/gz/7z); channel from the client (telegram/ios/android/web); guests
cannot submit; three-way admin filter.

## Refinements logged during implementation

- **Stage 0**: solver `replace` deferred to Stage 2 (nothing imports it yet;
  adding the path now would break CI, which checks out only this repo). Docker /
  compose deferred to a stage that has something to deploy. Trunk is `master`
  (owner preference); `feature/*` + PR from Stage 1; the genesis commit lands on
  `master` by necessity.
- **Stage 1** (interview + implementation):
  - Query layer: **go-jet** over `database/sql` (pgx stdlib) + otelsql; a
    `cmd/jetgen` tool regenerates the **committed** code from a throwaway
    container. Postgres **17** pinned for jetgen, tests and prod.
  - Sessions: opaque token stored only as a **SHA-256 hash** (kept as hex
    `text`, not `bytea` — avoids jet bytea-literal friction), **revoke-only**
    (no TTL); revocation-audit table deferred. Backend keeps a warmed
    write-through session cache that gates `/readyz`.
  - Data model: **UUIDv7** PKs; one unified `identities` table
    (`kind ∈ telegram|email`, widen to `vk`/`max` later); no soft-delete /
    actor-audit columns yet.
  - HTTP surface: **service/store/cache layer only**. `/api/v1/{public,user,
    internal,admin}` groups + `X-User-ID` middleware are scaffolding (exposed via
    `Server` group accessors); the session/account REST handlers land with the
    gateway in **Stage 6**. Admin bootstrap deferred to **Stage 10**.
  - Telemetry: providers + request-timing middleware + otelsql; exporters
    `none` (default) / `stdout`; OTLP + dashboards deferred to **Stage 12**.
  - Tests/CI: integration tests behind the `integration` build tag in
    `backend/internal/inttest` + new `integration.yaml` (testcontainers, Ryuk
    off, serial), firing on push and PR. Backend now **hard-depends on Postgres
    at boot** (migrations at startup) — a deliberate contract change from
    Stage 0, documented in both READMEs. All code stays in the existing
    `backend` module under `internal/` (+ `cmd/jetgen`); `go.work` untouched.
- **Stage 2** (interview + implementation):
  - Scope: `internal/engine` is a self-contained **library** (registry, bag,
    `Game` state machine, decode/replay). No `config`/`main`/`server` wiring this
    stage — there is no consumer yet; wiring lands in **Stage 3**, mirroring
    Stage 1's deferred handlers.
  - **Pure rules engine** (interview): the engine owns the in-memory `Game`,
    pure transitions (play/pass/exchange/resign + draw) **and end-condition
    detection**, including the standard **end-game rack-adjustment scoring** — a
    deliberate slice of Stage 3's "scoring/end-conditions" that the pure-engine
    boundary implies. Stage 3 keeps scheduling, the 24h timeout, persistence and
    GCG.
  - **Solver wiring**: `replace scrabble-solver => ../scrabble-solver` in
    `go.work`; `backend/go.mod` requires `scrabble-solver` (placeholder version,
    redirected by the replace) and `github.com/iliadenisov/dafsa` directly (for
    `dawg.Load`). CI clones the **public** solver repo at **master HEAD**
    anonymously into `../scrabble-solver` (no token); both Go workflows gained
    the step (the engine's untagged tests run under the integration workflow too)
    and set `BACKEND_DICT_DIR`.
  - **Dictionaries**: registry loads the committed DAWGs from a directory
    parameter; `dict_version` is an explicit string label; the latest version
    per variant is tracked. Smoke tests validate a known word per variant
    (English/Russian/Эрудит). **Эрудит is handled uniformly** — every real
    difference is already in `rules.Erudit()`; the move.go "single orientation
    per turn" note needs no special code (any single play is one-directional).
  - **Bag/blanks/exchange**: own deterministic `Bag` (Draw + Return) because
    `selfplay.Bag` cannot return tiles; exchange is legal only when the bag holds
    at least a rack and draws replacements before returning the swapped tiles. A
    blank is `Placement{Blank:true}` carrying its designated letter; the history
    keeps the concrete letter plus a blank flag (decoded via `Alphabet.Character`
    / `Decode`). `ReplayBoard` reuses `scrabble.Apply`, so no `internal/encoding`
    dependency.
  - **Deviation from the approved plan**: `docs/FUNCTIONAL.md` (+`_ru`) was left
    unchanged. Stage 2 adds no user-visible behaviour; the variant, per-game
    dictionary and dictionary-independent-history user stories already live in
    Stages 3–4, so a "light touch" here would have duplicated or pre-empted them.
- **Stage 3** (interview + implementation):
  - Scope, as in Stages 1–2: **domain service/store layer + engine wiring, no
    HTTP** (`internal/game`). The gateway↔backend REST surface lands in Stage 6;
    the only active driver this stage is a background turn-timeout sweeper started
    from `main`. The robot (Stage 5) will consume the same service API.
  - **Persistence = event-sourcing + warm cache** (interview): durable state is
    the `games` row plus an append-only decoded move journal (`game_moves`); the
    live position is an `engine.Game` kept in an in-memory cache with a ~24h idle
    TTL and rebuilt by replaying the journal on a miss (the seeded bag makes
    replay exact). Each game is serialised by a per-game mutex; a persistence
    failure evicts the live game so the next access rebuilds. §9 reworded from
    "stored structurally" to this model.
  - **Resign/timeout split** (interview): 2-player resign/timeout only this stage
    (the other player wins); multiplayer drop-out-and-continue + resigned-tiles
    disposition deferred to Stage 4. Per-game **turn-timeout duration** setting
    (5/10/15/30 min, 1/2/3/6/12/24 h; default 24 h) and a per-user **away window**
    (`accounts.away_start/away_end`, default 00:00–07:00 local, honoured by the
    sweeper with midnight-cross handling) added now; profile editing of the away
    window is Stage 4 and the robot's sleep (Stage 5) reuses it.
  - **Engine `Resign` fix** (interview, in `internal/engine`): the resigner keeps
    their accumulated score (no end-game rack adjustment) and never wins; `winner`
    excludes the resigner, so a two-player resign/timeout gives the win to the
    other player regardless of score. Timeout reuses `Resign`, so the game domain
    needs no winner override.
  - **Additive engine domain API**: `Direction`, `Game.SubmitPlay/SubmitExchange/
    EvaluatePlay/HintView/Hand`, `MoveRecord.{Dir,MainRow,MainCol}`,
    `Registry.Lookup`, `ParseVariant` — so `internal/game` never imports
    `scrabble-solver` (keeps the §5 single-importer invariant).
  - **Create = atomic with seats** (interview): `Create` seats all accounts and
    starts; lobby seat-filling is Stage 4. **Sweeper = periodic goroutine**
    (interview; default 60 s, `BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL`).
  - **Hint = settings + wallet** (interview): per-game `hints_allowed` +
    `hints_per_player`, plus a profile wallet `accounts.hint_balance` (spent after
    the allowance; purchases later). Category defaults (random 1 / tournament 0 /
    friendly 1-or-0) are the caller's job (lobby/tournaments).
  - **Stats** (interview): `account_stats` with **`draws`** added beyond §9's
    wins/losses; `max_word_points` = best single **move** score; ties draw,
    resign/timeout is a loss, guests get no stats.
  - **Complaint** (interview): full payload with `game_id`; word-check is scoped
    to the game's pinned `(variant, dict_version)`. Stage 10 owns the resolution
    lifecycle, so the `status` column carries no value CHECK yet.
  - **GCG** (interview): standard Poslfit dialect (UTF-8, `#player`/`#lexicon`
    pragmas, `8G`/`H8` coordinates, lower-case blanks, `.` pass-throughs, `-TILES`
    exchange) plus `#note` lines for resign/timeout; derived from the journal, so
    dictionary-independent.
  - **Engine wiring + config**: `main` loads the registry (`engine.Open`, a hard
    boot dependency like migrations) and starts the sweeper. New config:
    `BACKEND_DICT_DIR` (required), `BACKEND_DICT_VERSION` (default `v1`),
    `BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL` (60 s), `BACKEND_GAME_CACHE_TTL` (24 h).
    No CI change — both Go workflows already clone the solver sibling and export
    `BACKEND_DICT_DIR`. `accounts` gained `away_start`/`away_end`/`hint_balance`
    and the `account` package gained `SpendHint` (it owns its table).

- **Stage 4** (interview + implementation):
  - Scope, as in Stages 1–3: **domain service/store layer, no HTTP** — REST/stream
    is Stage 6. Chat and nudges are **persisted** now; live delivery (push /
    in-app stream) is Stage 6/8. New packages `internal/social` (friends, blocks,
    chat+nudge) and `internal/lobby` (matchmaking + invitations); profile editing
    and the email confirm-code extend `internal/account`. The services have no
    active driver this stage, so `main` builds them and hands them to the server,
    which exposes them via accessors (the Stage 1 scaffolding-accessor pattern) for
    the Stage 6 handlers.
  - **Friends** (interview): request → accept on a single `friendships` table;
    decline/cancel delete the pending row; **blocking severs** any friendship.
  - **Blocks** (interview): the existing global toggles **plus** a per-user
    `blocks` table; block effects are **mutual** (a block either way suppresses
    chat visibility and prevents requests/invitations between the pair).
  - **Friend games** (interview): invitation → accept; the game starts only when
    **all** invitees accept, any decline cancels it, and a pending invitation
    **lazily expires after 7 days** (checked on access — no new sweeper).
  - **Chat** (interview): ≤ **60 runes**, stored with the game forever, the
    sender **IP** kept for moderation (as `text`, following Stage 1's no-`bytea`
    precedent; the gateway forwards it in Stage 6), input **content-filtered**
    (links/emails/phone numbers incl. obfuscated forms) via `mvdan.cc/xurls/v2`
    plus a compact leet/separator normaliser and a ≥7-digit phone heuristic — the
    one new dependency. **Nudge is a chat message** (`kind='nudge'`), rate-limited
    to once per hour per game per sender.
  - **Matchmaking** (interview): an **in-memory** FIFO pool keyed by **variant**
    only (variant fixes the board language), pairing two humans (seat order
    randomised). The 10 s wait and **robot substitution are deferred to Stage 5**.
    The pool does **not** consult blocks (auto-match is anonymous) — a deliberate
    simplification of the plan's optional block-skip that also avoids a DB call
    under the pool lock.
  - **Email confirm-code** (interview): 6-digit code, 15-min TTL, ≤ 5 attempts,
    stored as a **SHA-256 hash**; a `Mailer` seam with an SMTP relay
    (`BACKEND_SMTP_*`) and a default **log mailer**. It binds an email to the
    current account; an email already confirmed by another account → `ErrEmailTaken`
    (**merge is Stage 11**); email-as-login is Stage 6 and reuses this mechanism.
  - **Multi-player drop-out** (interview; discharges the Stage 3 deferral): the
    engine's `Resign` now drops a seat and the rest **play on** while ≥ 2 are
    active, finishing (last-survivor wins) when one remains; `winner` excludes all
    resigned seats. A per-game **`dropout_tiles`** setting (`remove` default |
    `return`) governs the leaver's rack, which is **never revealed** to the others.
    Timeout reuses `Resign`, so a multi-player timeout drops one seat and play
    continues; `game.commit`/`timeoutGame` were already keyed on `g.Over()`, so they
    only needed the setting threaded through create/replay.
  - **Build/deps**: `go mod tidy` is not run — the bare-path `scrabble-solver`
    replace lives only in `go.work`, so `tidy`/`go get` cannot resolve it; the
    `xurls` dependency was added with `go mod edit -require` + `go mod download`,
    its checksums recorded in the committed **`go.work.sum`**. No CI workflow change
    (both Go workflows already clone the solver sibling and export
    `BACKEND_DICT_DIR`).

- **Stage 5** (interview + implementation):
  - Scope, as in Stages 1–4: **domain layer, no HTTP** — the robot consumes the
    public game API as an ordinary seated player (`internal/robot`), so only
    `internal/engine` still imports the solver. New: `engine.Candidates()` (decoded
    ranked plays) and a thin `game.Service.Candidates` + `RobotTurns` read.
  - **Account model** (interview): a pool of **durable accounts**, each a single
    `identities` row `kind='robot'` (migration `00004` widens the kind CHECK — a
    CHECK-only change, no jetgen). A curated ~16-name pool in code; `EnsurePool`
    provisions them idempotently at boot (a hard dependency, like the registry) with
    `block_chat`/`block_friend_requests` set, which is **all** the friend/DM blocking
    needs (no special-casing).
  - **Driver + state** (interview): a background sweeper goroutine
    (`robot.Service.Run`/`Drive`, mirroring the timeout sweeper); **every per-game
    and per-turn choice is derived deterministically from the game `seed`** (FNV-1a
    mix, restart-stable — not `hash/maphash`), so the robot keeps **no extra state**.
    `playToWin = mix(seed,"win")%100 < 40`; per-turn `delay`; sleep `drift`.
  - **Timing** (interview): per-move delay `2 + 88·u^k` minutes, `u~U(0,1)`,
    **k≈3.5 → median ~10 min**, clamped to [2,90]. A daytime nudge on the robot's
    turn pulls the move into a 2–10 min reply window; the robot proactively nudges
    after **12 h** idle on the human's turn (reusing `social.Nudge`'s once-per-hour
    guard; `social.LastNudgeAt` added to detect the human's nudge).
  - **Sleep** (interview — resolves the §7-vs-`account.go` mismatch): the robot
    sleeps 00:00–07:00 in the **opponent's timezone shifted by a per-game drift ∈
    [−3,+3]h** (so its night overlaps the human's rather than running anti-phase),
    computed on the fly per game — **no profile mutation, no concurrency cap**. The
    `account.go` away-window comment was corrected accordingly.
  - **Margin** (interview): pick the candidate whose resulting margin (own+move−opp)
    is closest to **[1,30]** when playing to win / **[−30,−1]** when playing to lose,
    tie-broken toward the conservative edge; no legal play → exchange the full rack
    when the bag can refill it, else pass. *(Later refined — separate PR: on ≈20% of
    opening/midgame moves the robot flips its intent for that one move, the chance
    tapering to 0 over the last 14 bag tiles and 0 at an empty bag, so the endgame
    stays strict; deterministic from the seed.)*
  - **Substitution** (interview): a matchmaker **reaper** (`Reap`/`RunReaper`)
    substitutes a pooled robot after a **10 s** wait (`BACKEND_LOBBY_ROBOT_WAIT`),
    `NewMatchmaker` now takes a `RobotProvider`. A waiter learns of a match — human
    pairing **or** substitution — through a new `Poll` + results map; production
    delivery is a **match-found notification** (session/in-app push + side-service),
    Stage 6/8 — noted in §10.
  - **Metrics** (interview, 1+2): robots are durable accounts, so `account_stats`
    is the authoritative, complete balance ground-truth (target ~40% robot wins);
    an OTel counter (`robot_games_finished_total`, exporter `none` today) and a
    structured log cover robot-finished games for live observation.
  - **Config**: `BACKEND_ROBOT_DRIVE_INTERVAL` (30 s), `BACKEND_LOBBY_ROBOT_WAIT`
    (10 s), `BACKEND_LOBBY_REAPER_INTERVAL` (1 s). No CI change (both Go workflows
    already clone the solver sibling and export `BACKEND_DICT_DIR`).

- **Stage 6** (interview + implementation):
  - **Scope = framework + vertical slice** (interview): the *whole* edge mechanism
    is built and the backend's REST surface + the live-event seam are opened for
    the first time, but only a representative slice of operations is wired
    end-to-end — auth (`auth.telegram`/`auth.guest`/`auth.email.request`/
    `auth.email.login`), `profile.get`, `game.submit_play`/`game.state`,
    `lobby.enqueue`/`lobby.poll`, `chat.post`, all five push events, and the admin
    passthrough. The remaining domain operations reuse the identical transcode
    pattern and are wired as the UI needs them: the play-loop ops (pass/exchange/
    resign, hint, evaluate, word-check/complaint, history, my-games list, chat
    list/nudge) in **Stage 7**; the social/account ops (friends, blocks,
    invitations, profile editing, stats, GCG export) in **Stage 8**.
  - **Wire contracts in a new shared `scrabble/pkg` module** (interview): the
    backend push proto (`pkg/proto/push/v1`) and the FlatBuffers edge payloads
    (`pkg/fbs`, one `scrabblefb` namespace) live here with **committed** generated
    Go, imported by both backend and gateway. The Connect envelope proto lives in
    `gateway/proto/edge/v1`. Codegen is dev-time (`buf generate` with **local**
    plugins + `flatc`, driven by per-module `Makefile`s, mirroring `cmd/jetgen`);
    CI only builds the committed output. `pkg` and `gateway` are bare-path modules
    like `scrabble-solver`, so `go.work` carries `use ./pkg`, `use ./gateway` and a
    `replace scrabble/pkg v0.0.0 => ./pkg` (the no-dot path is not VCS-fetchable);
    deps were added with `go mod edit` + `go work sync` (the established no-tidy
    pattern). `flatc` is pinned to **23.5.26** to match the `flatbuffers` Go runtime.
  - **Guest = durable account + `is_guest`** (interview): migration `00005` adds
    `accounts.is_guest`; a guest is a durable row with **no identity** (so the
    `sessions`/`game_players` foreign keys hold) that is **excluded from statistics**
    (the finish-time recompute skips guest seats) and from friends/history. The
    earlier "guests never reach this table" comments and §3/§9 were softened to
    "no profile/friends/stats persisted". Guest-row GC is a logged TODO (TODO-3).
  - **Push = in-process `Publisher` + backend gRPC listener** (interview): a new
    `internal/notify` hub (a `Publisher` interface defaulting to `Nop`, installed on
    `game`/`social`/`lobby` via `SetNotifier` during boot — additive, existing tests
    unchanged) is drained by a new backend gRPC server (`internal/pushgrpc`,
    `BACKEND_GRPC_ADDR` default `:9090`) serving `Push.Subscribe`. Emission lives in
    `game.commit` (so robot-driver and timeout-sweep moves emit `your_turn`/
    `opponent_moved` too — the background sources a handler-only design would miss),
    `social` (`chat_message`/`nudge`) and the matchmaker (`match_found`). Event
    payloads are FlatBuffers-encoded **in the backend** (it imports `pkg/fbs`); the
    gateway forwards them verbatim. Revoke/session-invalidation and cursor-resume are
    **deferred** (single-instance MVP).
  - **Edge envelope = minimal, token in header** (interview): the `Gateway` Connect
    service is `Execute(message_type, payload, request_id)` + `Subscribe`; the
    session token rides in `Authorization: Bearer`; auth ops are unauthenticated and
    return the token in the FlatBuffers `Session`. Domain outcomes ride back in the
    `ExecuteResponse.result_code` (HTTP 200); only edge failures (rate limit, missing
    session, unknown type, internal) are Connect error codes. No Ed25519/signing
    (the galaxy donor's crypto stack was dropped, per §3).
  - **Admin = gateway validates Basic-Auth** (interview): the gateway checks
    `GATEWAY_ADMIN_USER`/`_PASSWORD` and reverse-proxies to backend
    `/api/v1/admin/*`; the backend admin surface is a single `ping` until Stage 10.
  - **Rate-limit = 2 dimensions, 3 classes** (interview): public per-IP (30/min,
    burst 10), authenticated per-user (120/min, burst 40), admin per-IP (60/min,
    burst 20), plus an email-code per-IP sub-limit (5/10 min); token bucket
    (`golang.org/x/time/rate`) with a lazy stale-bucket sweep.
  - **Email-as-login** (discharges the Stage 4 deferral): `account.EmailService`
    gained `RequestLoginCode`/`LoginWithCode`, reusing the confirm-code mechanism but
    provisioning-or-finding the account by email identity (it does **not** refuse an
    already-confirmed address — that is the returning user).
  - **CI**: both Go workflows gained `gateway/**` (and `pkg/**` where backend depends
    on it) path filters and now build/vet/test `./backend/... ./pkg/... ./gateway/...`
    (unit) — integration stays `./backend/...` (the only module with tagged tests).
    The solver clone + `BACKEND_DICT_DIR` steps are unchanged.

- **Stage 7** (interview + implementation):
  - **Scope = playable slice** (interview): the *whole* UI shell plus the core play
    loop end-to-end; the social/account/history surfaces were split out into a new
    **Stage 8** and the later stages shifted +1 (Telegram→9, Admin→10, Linking→11,
    Polish→12). Stage 7 wires only the operations the slice needs (the Stage 6
    "as the UI needs them" pattern): the new gateway/transcode + backend-REST ops
    `games.list`, `game.{pass,exchange,resign,hint,evaluate,check_word,complaint,
    history}`, `chat.{list,nudge}`. The only new domain code is `game.ListForAccount`
    (the "my games" query) and seat **`display_name`** resolution (server DTO layer);
    `SeatView` gained a trailing `display_name`. Friends/blocks/invitations,
    profile-editing, stats and the history/GCG viewer are Stage 8.
  - **Stack** (interview): plain **Svelte 5 (runes) + TypeScript + Vite**, no
    SvelteKit; `@connectrpc/connect-web` + the `flatbuffers` runtime, with the edge
    TS bindings generated from the **same** `edge.proto` (`protoc-gen-es`) and
    `scrabble.fbs` (`flatc --ts`) and **committed** under `ui/src/gen/` (dev-time
    codegen, like `cmd/jetgen` / `pkg/Makefile`; CI builds the committed output).
  - **No board on the wire** (discovered): `StateView` carries no grid, so the client
    **replays the decoded move journal** (`game.history`, newly wired) onto an empty
    board; premium squares + tile values are a client-side map **ported from
    `scrabble-solver/rules/rules.go`** with a Vitest parity test.
  - **Board UX** (interview): full-width, borderless; tiles placed by **Pointer-Events
    drag or tap** (no HTML5 DnD — it has no touch support); a contextual **MakeMove**
    control (short tap → make/reset popup, ~1 s press-and-hold → commit); per-tile
    recall by tapping a pending tile; a **two-state zoom** (15↔9 cells) on touch only
    (auto-zoom-in on placement, double-tap / pinch manual); a blank-letter chooser.
    All board/tiles/effects are **pure HTML5/CSS + Unicode** — no image/font/SVG asset.
  - **Theming** (interview): own **CSS custom-property tokens**, light/dark via
    `prefers-color-scheme`, **Telegram-themeParams-ready** (a runtime hook can override
    the tokens; the SDK is wired in the Telegram stage). **Navigation** (interview):
    dependency-free **hash router**; session token in memory + **IndexedDB**, re-resolved
    on reload (reopen Subscribe, refetch the open game); stream reconnect on focus.
    **i18n** en/ru is a hand-rolled typed catalog (compile-time key parity + a test).
  - **Mock transport** (owner request): a build-flagged in-memory fake (`VITE_MOCK`,
    `pnpm start`) drives lobby → active game → board with no backend, tree-shaken out
    of production; it is the same fixture the Playwright smoke uses.
  - **Tests/CI** (interview): **Vitest** units (board replay, placement machine,
    premium parity, i18n parity, FlatBuffers codec) + a **Playwright** smoke against
    the mock; a new **`ui-test.yaml`** workflow (type-check, unit, build with a
    **bundle-size budget** — prod is ~67 KB gzip JS — and a chromium e2e). The Go
    workflows already cover the new backend/gateway/pkg code; a `game.ListForAccount`
    integration test and gateway transcode tests for the new ops were added.
  - **UX polish** (follow-up PR): a mobile-app **app shell** (growing nav bar, content
    pinned to the bottom) + a one-line **announcement banner** (client-side mock
    rotation now; server-driven channel later — §10); a mobile-OS **tab bar** and a
    reusable **HoldConfirm** press-and-hold control (MakeMove 🏁 + game-action confirms);
    board **zoom reworked** to a width-based zoom in a fixed viewport (real native
    scroll, double-tap; pinch/swipe dropped) with constant `cqw` labels, corner-letter
    tiles, last-word dark-tile highlight, and a Settings
    **bonus-label style** (beginner/
    classic/none); **hint lays its tiles on the board** (no spend when no move — a new
    `no_hint_available` result code); the history opens as an in-place **slide-down**
    (not a modal); word-check is alphabet/length-limited, cached and throttled. Design
    details live in the new [`docs/UI_DESIGN.md`](docs/UI_DESIGN.md).

- **Stage 8** (interview + implementation):
  - **Scope = vertical slice continued**: the social/account/history operations were
    opened end-to-end (UI → gateway transcode → backend REST → existing domain
    services). The only new backend logic is `lobby.ListInvitations`,
    `account.Store.GetStats`, a `game.SharedGame` seam (self-join on `game_players`),
    the friend-code mechanism, and the friendships `declined`-status change.
  - **Friends — two add paths** (interview, a deliberate plan change): **one-time
    friend codes** (the player to be added issues a **6-digit numeric** code, 12 h TTL,
    SHA-256-hashed like email codes, single active per issuer, single-use, redeem
    rate-limited) and a **play-gated request** (`SendFriendRequest` now requires a
    shared game — active or finished). An explicit **decline is permanent** (blocks
    re-send), an **ignored request lazily expires after 30 days** and may be re-sent,
    and a **code from the same person bypasses a prior decline**. This **supersedes
    Stage 4's** "declining/cancelling deletes the row" (cancel by the requester still
    deletes; decline now sets `status='declined'`). Migration **00006** widens
    `friendships_status_chk` and adds **`friend_codes`** (jetgen regen). No public ID
    or name search — discovery is codes + befriend-an-opponent.
  - **Badges = poll + push** (interview): a new generic **`notify`** push event
    (`notify.KindNotification`, sub-kinds friend_request/friend_added/invitation/
    game_started) drives the lobby hamburger + "Friends" badge; emitted on friend-
    request and invitation create and on the invitation's game start. The client polls
    incoming requests + open invitations on lobby open and on focus (a missed push
    while hidden), and re-polls on the `notify` event. Cursor-resume stays deferred
    (single-instance MVP, §10).
  - **Language single-control** (interview): the Settings language control writes
    through to the durable account's `preferred_language` (`profile.update`); guests
    keep only the client preference. Seeding the language from the platform/client on
    first provider login is a **Stage 9** forward-note.
  - **Guests = durable-only** (interview): friends/blocks/invitations/statistics and
    history management are durable-account-only; a guest sees a sign-in prompt.
    Binding an email to an existing guest (account linking) stays **Stage 11**.
  - **GCG = finished-only + share** (interview): `game.ExportGCG` refuses an active
    game (`game.ErrGameActive`) to avoid leaking the live journal mid-play; the client
    exports via the **Web Share API** where available, else a **Blob download**
    (`game-<id>.gcg`). Capacitor-native file save lands with the native wrapper.
  - **IA = as the mockup** (interview): Friends (friends + blocks) is its own screen
    from the lobby menu; Invitations is a lobby section + a "play with friends" mode in
    New game; Stats is a lobby tab-bar button; profile editing is on Profile; history +
    GCG stay in the game.
  - **Wire/codegen**: new fbs tables (friends/blocks/invitations/profile-update/email-
    bind/stats/gcg + `NotificationEvent`; `Profile` gained trailing away fields) in
    `pkg/fbs`, regenerated to committed Go + TS; ~21 new gateway transcode ops; new
    REST handlers under `/api/v1/user/{friends,blocks,invitations,profile,email,stats}`
    and `…/games/:id/gcg`. UI grows to ~82 KB gzip JS (budget 100 KB). No CI workflow
    change (the Go and UI workflows already cover the new code).
  - **UI polish (owner review follow-up)**: a copyable friend code (📋 + toast); the
    lobby notification badge fixed (it had inherited the hamburger-bar style) and made
    a proper count dot; Safari flex inputs given `min-width:0`; **profile-edit
    validation on both UI and backend** — display-name format (letters + single
    `␠`/`.`/`_`, ≤ 32 runes), a **UTC-offset** timezone picker (`account.ResolveZone`
    parses `±HH:MM` or IANA; DST is traded for the simple picker), a 10-minute away grid
    capped at **12 h** (wrap-aware), email format — with Save disabled and invalid
    fields red-bordered while any field is invalid; language stays in Settings; in a
    game, an "add to friends" item flips to a disabled "request sent"; chat send/nudge
    became ⬆️/🛎️ icons; a **finished game** drops its last-word highlight, hides Check
    word / Drop game, disables zoom, and draws an **inert footer** (greyed rack + tab
    bar) instead of hiding it. Two **iPhone-simulator** passes then made the chat and
    modals keyboard-aware (`dvh` plus a `visualViewport` listener that sizes the modal
    backdrop to the area above the keyboard), reserved the rack height so a finished
    footer does not collapse, and compacted the play-with-friends form (a searchable
    bounded-scroll friend list, a pinned invite, and an explicit, **required game
    type** — a smart default is TODO-6). On the owner's call, **every profile / new-game
    picker is a native `<select>`** (the away window as hour + 10-minute selects, the
    timezone as a UTC-offset select): native time/wheel inputs render differently per
    OS and can't be forced to match, and a select also avoids the iOS "clear" button
    that would empty a time field.

- **Stage 9** (interview + implementation):
  - **Connector as its own container** (interview): the Telegram side-service is a
    standalone module `platform/telegram` (binary `cmd/telegram`) holding the bot
    token **only there**; gateway and backend reach it by **unauthenticated gRPC**
    on the trusted internal network, and it egresses to `api.telegram.org` through a
    **VPN sidecar** (`deploy/docker-compose.yml`, mirroring `../15-puzzle`). Bot
    library **`github.com/go-telegram/bot`** (one new dep), **long-poll** updates.
  - **initData validation moved off the gateway** (interview): the gateway's HMAC
    validator was **relocated** into the connector (`internal/initdata`, now also
    returning `language_code`); the gateway calls `connector.ValidateInitData` over
    gRPC during `auth.telegram`. The hop is negligible (loopback gRPC, once per
    login). `GATEWAY_TELEGRAM_BOT_TOKEN` is gone; `GATEWAY_CONNECTOR_ADDR` replaces
    it. The `gateway/internal/auth` package was deleted.
  - **Connector gRPC API** (`pkg/proto/telegram/v1`, service `Telegram`): the
    generic methods are **platform-agnostic**, keyed by the identity `external_id`
    (so a future VK/MAX connector reuses them); only `ValidateInitData` is
    Telegram-specific. Methods: `ValidateInitData`, **`Notify`** (the out-of-app push
    — renders a localized message + a Mini App deep-link button from the FlatBuffers
    payload), `SendToUser` and `SendToGameChannel` (arbitrary admin messages — built
    and unit-tested now, **wired to the admin surface in Stage 10**; the game channel
    id lives only in connector config).
  - **Push = fallback, gateway-routed, de-dup by presence** (interview): the gateway
    already consumes the firehose and knows in-app presence (`push.Hub.HasSubscribers`),
    so it decides in-app vs out-of-app **atomically**: for a recipient with **no live
    in-app stream** it fetches a new backend `/internal/push-target`
    (`{external_id, language, notifications_in_app_only}`) and calls `connector.Notify`
    only when they have a Telegram identity and have **not** set the new flag. Push
    set: `your_turn`, `nudge`, `match_found`, and the `notify` sub-kinds `invitation`/
    `friend_request` (the connector skips the rest). Delivery runs in a goroutine so a
    slow connector never stalls the firehose; best-effort (no cursor resume — single
    instance, §10).
  - **Profile flag `notifications_in_app_only`** (interview, **default true** → push
    is **opt-in**): migration `00007` (+ jetgen), threaded through
    `account.Profile`/`UpdateProfile`, the REST DTOs, the fbs `Profile`/
    `UpdateProfileRequest` (default `true` in the schema so an unset field reads
    conservatively), and a Profile-screen toggle. Flagged at review: the channel is
    silent until a user turns it off.
  - **Language seeding from the platform** (discharges the Stage 8 forward-note):
    `account.ProvisionTelegram` seeds a **brand-new** account's `preferred_language`
    from the Telegram `language_code` and its display name from `first_name`/
    `username` (existing accounts untouched); the UI's `adoptSession` already adopts
    the server language when the user has not locked a locale, so no extra UI seeding
    was needed. The gateway forwards the fields from `ValidateInitData`.
  - **Mini App = `/telegram/` + guard** (interview): the gateway serves the one SPA
    build under `/telegram/` (Vite **relative base**; the hash router is
    path-agnostic). The UI detects a Telegram launch by `Telegram.WebApp.initData`,
    applies `themeParams`, authenticates via the existing `auth.telegram` op (UI
    `authTelegram` codec/client/transport/mock added), and routes the deep-link
    `start_param` (`g`/`i`/`f` → game / lobby-invitation / friend-code redeem). On the
    `/telegram/` path **without** initData it redirects to the site root. The official
    `telegram-web-app.js` loads from `index.html` (harmless outside Telegram).
  - **Deep-link scheme** (shared Go `platform/telegram/internal/deeplink` ↔ TS
    `ui/src/lib/deeplink.ts`): `g<game uuid>` / `i<invitation uuid>` / `f<6-digit
    code>` / empty = lobby. A friend-code **share-to-Telegram** link is shown when
    `VITE_TELEGRAM_LINK` is configured (**partially discharges TODO-5**; QR still
    open). The `Notify` button and the bot `/start` reply both wrap the payload as
    `<MiniAppURL>?startapp=<payload>`.
  - **Test environment** (interview nuance): the Bot API base is overridable for
    Telegram's test environment — `TELEGRAM_TEST_ENV=true` suffixes the token with
    `/test` so the client hits `/bot<token>/test/METHOD` (`TELEGRAM_API_BASE_URL`
    overrides the host for a mock/self-hosted server).
  - **Deploy groundwork** (interview): `platform/telegram/Dockerfile` (builds the
    connector standalone — drops backend/gateway and the solver replace from a copy
    of `go.work`, validated with `docker build`) + the connector-scoped compose with
    the VPN sidecar; a root `.dockerignore`. **No public ingress** for the connector
    (long-poll + sidecar egress); the host reverse proxy routes only to the gateway
    port, which serves the Mini App. The full multi-service deploy is **Stage 12**.
  - **Wire/codegen/CI**: new proto `pkg/proto/telegram/v1` (committed Go); fbs
    `Profile`/`UpdateProfileRequest` gained `notifications_in_app_only` (committed Go
    + TS). `go.work` gains `use ./platform/telegram`; deps via `go mod edit` +
    `go work sync` (no-tidy). `go-unit.yaml` gained the `platform/**` path filter and
    builds/vets/tests `./platform/telegram/...`. UI grows to ~86 KB gzip JS (budget
    100 KB). The connector's unit tests use an httptest fake Bot API; a Playwright
    smoke drives the Mini App launch + guard with an injected `window.Telegram`.
  - **Stage 10 forward-note**: the admin surface will wire `connector.SendToUser`/
    `SendToGameChannel` (backend gains its own connector client) for operator
    broadcasts to a user and the game channel.
  - **Verification-time fixes** (caught by the CI gate): (1) the gateway transcode
    dropped `notifications_in_app_only` in four places (`ProfileResp`, `encodeProfile`,
    `profileUpdateHandler`, the `UpdateProfile` body) so the toggle never reached the
    backend — fixed, with a round-trip transcode test added. (2) The e2e suite was made
    **hermetic** (a shared `ui/e2e/fixtures.ts` blocks the real `telegram-web-app.js`):
    the render-blocking CDN `<script>` hung every page load on the CI runner, where
    telegram.org is unreachable, timing out all non-Telegram specs. (3) A pre-existing
    time-of-day flake in `TestTimeoutSweep` (the default 00:00–07:00 away window made
    the sweeper skip when CI ran with `now-1h` inside it) was made deterministic by
    clearing the test account's away window.

- **Stage 10** (interview + implementation):
  - **Admin console = backend-rendered `/_gm`, gateway Basic-Auth** (interview, two
    rounds): the owner chose a dedicated web console but, pointing at `../galaxy-game`
    and asking to keep it simple, the deliverable is **server-rendered Go
    `html/template` + one embedded CSS** (`backend/internal/adminconsole`: a
    framework-agnostic renderer + page view-models, `//go:embed` templates/assets, zero
    JS, no build step), **not** a SPA. It lives **in the backend** on its own route
    `/_gm/*`; the **gateway** (the project's built-in reverse proxy) gates `/_gm/*` with
    the existing `GATEWAY_ADMIN_USER/PASSWORD` Basic-Auth on its **public** listener and
    proxies **verbatim** to backend `/_gm/*` (mounted on the edge mux below the h2c wrap
    so Connect keeps working). This **supersedes Stage 6's** gateway-fronts-
    `/api/v1/admin` model: the separate admin port `GATEWAY_ADMIN_ADDR` is dropped (only
    the port — user/password stay), the backend `/api/v1/admin` group + `ping` are
    removed, and `gateway/internal/admin` is repurposed to the verbatim proxy. The
    backend keeps **no operator identity** and no `admin_accounts` table; CSRF on the
    console's POSTs is a **same-origin** check (`Origin`/`Referer` vs `Host`, the gateway
    preserves the inbound Host) — no token. Discharges Stage 1's "admin bootstrap" (it is
    config, not a DB seed).
  - **Complaint resolution + dictionary pipeline** (interview): migration **00008**
    (+ jetgen) adds `disposition`/`resolution_note`/`resolved_at`/`applied_in_version`
    to `complaints` and the deferred `status` CHECK (`open|resolved`) — **discharges
    Stage 3's** deferral (no `resolved_by`: operator identity is not tracked). Resolution
    sets a disposition (`reject`/`accept_add`/`accept_remove`); accepted complaints are
    **derived by query** into a pending dictionary-change list (no new table), stamped
    `applied_in_version` once a rebuilt version is loaded. New `game` reads
    `ListComplaints`/`GetComplaint`/`CountComplaints`/`ResolveComplaint`/
    `DictionaryChanges`/`MarkChangesApplied`; admin list/count reads
    `account.ListAccounts/CountAccounts/Identities` and `game.ListGames/CountGames/
    GameByID`.
  - **Dictionary hot-reload = per-version subdir** (interview): the launch version stays
    in the flat `BACKEND_DICT_DIR` (CI/dev untouched); a reloaded version `X` loads from
    `BACKEND_DICT_DIR/X/` via the new `Registry.LoadAvailable` (present variants only),
    and boot re-loads every subdirectory via `engine.OpenWithVersions` so reloaded
    versions survive a restart. **Partially addresses TODO-2** (the runtime reload
    contract; the offline DAWG generator stays future work).
  - **Operator broadcasts** (discharges Stage 9's forward-note): the backend gains its
    own connector gRPC client (`backend/internal/connector`, `BACKEND_CONNECTOR_ADDR`,
    nil when unset) over the existing `pkg/proto/telegram/v1`; the console messages a
    user by `account_id` (backend resolves the Telegram `external_id`) and posts to the
    game channel via `SendToUser`/`SendToGameChannel`.
  - **Config/CI**: backend adds `BACKEND_CONNECTOR_ADDR`; gateway drops
    `GATEWAY_ADMIN_ADDR` (keeps user/password). No new module and no fbs/proto/UI codegen
    (the console is server-rendered Go). The Go workflows already span
    `./backend/... ./gateway/... ./pkg/...`; integration stays `./backend/...`.

- **Stage 11** (interview + implementation):
  - **Scope = link-via-confirm + merge for email and Telegram** (interview): the
    current account is the merge **primary**; a linked identity that already has its
    own account is merged into the current one and the secondary is retired as an
    **audit tombstone** (`accounts.merged_into`/`merged_at`, migration `00009`
    + jetgen). Linkable this stage: **email** (the existing confirm-code) and
    **Telegram via the Login Widget** (the web sign-in). New `internal/accountmerge`
    (the single-transaction data merge) and `internal/link` (the orchestrator over
    account + accountmerge + session).
  - **Tombstone, not delete** (interview): the secondary row is kept so a **shared
    finished game**'s no-cascade `game_players`/`chat`/`complaints` foreign keys stay
    valid; its seat in such a game is left in place. The merge is **refused**
    (`ErrActiveGameConflict`) only when the two share an **active** game.
  - **Merge algorithm** (one tx): stats summed (wins/losses/draws) + max kept;
    `hint_balance` summed; identities repointed; non-shared `game_players` transferred
    (shared kept); `chat_messages`/`complaints` reassigned; friendships/blocks repointed
    with self-edge drop and dedupe (friendships by status precedence
    accepted>pending>declined); invitations: secondary's as inviter deleted, invitee
    rows deduped; secondary's `email_confirmations`/`friend_codes` dropped; secondary
    tombstoned. Sessions are handled one layer up: `session.Service.RevokeAllForAccount`
    (+ `Cache.RemoveByAccount`) retires the secondary's sessions after the tx.
  - **Primary direction + guest inversion** (interview): primary = the current account,
    **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
    transfer into it, the guest is retired, and a **fresh session for the durable
    account is minted and returned** (the client adopts it). Binding a **free** identity
    to a guest is a plain upgrade (clear `is_guest`, same session). Discharges Stage 8's
    "guest email-binding is Stage 11".
  - **API/UX = dedicated ops; reveal only after the code** (interview): new edge ops
    `link.email.request/confirm/merge` (Email-rate-limited) and
    `link.telegram.confirm/merge`. `request` **always** mails a code (no pre-send
    "taken" signal, so a probe cannot enumerate registered addresses); a required merge
    is revealed **only after** the code is verified, gating an explicit irreversible
    merge step (the Profile screen's confirmation dialog). This **supersedes Stage 8's**
    `email.bind.*` ops (and their fbs `EmailBindRequest`/`EmailConfirmRequest` tables),
    which were retired from the gateway/UI for that reason; the backend
    `EmailService.RequestCode`/`ConfirmCode` primitives stay (still covered by inttest).
  - **Field policy** (interview): `display_name` = primary's; profile prefs/flags
    (language, timezone, away window, block toggles, `notifications_in_app_only`) =
    primary's; `hint_balance` = **sum**. A new service column **`paid_account`**
    (`bool`, default false; lifetime one-time-payment marker, no purchase flow yet) is
    added in `00009` and **ORed** on merge (`true` always wins). It is not user-editable
    and is shown read-only on the admin account-detail page.
  - **Telegram Login Widget** (interview, owner chose the broader scope): the connector
    validates it (`internal/loginwidget`, secret = `SHA-256(bot_token)`, distinct from
    initData) via a new `Telegram.ValidateLoginWidget` RPC; the gateway validates the
    widget payload and passes the **trusted** `external_id` to the backend link route
    (same trust model as `auth.telegram`). The UI offers "Link Telegram" only in a plain
    web context (`loginWidgetAvailable`), driving the popup `Telegram.Login.auth`; it is
    **inert in production until BotFather `/setdomain`** registers the site domain and
    `VITE_TELEGRAM_BOT_ID` is configured (a deploy concern, Stage 12). e2e mocks the
    widget (telegram.org is blocked on CI).
  - **Wire/CI**: new fbs `LinkEmailRequest`/`LinkEmailConfirm`/`LinkTelegramRequest`/
    `LinkResult` (committed Go + TS); new proto RPC (committed Go); new REST routes under
    `/api/v1/user/link/*`. The Go workflows already span `./backend/... ./gateway/...
    ./pkg/... ./platform/telegram/...`; integration stays `./backend/...`. UI ~90 KB gzip
    JS (budget 100 KB). New error code `merge_active_game_conflict`.

- **Stage 12** (interview + implementation):
  - **Re-scoped & split** (interview): the original "Polish (observability + perf +
    deploy)" was too large for one session, so it was split — **Stage 12** = observability
    + performance + guest GC; **Stage 13** = alphabet-on-the-wire (TODO-4); **Stage 14** =
    CI & deploy (TODO-1, TODO-2, the collector + dashboards). The latter two were written
    into the plan now as the agreed baseline (each still re-interviews at its own start).
    (Stage 14 was itself later re-scoped to the solver/dictionary split alone; deploy +
    observability + the dual-bot idea split into Stages 15–18.)
  - **Shared telemetry** (interview): a new `pkg/telemetry` owns the OTel provider
    bootstrap (exporter selection, W3C propagators, shutdown, Go runtime metrics); the
    backend `internal/telemetry` is now a thin facade over it (keeping its gin middleware),
    and the gateway and connector gained telemetry runtimes. A configurable **`otlp`**
    exporter was added alongside `none`/`stdout`; the **default stays `none`**, the OTLP
    endpoint comes from the standard `OTEL_EXPORTER_OTLP_*` env, and the collector +
    dashboards are Stage 15 (so CI needs none). `otelgrpc` instruments the backend push
    server, the gateway's backend + connector clients, and the connector's gRPC server.
    New config `GATEWAY_SERVICE_NAME`/`GATEWAY_OTEL_*` and `TELEGRAM_SERVICE_NAME`/
    `TELEGRAM_OTEL_*`; the backend's existing `BACKEND_OTEL_*` gained the `otlp` value.
  - **Metrics = operational, business-near** (interview): histograms
    `game_replay_duration` and `game_move_validate_duration`; counters
    `games_started_total`, `games_abandoned_total` (a turn-timeout seat drop) and
    `chat_messages_total` (`kind`=message/nudge); an observable gauge `game_cache_active`;
    the gateway `edge_request_duration` (`message_type`/`result`); plus Go runtime/heap
    metrics. Game-scoped metrics carry a **`variant`** attribute
    (english/russian_scrabble/erudit — chosen over a coarser `language`, which it
    subsumes); the gateway edge metric is variant-agnostic. Optional wiring uses the
    established `SetMetrics`/`SetNotifier` setter pattern (default no-op meter), so existing
    constructors and tests are untouched. **No speculative optimisation** — there is no
    measured hotspot; the deliverable is the instrumentation (the standing "performance only
    with evidence" rule). pprof was not added (reframed away by the owner).
  - **Guest GC** (interview, TODO-3): age-based, no-seat-only — see the discharged TODO-3
    below; new config `BACKEND_GUEST_REAP_INTERVAL`/`BACKEND_GUEST_RETENTION`.
  - **Deps/CI**: new OTel modules (the OTLP exporters,
    `contrib/instrumentation/runtime`, `otelgrpc`) added with the no-tidy pattern
    (`go mod edit` + `go mod download` + `go work sync`; `pkg` carries no bare-path dep, so
    it tidies cleanly). No workflow change — the Go workflows already span
    `./backend/... ./gateway/... ./pkg/... ./platform/telegram/...`, integration stays
    `./backend/...`, and the default `none` exporter keeps CI collector-free.

- **Stage 13** (interview + implementation, discharges TODO-4):
  - **Scope = live play only** (interview): indices ride the wire for `StateView.rack`
    (out) and `SubmitPlay`/`Evaluate`/`Exchange`/`CheckWord` (in). The **board path is
    untouched** — `MoveRecord` (history, move results, hint), formed `words`,
    `ComplaintRequest.word` (durable, admin-reviewed) and `WordCheckResult.word` (echo) stay
    decoded concrete characters, so the durable journal / GCG and the §9.1 invariant are
    unchanged. **Hard cutover**, no dual letter/index fields (single client; the fbs Go + TS
    regenerate in one PR; no external wire consumer). Exchange moved fully to indices, a
    blank = the shared sentinel index **255** (`engine.BlankIndex`).
  - **Edge-mapping layering** (engineering): the engine gained a cached per-variant codec —
    `AlphabetTable` (the `(index, letter, value)` table from the solver ruleset),
    `LetterForIndex`, `EncodeRack`, `DecodeTiles`, `DecodeWord` — and the backend **server
    edge** owns the index↔letter mapping. `game.Service`'s domain methods, `engine.Game` and
    the **robot** keep a single **letter-based** play path (untouched); a new thin
    `game.Service.GameVariant` (a single-column `SELECT variant`, cheaper than `GetGame`)
    lets the inbound handlers resolve the variant without doubling the play-path read. The
    **gateway carries no alphabet table** — it passes indices through verbatim; `check_word`
    rides as repeated `?idx=` query params.
  - **`include_alphabet` flag** (interview): `StateRequest.include_alphabet` gates the table
    so it is not resent on every poll; the client sets it only on a **per-variant cache
    miss** (first open of a variant), and the table then arrives with the index rack so the
    rack is always decodable. The client caches the table in memory by variant
    (`ui/src/lib/alphabet.ts`).
  - **Letter case** (discovered): the solver emits **lower-case** letters and the rest of
    the UI works in **upper case**. The wire and the journal stay lower case; the **UI
    normalises display to upper case** (the codec upper-cases decoded board tiles and words,
    and the alphabet cache upper-cases on ingest), so `placement.ts` / `board.ts` /
    `checkword.ts` are unchanged and the latent real-backend lower-case display is fixed.
  - **Parity rework** (interview): the real value/alphabet parity moved to a **Go engine
    test** (`engine.AlphabetTable`: EN/RU/Эрудит sizes, EN a=1/q=10, **Эрудит ё=index 6,
    value 0**); `ui/src/lib/premiums.ts` is now **geometry only** (its value tables,
    `tileValue` and `alphabet` were removed, its parity test trimmed to the premium grid);
    the codec test round-trips the index tiles + the alphabet table; the **mock keeps a
    fixture table** (relocated from `premiums.ts`) seeded into the client cache, so the
    mock-driven UI is alphabet-agnostic too.
  - **Wire/codegen/CI**: new fbs `AlphabetEntry` + `PlayTile`; `StateView.rack`→`[ubyte]` +
    `alphabet`; `StateRequest.include_alphabet`; `SubmitPlay`/`Eval` tiles→`[PlayTile]`;
    `Exchange` tiles→`[ubyte]`; `CheckWord.word`→`[ubyte]` (committed Go + TS regenerated).
    UI ~90 KB gzip JS (budget 100 KB). **No CI workflow change** — the Go workflows already
    span `./backend/... ./gateway/... ./pkg/...` and the UI workflow runs check/unit/build +
    a chromium/webkit e2e. `docs/FUNCTIONAL.md` is **untouched** (no user-visible behaviour
    change — the UI looks and plays the same; like Stage 2). The index-drift caveat is
    handled by construction (the running backend produces the table, so client↔server cannot
    drift); the DAWG/solver build-time agreement remains **Stage 14 / TODO-2**.

- **Stage 14** (interview + implementation, re-scoped + discharges TODO-1/TODO-2):
  - **Re-scoped to the split** (interview): the original "CI & deploy" was several sessions of work,
    so it was cut to the **solver/dictionary split** (the dependency foundation) and the deploy +
    observability + the dual-bot idea were written into the plan as new **Stages 15–18**. The deploy
    decisions taken at the interview are recorded there (embed the UI in the gateway via `go:embed`;
    full Collector+Prometheus+Tempo+Grafana stack; **two contours** — test = auto on feature-branch
    push on the local host, prod = manual SSH `docker save`/`load` after merge; `TEST_`/`PROD_` secret
    prefixes since Gitea 1.26 has no environments — verified).
  - **TODO-1 — publish solver** (interview: "опубликовать и запинить"): `scrabble-solver` renamed to
    module `gitea.iliadenisov.ru/developer/scrabble-solver`, `internal/{wordlist,dictdawg}`
    **de-internalised** to public packages (so the dict repo imports one builder — no drift), the build
    pipeline (`cmd/builddict`, `dictprep`, the `dictionaries` submodule) moved out, `internal/dict`
    repointed at the committed `dawg/*.dawg` fixtures, tagged **v1.0.0**. scrabble-game pins it in
    `backend/go.mod`, drops the `go.work` replace + the CI clone, and sets `GOPRIVATE=gitea.iliadenisov.ru/*`
    (go fetches the module directly from Gitea — verified end-to-end). The solver hash lives in
    `go.work.sum` (workspace mode; the bare-path `scrabble/pkg` replace still blocks `go mod tidy`).
  - **TODO-2 — dictionary repo** (interview: "полный TODO-2, новый репо"): `developer/scrabble-dictionary`
    builds the three DAWGs against the published solver + pinned `dafsa`/`alphabet` v1.1.0,
    **byte-identical** to the solver fixtures; published as the release artifact
    `scrabble-dawg-v1.0.0.tar.gz`; both Go workflows download it for `BACKEND_DICT_DIR` instead of
    cloning the solver. English source vendored from `kamilmielnik/scrabble-dictionaries`; the Эрудит
    fold is committed as `dictprep/russian/erudit.txt`, so the build needs no `python`.
  - **Bootstrap nuances** (encountered): the dict repo was created empty with a protected `master`, so
    it was seeded once via an owner-authorised protection lift→push→restore (a subsequent CI-fix push
    correctly went through a PR, not another lift); it was made **public** (like the solver) so the Go
    workflows fetch the artifact anonymously. Its CI is a **build-only** validation gate — the
    auto-release step's `${{ github.* }}` contexts failed the Gitea workflow compile, so releases are
    published manually for now (a logged follow-up).

- **Stage 15** (interview + implementation):
  - **Re-framed service-agnostic** (interview): the owner kept the two-bots-in-one-container model but
    generalised the language signal — the sign-in service returns a **set** of supported game languages
    (subset of `{en, ru}`, ≥ 1) on the validate response, and the **UI gates** the New Game variant choice
    by it. Two distinct scopes, deliberately not conflated: the **gating set** is per-session (rides the
    `Session` fbs, never persisted — so the same `telegram_id` logged in through the en- and ru-bot gates
    differently, which is correct), and the **routing language** is per-account.
  - **Push routing resolved** (interview, the original "which bot delivers" open detail): only the
    **user-facing `Notify`** carries the `en`/`ru` language from the user's **last `ValidateInitData`**,
    persisted as `accounts.service_language` (migration `00010`, written every login — new and existing —
    last-login-wins, read by `/internal/push-target` with a `preferred_language` fallback). It is NOT the
    game's variant language. **Correction mid-interview:** the admin broadcasts `SendToUser` /
    `SendToGameChannel` are admin-panel-only and unrelated to `ValidateInitData`; they pick the bot by an
    **operator-chosen** language (a console `<select>`), so a `language` field was added to those two RPCs
    sourced from the form, not from `service_language`.
  - **Gating = UI-only, creation-only** (interview): the backend does not enforce (a valid game is
    harmless, not a trust boundary); only the New Game pickers (auto-match + friend invite) filter — there
    is no variant picker on accept/open/play, so those are inherently ungated. Non-Telegram logins
    (web/email/guest) carry the gateway default set (`GATEWAY_DEFAULT_SUPPORTED_LANGUAGES`, default all).
  - **Wire/connector**: `ValidateInitDataResponse` gained `service_language` + `supported_languages`; the
    fbs `Session` gained `supported_languages:[string]`; `SendToUser`/`SendToGameChannel` gained
    `language` (committed Go + TS regenerated via `make -C pkg gen` + `pnpm -C ui codegen`). The connector
    config moved to **per-language** bots (`TELEGRAM_BOT_TOKEN_EN/_RU`, `TELEGRAM_GAME_CHANNEL_ID_EN/_RU`;
    `TELEGRAM_MINIAPP_URL` shared; ≥ 1 token required — a breaking config change, no prod yet); the
    server hosts a bot map and routes by language. The push template language now follows the routing bot
    (was `preferred_language`) — a documented change. The deploy compose/Dockerfile env was updated to the
    per-language vars (the full deploy stack is Stage 16). No CI workflow change (the Go and UI workflows
    already span the touched modules).

- **Stage 16** (interview + implementation):
  - **Branch model reshaped** (interview, supersedes the Stage 0 `feature/* → master`): a long-lived
    **`development`** integration branch + **`master`** as the prod trunk. Feature branches are cut from
    `development`; a feature-branch commit triggers nothing. A single consolidated
    `.gitea/workflows/ci.yaml` (Gitea has no cross-workflow `needs`) runs `unit`+`integration`+`ui` on a PR
    into `development`/`master` and a **gated `deploy`** job (`needs` the three) that auto-rolls the test
    contour **on a PR into — or a push to — `development`** (owner's "и PR, и push"). A PR into `master` is
    test-only; prod is the manual Stage 18. The former `go-unit`/`integration`/`ui-test` workflows were
    folded in (no path filters — full CI on every PR, per the owner). Console kept plain (`NO_COLOR`,
    `docker compose --ansi never`, `--progress plain`).
  - **Gateway serves the UI** (interview, the §13 single-origin): a new `gateway/internal/webui` embeds
    `dist` via `go:embed` (a committed placeholder index so `go build`/CI compile without a UI build) and
    serves the SPA at `/` and `/telegram/` (a path-stripping SPA handler, index.html fallback for the hash
    router), mounted in the edge mux **below** the h2c wrap; `/_gm` stays an explicit 404 when the local
    admin proxy is off so the catch-all does not leak the shell. The `gateway/Dockerfile` node stage builds
    the UI with the `VITE_*` build-args and copies it into the embed dir before `go build`.
  - **Images** (interview): multi-stage distroless `backend/Dockerfile` (a DAWG stage `curl`s the
    `scrabble-dawg` release pinned to `DICT_VERSION`, `GOPRIVATE` fetches the solver) and `gateway/Dockerfile`
    (node UI stage + Go stage), both trimming `go.work` like `platform/telegram/Dockerfile`. Built and
    verified locally.
  - **Contour = caddy-fronted** (interview, "caddy всё равно нужен для https"): a new `caddy` service owns
    a **single `/_gm` Basic-Auth** and routes `/_gm/grafana/*` → Grafana (anonymous-admin + sub-path, no
    own accounts) and the rest of `/_gm/*` → the backend console; everything else → the gateway. This
    **supersedes Stage 10's** gateway-fronts-`/_gm` model **in the deploy topology** (the gateway's own
    `/_gm` proxy stays for a local non-caddy run). TLS: the **host caddy** terminates it for the test
    contour and forwards to `scrabble:80`; the in-compose caddy is parameterised (`CADDY_SITE_ADDRESS`) to
    own ACME on prod (Stage 18) where there is no host caddy.
  - **Networks** (engineering): inter-service traffic on a private `internal` network (project-scoped DNS,
    no name collisions on the shared `edge`); only caddy joins the external `edge` (alias `scrabble`). The
    connector keeps its VPN sidecar (the only egress that needs the tunnel). The connector-scoped
    `platform/telegram/deploy/docker-compose.yml` was **retired** (the root `deploy/docker-compose.yml`
    supersedes it; the connector Dockerfile stays).
  - **Observability stack** (interview): OTel Collector (OTLP/gRPC → a Prometheus scrape endpoint +
    Tempo OTLP) + Prometheus (**15d**) + Tempo (**72h**) + Grafana (provisioned Prometheus+Tempo datasources
    + four dashboards: Service overview, Edge/UX, Game domain, Users; Traces via the Tempo datasource +
    Explore, no fixed panels). The collector's prometheus exporter uses `add_metric_suffixes:false` +
    `resource_to_telemetry_conversion` so the dashboards' PromQL matches the in-code metric names and carries
    `service_name`. The three services export `otlp` in the contour (default stays `none`, so CI needs no
    collector). Loki/logs were left out of scope (container stdout / zap JSON).
  - **User metrics** (interview): a backend `accounts_created_total{kind}` counter (telegram/email/guest;
    robots excluded — they are a provisioned pool, not users) via the Stage-12 `SetMetrics` no-op pattern,
    and a gateway **in-memory** `active_users{window=24h,7d}` observable gauge (distinct authenticated edge
    actors). The owner chose the in-memory gauge over a DB `last_seen_at` (overkill); its single-instance /
    reset-on-restart limits are documented (a live gauge, not billing).
  - **Owner actions before the contour is green** (surfaced, not blockers): set the **`TEST_`** Gitea
    secrets/variables (see `deploy/.env.example`) and add a host-caddy route `<test domain> → scrabble:80`
    on the runner host. CI bootstrap nuance: the first PR introducing `ci.yaml` may first deploy on the
    post-merge push to `development` (depending on whether Gitea runs head/base workflows for a PR), after
    which PR-time deploys work.
  - **Telegram test environment** (post-deploy fix): the connector now selects Telegram's test env with the
    library's native `tgbot.UseTestEnvironment()` (was a `token += "/test"` hack — functionally identical,
    verified, but the option is idiomatic and now has a `bot` test asserting the `/bot<token>/test/getMe`
    path). The test contour **pins `TELEGRAM_TEST_ENV=true` in `ci.yaml`** (the contour is the test
    environment) rather than via a `TEST_`-prefixed variable — removing a confusing double-`TEST` operator
    knob and the secret-vs-variable footgun; prod (Stage 18) leaves it `false`.

- **Stage 17** (interview + implementation): the test-contour verification pass. The owner's
  collected caveats were classified (fix-now / verify-then-fix / discuss) and resolved in one session.
  - **Russian Scrabble fixed** (#6): the UI sent the variant id `russian` while the backend's canonical
    string (and `StateView`) is `russian_scrabble`, so `lobby.enqueue`/invite returned 400 (confirmed in
    the contour logs). The UI was aligned to `russian_scrabble` (the `Variant` type, `variants.ts`,
    `Lobby.svelte`, mock fixtures, premium/alphabet keys, tests); the backend label is unchanged
    (persisted games, GCG and the `variant` metric attribute keep it).
  - **Nudge message** (#3): `social.ErrNudgeOnOwnTurn` shared the `not_your_turn` result code with
    `game.ErrNotYourTurn`, so nudging on your own turn read "it is not your turn" — backwards. A distinct
    `nudge_own_turn` code + i18n message was added, and the UI disables the nudge control on your own turn.
  - **Connector name sanitization** (#2): `account.ProvisionTelegram` now cleans the platform name to the
    editable display-name format (`sanitizeDisplayName`) and falls back to `Player`/`Игрок-NNNNN` (by
    language) when nothing remains. A new `account.ProvisionRobot` lets system robot names bypass editor
    validation (e.g. "Peter J.").
  - **Robot names** (#5, interview): per-language composed pools — 32 full + 32 colloquial first names
    paired by index, plus a surname pool (gender-agreed for Russian) rendered in three forms (first only /
    first + surname initial / first + full surname), composed deterministically per pool slot (stable
    across restarts). `Pick(variant)` is variant-aware: a Russian game draws Russian names with ≤ ~20%
    Latin, an English game the Latin pool. Robot identities are keyed `robot-<lang>-<index>`.
  - **Robot names — per-game variety** (post-release, 2026-06): the seeded pools above now only back
    each account's *fallback* name. The disguised reaper stamps a **fresh per-game name** on the robot's
    seat — a new `game_players.display_name` snapshot, which also captures humans' names so a later rename
    no longer rewrites past games (a reader falls back to the account name for a pre-migration row). The
    name is drawn from a wide composed corpus (`internal/robot/namevariety.go`): Western locales
    (EN/DE/ES/IT/FR/PT), native Japanese/Chinese names, a gender-agreed Russian pool, and human-style
    handles (stem + optional `.`/`_` + optional trailing number). Routing keeps the spirit of `Pick`
    (Russian: Cyrillic + ≤20% Latin, never a CJK script; English: the full corpus) via `robot.PickNamed`.
    `account.ValidateDisplayName` was loosened to admit a **trailing run of ≤5 digits** (mirrored in
    `ui/src/lib/profileValidation.ts`), so "Player2007"-style handles are valid for humans too and the
    disguised robot stays indistinguishable.
  - **Robot timing** (#4, interview): the fixed `2 + 88·u^3.5` move delay became move-number-aware — the
    band interpolates from [3,10] min at the first move (raised from [1,5] in round 4, #14) to [10,90] min
    by ~28 moves, right-skewed by k=4, so early moves are quick and the endgame can be long. A daytime
    nudge pulls the reply toward the move's lower band.
  - **Multi-device push** (#7, interview): `emitMove` no longer skips the acting seat, so the mover's own
    other devices (and their lobby) refresh. `opponent_moved` stays in-app only (no out-of-app push to the
    actor), and the gateway already fans each event out to all of a user's live streams.
  - **Move-duration analytics** (#1, interview): a live `game_move_duration{variant,phase}` histogram
    (opening/middle/endgame) + a Grafana panel, plus offline per-user analytics in the admin console —
    min/avg/max columns in the user list and an inline-SVG chart of think-time by the player's move number,
    computed from the journal (`game_moves.created_at` deltas; no schema change). Per-user stays offline,
    not a Prometheus label, to avoid cardinality blow-up; the live histogram aggregates all seats (robots
    included), so the per-human admin view is authoritative.
  - **CI** (#9/#10, interview): `unit`/`integration`/`ui` are path-conditional behind a `changes` job; an
    always-running `gate` job aggregates them (success-or-skipped) and is the single branch-protection
    required check (`CI / gate`), so a skipped job never blocks a merge. The deploy job gained a
    Telegram-connector liveness probe (`docker inspect`: running, not restarting, stable restart count,
    with a VPN-handshake grace period) — closing the Stage 16 blind spot where a crash-looping connector
    was invisible to the gateway-only probe.
  - **UI theming / UX**: inside Telegram the colour scheme is forced from `WebApp.colorScheme` over the OS
    `prefers-color-scheme` (fixes the Telegram Desktop breakage, #12) and the theme switcher is hidden
    (#11); the nav bar takes Telegram's bg and the announcement banner a subtle `--ad-bg` accent (#14/#15);
    the reconnect banner is suppressed while backgrounded and the stream reconnects on return (#16); hint
    zoom scrolls to the placement (#17); the players plaque raises the active seat and sinks the others
    with a tap toggling history (#19/#20); history fixes the word-column jitter and pins its bottom shadow
    to the board (#21/#23); directional screen-slide transitions (#18a); a per-game in-memory cache renders
    instantly on re-entry and refreshes in the background (#13).
  - **Grafana repeated password (#8) — not a server defect**: verified live that caddy challenges `/_gm`
    and `/_gm/grafana` with one identical realm and Grafana serves anonymously, so the repeated prompt is a
    browser Basic-Auth scoping quirk (likely Safari/Desktop), not infra — left for the owner to re-verify,
    no server change. **Multi-word history (#22)** was already implemented (all formed words shown).
  - **Contour-verification follow-ups** (rounds 2–3, from live testing): the Grafana
    double-password was its **Live WebSocket** tripping caddy Basic-Auth — Grafana Live is
    disabled (`GF_LIVE_MAX_CONNECTIONS=0`) and the admin console links to Grafana; the
    move-duration panel was invisible because the deploy reseed (`rm -rf`) left the
    config-only services on a stale bind mount — the deploy now **force-recreates**
    caddy/otelcol/prometheus/tempo/grafana; the **per-user rate limit** was raised 120/40 →
    300/80 and the UI no longer reloads on the echo of its own move; the iOS/Telegram
    reconnect banner gained a resume **grace window** (visibilitychange + pageshow/pagehide
    + Telegram `activated`/`deactivated`); **Telegram Mini Apps polish** was adopted —
    chrome colours (`setHeaderColor`/`setBackgroundColor`/`setBottomBarColor`), native
    **BackButton**, **HapticFeedback**, **closing confirmation** in a game,
    **disableVerticalSwipes**; the players-plaque highlight was inverted so the active seat
    pops; the make-move popover became a direct **✅** with a tab-bar **↩️ Reset**; the hint
    button disables at zero hints; plus **board-only vertical scroll** (#9) and a
    **keyboard-overlay** check-word dialog (#10).
  - **Contour-verification follow-ups** (round 4, from live testing): the robot early-move band was raised
    [1,5] → [3,10] min so openings are less hasty (#14); the **profile** is edited inline (the Edit/Cancel
    toggle is gone, the form is always shown for durable accounts, hint balance stays read-only) and a
    single trailing "." is allowed in display names (#5/#6); a pending tile now recalls by **double-tap**
    or by **dragging it back onto the rack** (single-tap recall removed), and **holding a dragged tile over
    a cell ~1 s auto-zooms** there (#3/#10); **🔀 Shuffle is animated** — tiles hop along a low parabola to
    their new slots, duration scaled by distance, with a haptic shake (#9); a **lines-off board** Settings
    toggle (default off) renders a gapless checkerboard with rounded, right-shadowed tiles, reclaiming
    ~14px of width (#12). **Deferred (discussed):** board **pinch zoom** (#2) — it fights both the native
    scroll and the new one-finger drag-back, so it stays dropped in favour of double-tap + hover-hold zoom;
    **robot win% in the admin game detail** (#16) — needs the seed-derived play-to-win decision exposed
    across the game/robot package boundary, to be picked up when that seam is added.
  - **Contour-verification follow-ups** (round 5, from live testing): **resign on the opponent's turn**
    now works — the engine gained `ResignSeat(seat)` and `game.Resign` bypasses the turn check to forfeit
    the actor's own seat (it no longer returned "not your turn"); **quick-match cancel** was a UI no-op
    (only stopped polling) — added the full path (REST `/lobby/cancel` → gateway → client) and clear the
    matchmaker's pending result on cancel, so a cancelled search is dequeued (no "already queued", no later
    robot-substituted game); **lobby win/loss** ranked by score, so a 0-0 resignation read as a win —
    `result.ts` now places the viewer below the winner (rank ≥ 2), matching the game detail; a **friend
    request to a robot** is accepted as pending and expires like a human ignore (robots no longer set
    `BlockFriendRequests`); the **nudge cooldown** error got its own `nudge_too_soon` code/message and the
    chat button disables for the hour, with the note reworded to "Waiting for your move!". UI polish:
    **even zoom** (interpolate the scroll toward a pre-clamped target as the real width grows/shrinks — no
    lurch-and-snap) that **recentres only on the first zoom-in**; a drag **drop-target highlight**; **pinch
    zoom** (two-finger, preventDefault only on two touches; a second finger aborts a drag); shuffle hop
    capped at **0.3 s** and off under reduce-motion; a **borderless** make-move icon, disabled on a known-
    illegal pending word; **variant display names** (english & russian_scrabble → Scrabble/Скрэббл, erudit
    → Erudite/Эрудит) shown on the create-game controls and the in-game title. **L2 done:** the admin game
    card now shows each robot seat's per-game play-to-win **intent** + the ~40% target and, on the robot's
    turn, its deterministic **next-move ETA**. *Open edge:* a bilingual New Game (both en+ru offered) would
    show two "Scrabble" buttons — left as the owner's chosen naming; disambiguate if it bites.
  - **Contour-verification follow-ups** (round 6, from live testing) — **shipped & deployed:** profile drops
    the hint-balance line; no mobile tap-flash on a board cell (`-webkit-tap-highlight-color`); variant
    display names keyed by the game's **alphabet**, not the UI language (english → "Scrabble",
    russian_scrabble → "Скрэббл", both unlocalized so they never collide; erudit localized), and the in-game
    title shows the variant name; **chat & nudge are mutually exclusive by turn** (message field on your
    turn, nudge on the opponent's, grey "awaiting reply" caption during the cooldown), with chat enforced
    server-side to your own turn (`ErrChatNotYourTurn`); the **nudge cooldown resets** once the player has
    moved or chatted since the last nudge (`game.LastMoveAt` + last chat vs last nudge; the UI mirrors it);
    the **About** screen got localized titles + a rules link + the random/friends sections, and the app
    **version comes from `git describe`** (Vite define `__APP_VERSION__` ← Docker build-arg in the deploy
    step, default "dev"); the **quick-game buttons** became lobby-style plaques — name + flag (🇺🇸/🇷🇺 + a
    bundled minimalist USSR-flag SVG) + a one-line rules summary (bag size, the ё rule, bonus differences
    from the engine rulesets) + the 24h move-limit beneath; two follow-up fixes (pin the nudge right when
    available; redraw the USSR emblem as a thin schematic hammer & sickle); **#3 drag-reorder of rack tiles**
    with a visual gap (the dragged tile lifts out, the rest slide to open a slot; `reorderIndices`
    unit-tested; only with no pending tiles); and the **persistence backend foundation** (#4/#5/#6): a
    `game_drafts` table (migration 00011) + raw-SQL store/service (`GetDraft`/`SaveDraft`) that, on every
    committed move, clears the actor's own draft and resets any opponent's board draft whose cell the play
    overlapped — 5 integration tests.
  - **Stage 17 round 6 — final pass (#4/#5/#6 + #16–20), shipped:**
    1. **Draft persistence — gateway slice + UI (#4/#5/#6, PR #20).** FB `DraftRequest{game_id, json}`
       (save) + `DraftView{json}` (get reuses `GameActionRequest`); the client serializes
       `{rack_order, board_tiles}` itself (no FB tile array), the gateway forwards it as `json.RawMessage`
       both ways (no double-encode), and `GET`/`PUT /games/:id/draft` (a server `draftDTO` ↔ `game.Draft`)
       is the only place that reads the shape. UI: debounced save of the rack order (#4) + board draft (#6)
       and restore on load (`lib/draft.ts`, reconciling against the committed board); **#5** — tiles may be
       arranged on the opponent's turn (placement relaxed; the preview and Make-move stay your-turn-only,
       so an off-turn draft is position-only). Off-turn tiles keep the **existing pending highlight** — no
       caption, no new style (owner's call). The backend draft endpoint is sub-ms.
    2. **Landing + `/app/` move (#16–20, this PR).** One Vite build with **two HTML entries** — the game
       SPA (`index.html`) and a new lightweight landing (`landing.html` → `Landing.svelte`, reusing the
       theme/i18n/`aboutContent` leaf modules, not the app store, so it stays small). The gateway serves the
       **landing at `/`** and the **game SPA at `/app/` and `/telegram/`** (`webui.Handler(stripPrefix,
       indexName)`); relative base keeps one build serving every mount with a shared `dist/assets/` (the
       planned per-target `base` conditional proved unnecessary). **Correction to the original note:** the
       Telegram **Mini App stays at `/telegram/`** — only the plain web app moved off `/` to `/app/`, so
       BotFather is untouched. The landing's "Play in Telegram" link is **per-language** via two new build
       vars `VITE_TELEGRAM_LINK_EN` / `VITE_TELEGRAM_LINK_RU` (test/prod bots differ → no hardcoding; the
       button hides when unset). Logo copied `.claude/telegram-logo.svg` → `ui/public/` (source stays
       untracked).
    - **Edge robustness (folded into the landing PR).** (a) **Static cache headers** — the embedded
      `http.FileServer` over `go:embed` has a zero modtime, so it emitted no validators → the client
      re-downloaded the whole bundle every launch; now hash-named `/assets/*` are `immutable` (a relaunch
      is a cache hit) and the HTML shells are `no-cache`. (b) **Live-stream 15 s abort** — the `Subscribe`
      heartbeat only fired after the first 15 s tick, so the stream sat silent and raced a ~15 s edge idle
      timeout (constant reconnects in the caddy log); now an **immediate heartbeat on open** + a **10 s**
      default interval. Both surfaced while diagnosing a reported "slow load in Telegram" that was actually
      the owner's **external network** (the server is sub-ms end-to-end) — not a regression.
  - **Landing follow-up (owner review pass):** reworked from the first cut — the per-language Telegram
    link var renamed `VITE_TELEGRAM_LINK_EN/_RU` → **`VITE_TELEGRAM_GAME_CHANNEL_NAME_EN/_RU`** (it carries
    a channel **username**, the landing builds `https://t.me/<name>`; the connector keeps the matching
    `..._CHANNEL_ID_..` to post). Switchers became icons — a 🌐 language dropdown (saved, synced to the app)
    and a ☼/☾ theme toggle that is **ephemeral** (follows the system scheme, never persisted, no "auto").
    The "Play in browser" CTA was dropped (no standalone-web onboarding yet).
  - **Game/Telegram review-pass polish:** the USSR flag emblem redrawn (canonical hammer & sickle,
    scaled down ×1.5 below the star, the star itself +25%); touch drag enlarges the drag ghost ×1.5
    (touch only — the finger hides the tile) and suppresses the iOS tap-highlight that lingered on a
    rack tile sliding into a dragged tile's slot; a placed tile can be **dragged to another board
    cell** (it lifts off its origin for the drag, and `touch-action:none` lets the drag win over the
    board pan when zoomed) and the manual-select ring clears when a tile is recalled; and **Telegram
    fullscreen** no longer hides our header under its native nav — the whole header drops below the
    content-safe-area top inset (title and the right-aligned menu both clear the nav), via
    `--tg-content-top` from the SDK + a `tg-fullscreen` class. (Telegram's Mini App SDK exposes no way
    to set the native nav-bar title, move its buttons, or add items to its "⋯" menu, so we keep our
    own header and simply push it clear.)
  - **Lobby sort + in-game friend state (review pass, PR C):** the **my-games** lobby now groups games
    into *your turn* / *opponent's turn* / *finished* (empty sections hidden) and orders them by last
    activity — your-turn oldest-first (the longest-waiting on top), the other two newest-first — in a
    compact, line-separated list (the owner's density pick over bordered cards). `gameDTO` / FB
    `GameView` gained `last_activity_unix` (the turn start while active, the finish time once
    finished). The in-game **"add to friends"** item is now **server-derived** (new `GET
    /user/friends/outgoing` + `friends.outgoing` op, returning the addressees already requested —
    pending **or** declined, which both read as "request sent") so it is correct across reloads, shows
    a disabled **"✓ in friends"** once accepted, and **live-updates** when the opponent answers:
    `RespondFriendRequest` now publishes `friend_added` (accept) / `friend_declined` (a new notify
    sub-kind, decline) to the **original requester**, whose open game re-derives its friend state.
    Owner decisions: a declined request stays "request sent" (non-revealing); an accepted opponent
    reads "✓ in friends"; rack-tile reorder while tiles are placed stays disabled by design.
  - **Admin "Messages" moderation section (#18, PR D):** a new `/_gm/messages` console page lists
    posted chat messages (**nudges excluded**) newest-first — time · **source** (guest / robot /
    oldest identity kind) · sender (→ user card) · IP · body · game (→ game card) — searchable by
    sender name / external-id glob masks and pinnable to one game (`?game=`) or sender (`?user=`),
    linked from the game and user cards. Server-rendered (`adminconsole` `MessagesView` +
    `messages.gohtml`, 50/page via the shared pager); the list query lives in `social` (raw SQL,
    `kind='message'`, the source via a SQL `CASE`), reusing the now-exported `account.LikePattern`
    glob helper. Owner decisions: messages only (no nudges), separate name/ext masks (matching the
    Users section), a top-level nav entry plus the card deep-links.
  - **Round-6 follow-up — UX polish + client-IP fix (this PR):**
    - **Client IP through the edge.** The compose caddy now sets `trusted_proxies static
      private_ranges`, so the real client IP survives the host-caddy hop (it was logging the
      docker-network caddy hop `172.18.0.x` for chat moderation, and bucketing the gateway's
      per-IP rate limiter on it). Correct + spoof-safe in **both** contours (prod has no host
      caddy → public clients untrusted → real peer used). `peerIP` unit-tested.
    - **Ad banner** was gated off here behind `SHOW_AD_BANNER=false`; the **AD** phase
      (PRERELEASE.md) later turned it into the real server-driven advertising network and removed
      the gate (`Screen` renders it from `app.profile.banner`).
    - **Landing** Telegram entry is now just the **64px logo** (clickable, no button/caption).
    - **TG-fullscreen header** reworked again: title + menu are one **centred pair** (hamburger
      right of the title) pinned to the **bottom** of the TG nav band, lining up with Telegram's
      own controls.
    - **Edge-swipe back** (`Screen.svelte`): a left-edge rightward drag navigates to `back`
      (touch/pen only, armed only from ≤24px so it never fights the board's gestures; skipped
      inside Telegram, which has its own back).
    - **Chat + word-check are now their own routed screens** (`/game/:id/chat`,
      `/game/:id/check`, header back to the game, no tab-bar) so the soft keyboard simply resizes
      the **visible viewport** — mirrored into a `--vvh` CSS var the `Screen` height uses, since
      iOS doesn't shrink `dvh` for the keyboard — with the input pinned to the bottom: no modal
      relayout, no page jump (this superseded a first bottom-sheet-`Modal` attempt). New chat
      messages raise an **unread badge** on the in-game hamburger + the Chat menu row (per game,
      cleared on open), mirroring the lobby badge; the chat screen is routable for a future
      Telegram deep-link. The TG-fullscreen header was also finalised over a couple of review
      passes: title + menu as a centred pair inside Telegram's nav band (between `--tg-safe-top`
      and `--tg-content-top`), with a small padding bump so the native controls aren't flush.
    - **Tests backfilled** for the merged round-6 work: e2e for the in-game "✓ in friends" item
      and a board→board tile relocation; codec units for `last_activity_unix` + `OutgoingRequestList`.
    - **Hide finished games (#5, shipped):** a player can remove a finished game from their own
      *my games* list — **per-account, finished-only and irreversible** (the game stays for the
      other players; there is no un-hide). On a finished row a **swipe-left** (touch) or a tap on
      its **kebab ⋮** (the desktop affordance) reveals a **❌** that hides it; active rows carry an
      inert **›** chevron purely to keep the right-edge icons aligned. New table
      `game_hidden(account_id, game_id)` + migration `00012`; `ListGamesForAccount` filters the
      hidden set; `POST /api/v1/user/games/:id/hide` behind the `game.hide` edge op (reusing
      `GameActionRequest` → an `Ack`); the lobby drops the card optimistically and keeps the cache
      in sync. Covered by an integration test (active→`ErrGameActive`, outsider→`ErrNotAPlayer`,
      per-account visibility, idempotent), a gateway transcode test, and a mock e2e (kebab → ❌).
    - **Enriched out-of-app push (#4, shipped):** the "your turn" Telegram notification now names
      the opponent and recaps their last move — voiced as the opponent, `«{name}: my move —
      «WORD». Score 120:95»` for a scoring play, or a short "swapped / passed, your turn" — and a
      new **game-over** notification reports the result + final score when a game ends (any path:
      closing play, all-pass, resign, timeout). Scores are **recipient-first** (the reader's
      score leads), 2- to 4-player (`120:95:80`). `YourTurnEvent` gained `opponent_name`/
      `last_action`/`last_word`/`score_line` (appended, backward-compatible) and a new
      `GameOverEvent` carries `result`/`score_line`; both emit per-recipient from the game commit
      (`emitMove`), join the out-of-app whitelist, and render per language (en/ru) in the Telegram
      connector. The backend resolves the mover's display name (the score line and result are
      built per recipient). Covered by notify round-trip, emit, connector-render (en/ru) and
      routing tests.
    - **No-op drain of all bot updates (#1, investigated — no change):** confirmed the Telegram bot
      already long-polls and the library advances the offset for every delivered update (the default
      handler no-ops anything but `/start`), so the queue never piles up. Telegram withholds only
      `message_reaction` / `message_reaction_count` / `chat_member` by default, and — being
      unrequested — those never queue either. Owner chose to leave `allowed_updates` at the default
      (zero risk) rather than hand-maintain a full whitelist (a wrong/stale type would break
      `getUpdates` entirely); a specific type will be requested when a concrete handler needs it.
    - **Reconnect UX — "Connecting…" + soft-disable (#2, shipped):** connectivity failures became
      **state, not toasts**. A global `online` signal (`lib/connection.svelte.ts`) flips on a
      transport `unavailable` / `rate_limited` (and on the live stream's drop), driving a pure-CSS
      header **spinner + "Connecting…"** in place of the title and softly disabling the in-game
      server actions (commit / exchange / pass / hint; local board/rack/reset stay live). The
      transport (`exec`) **auto-retries with capped backoff** — every op on a rate-limit, **reads
      only** on `unavailable` (mutations are not blindly re-sent; their buttons are disabled while
      offline, so the player re-issues on reconnect — the idempotency caveat the owner accepted). A
      reachability **watcher** (`profile.get` probe) and any successful traffic clear the signal; the
      old red `error.unavailable` toast is gone (the indicator replaces it). A server-data screen
      still **opens with the spinner** and fills on reconnect (global indicator + read auto-retry),
      so navigation is never dead. Pure policy unit-tested (`retry.ts`); a mock-only `window.__conn`
      hook drives a Chromium+WebKit e2e (indicator appears offline, the action disables, both clear
      on reconnect). The visual soft-disable spans the server-action buttons across the app: the
      game bar (commit/exchange/pass/hint/resign), chat send + nudge, profile save / link / merge,
      friends (request/respond/unfriend/block/code), New Game (auto-match + invite) and the lobby
      hide ❌; purely local controls (board/rack/reset, menu, navigation, settings) stay live.
    - **Nudge defects (owner-reported, shipped):** two from a live contour game.
      **(A) Frequency** — the robot's proactive nudge fired hourly for 12 h+ (12 h idle threshold +
      the 1 h cooldown, uncapped). Replaced with a **lengthening, randomized schedule** in the
      robot strategy/driver: the first nudge ~60-90 min into the human's turn, each later gap
      growing toward 1-6 h (the gap is a uniform sample in `[60 min, ceil]`, `ceil` ramping from
      90 min to 6 h over 12 h of idle, measured from the previous nudge), so a long wait gets a
      handful of increasingly-spaced reminders. **(B) Language** — the out-of-app push routed by
      the recipient's **global `service_language`** (last-login-wins), so after re-logging through
      the RU bot an English game's nudges came from the RU bot. Now a game push (your_turn,
      game_over, nudge, match_found) carries the **game's own language** (`engine.Variant.Language`)
      on `push.Event`, and the gateway routes by it (falling back to `service_language` for
      non-game pushes); the New-Game variant-gating guarantees deliverability. Covered by the
      `proactiveNudgeGap` unit test, the retimed `TestRobotProactiveNudge`, `TestVariantLanguage`,
      emit (`your_turn`/`game_over` language) and a `TestNudgeRoutedByGameLanguage` integration test.

### Stage 19 — user feedback
- Dropped the planned `feedback_messages.attachment_type` column: the safe content-type is derived from the
  file-name extension at both validate and serve time (one source of truth), so a stored column was
  redundant.
- The admin filter is **three-way** (Непрочитанные / Прочитанные / Архив) rather than the two first
  sketched, mirroring complaints' `open·resolved·all`, so a read-but-unarchived message is never
  unreachable (owner-approved at planning).
- The guest gate was extended to the **gateway** at the owner's request: a new `Op.NonGuest` flag plus
  `is_guest` threaded through the session resolve + gateway cache reject a guest's `feedback.submit` with
  the `guest_forbidden` result code before any backend call (the backend keeps its own guest check as the
  backstop). `handleResolveSession` now loads the account best-effort to report `is_guest` (a read error
  falls back to false so the auth hot path stays resilient).

## Deferred TODOs (cross-stage)

- ~~**TODO-1 — publish & version the solver.**~~ **Done in Stage 14.** `scrabble-solver` is
  published as module `gitea.iliadenisov.ru/developer/scrabble-solver` (tagged `v1.0.0`, with
  `wordlist`/`dictdawg` de-internalised to public packages); `backend/go.mod` pins it, the `go.work`
  replace and the CI sibling-clone are gone, and `GOPRIVATE=gitea.iliadenisov.ru/*` fetches it directly
  (no public proxy/checksum DB). Removes the floating `master` dependency accepted since Stage 2.
- ~~**TODO-2 — split the solver into engine vs dictionary generator + versioned dictionary
  artifacts.**~~ **Done in Stage 14.** A new repo `developer/scrabble-dictionary` holds the word-list
  sources + `cmd/builddict` (moved out of the solver, with `dictprep` and the `dictionaries` submodule)
  and builds the three DAWGs against the **published solver + pinned `dafsa`/`alphabet` v1.1.0** — the
  output is **byte-identical** to the solver's committed fixtures, so the index-drift caveat is handled
  by construction. Delivered as a Gitea **release artifact** `scrabble-dawg-vX.Y.Z.tar.gz` (not
  `go get`; DAWGs are data; **one semver label for the whole set**); the Go workflows download it for
  `BACKEND_DICT_DIR`. The runtime dynamic-reload contract (per-version `BACKEND_DICT_DIR/<version>/` via
  `Registry.LoadAvailable` / `engine.OpenWithVersions`, Stage 10) is unchanged — a deploy drops a new
  set into the directory; a version is safe to retire once no active game pins it.
- ~~**TODO-3 — garbage-collect abandoned guest accounts.**~~ **Done in Stage 12.**
  A periodic `account.GuestReaper` deletes guests (`is_guest`) **with no game seat at
  all** whose account age exceeds `BACKEND_GUEST_RETENTION` (default 30 d, swept every
  `BACKEND_GUEST_REAP_INTERVAL`, default 1 h). Two schema facts shaped this, narrowing
  the original sketch: (1) `game_players`/`chat_messages`/`complaints` reference accounts
  **without** `ON DELETE CASCADE`, and a finished game belongs to the other players'
  history, so a guest with any seat is retained (a delete would be blocked anyway) — hence
  "no seat", not "no active game"; (2) sessions are revoke-only with no maintained
  `last_seen_at`, so a lingering session never expires and **account age** is the
  abandonment trigger, not "last session gone". The reaped guest's `sessions`/`identities`/
  `account_stats` fall away via their own `ON DELETE CASCADE`.
- ~~**TODO-4 — put the per-game alphabet on the wire (owner's idea, Stage 7).**~~ **Done in
  Stage 13.** The client is now alphabet-agnostic: it caches each variant's `(index, letter,
  value)` table — sent by the backend behind `StateRequest.include_alphabet` on a per-variant
  cache miss — and live play exchanges **letter indices** both ways (rack, submit-play,
  evaluate, exchange, word-check; a blank rides as the sentinel index 255). The table is
  produced from the solver ruleset (`engine.AlphabetTable`), so it is pinned by the solver
  version and cannot drift from the running backend, and `ui/src/lib/premiums.ts` is now
  geometry only. The durable journal / history / GCG stay decoded concrete characters (§9.1,
  unchanged). The DAWG/solver build-time agreement (the original caveat, shared with TODO-2) was
  discharged in Stage 14: the dict repo builds against the published solver + pinned
  `dafsa`/`alphabet`, byte-identical to the fixtures.
- **TODO-5 — QR friend codes (owner's idea, Stage 8).** *Partially done in Stage 9, extended later:*
  the deep-link scheme exists (`f<code>`, shared Go ↔ TS), the bot redeems it on launch, and the UI
  offers a real **Share** control for an issued code — Telegram's native share-to-chat picker inside
  the Mini App (`openTelegramLink` + `t.me/share/url`), the system share sheet (`navigator.share`) on
  the web, else copy-the-link. The shared link points at the **same bot the player signed in through**:
  it carries the session's `service_language` (now on the `Session` wire) and picks
  `VITE_TELEGRAM_LINK_EN`/`_RU`, falling back to `VITE_TELEGRAM_LINK`. **Still open:** render the link
  as a **QR** so a friend can add you by scanning. The code semantics (12 h TTL, single use, one active
  per issuer) stay as-is; only the delivery changes.
- **TODO-6 — smart default for the friend-game "game type" (owner's idea, Stage 8).**
  The play-with-friends form has no preselected variant today (an empty, required
  pick). Default it from the player's history (the variant they play most, from
  `account_stats` or a games query), falling back to their interface language
  (en → English, ru → Russian/Эрудит). Until then the explicit pick avoids guessing
  wrong.
- **TODO-7 — animate a lobby card's move between sections (owner's idea, 2026-06).**
  When a game changes bucket — *your turn* → *finished*, *opponent's turn* → *your turn*, etc. —
  its card simply re-renders in the new section; only the status emoji **blinks twice** (added
  alongside the named-nudge + lobby-blink work). A FLIP / slide animation that visibly relocates
  the card to its new section would be a nicer touch. Deferred — purely cosmetic, nothing depends
  on it.
