# Agent field notes — scrabble-game

Non-obvious, hard-won project knowledge that is **not** in the main docs (`CLAUDE.md`, `docs/*`,
`deploy/README.md`). Kept in the repo so it travels with a clone to any host. These are working
memory, not a spec — **verify any named file / flag / function against the current code before acting
on it**, and prefer the authoritative docs where they overlap. Add to this file as new gotchas turn up.

## Codegen & build

- **jetgen churns everything.** `backend/cmd/jetgen` regenerates go-jet code for *all* tables and may
  reorder output. After running it, revert the churn on tables you did not touch and commit only your
  table's change.
- **flatc is version-pinned** (`pkg/Makefile`, `REQUIRED_FLATC = 23.5.26`, hard-checked). A different
  flatc silently churns the generated wire code and can flip wire defaults. Never regenerate FBS with
  another flatc version.
- **gopls lags codegen.** Right after an FBS/jet regen, gopls shows phantom "undefined" errors. Trust
  `go build` / `go test`, not the editor squiggles.
- **go-jet type mapping:** SQL `numeric` → `float64`, `interval` → `string`. Never store money as
  `numeric` (float precision loss) — use **bigint minor units + a `Money` type**; store durations as
  **int seconds**, not `interval`.
- **`go mod tidy` chokes on dot-free local module paths** (`scrabble/...`). Hand-edit `go.mod` when a
  bump is needed. The solver (`../scrabble-solver`) is consumed via `go.work` replace locally, but a
  prod bump goes through a solver **PR → master + a published tag**, not a local replace.
- **Run the whole CI suite locally before pushing** — unit + integration (`//go:build integration`,
  Postgres) + the UI job + codegen check. Do not lean on CI to catch what a local run would.
- **The UI job's last step is a size gate, not a test:** `cd ui && node scripts/bundle-size.mjs`
  (gzip budgets per entry, `BUDGET` in that file). `pnpm build` succeeding says nothing about it, and
  the app entry usually sits within a few hundred bytes of its cap, so *any* feature touching an
  always-loaded screen can fail CI green-on-everything-else. Run it, and **rebuild first** — the
  script measures whatever is in `dist/`, so a stale build silently measures the wrong branch. Put
  new logic behind an existing lazy import where it belongs; raising `BUDGET` is allowed but the
  file's header comment records the reason for every past raise — keep that up, and ask the owner.
- **CI runner shares this host's `/tmp` as a different user.** In workflow steps, write artifacts to
  `${GITHUB_WORKSPACE}`, never a fixed `/tmp/...` path (cross-user permission failures otherwise).
- **pnpm corepack pre-flight flakes.** `pnpm exec` / `pnpm check` occasionally abort on a corepack
  pre-flight. Dodge it by invoking the tool directly: `node_modules/.bin/<tool>`.

## Native Android build (Capacitor)

The native Android app is a Capacitor 8 wrapper of the `ui` SPA, scaffolded under
`ui/` (a Node project outside `go.work`). Hard-won bring-up facts — verify against current code:

- **Capacitor 8 needs JDK 21, not 17.** `@capacitor/android` compiles at `VERSION_21`; a JDK 17 Gradle
  run dies with `error: invalid source release: 21`. Install sudo-free via the Homebrew **formula**
  `brew install openjdk@21` (the `temurin@21` **cask** wants sudo for a system `.pkg`, unusable
  non-interactively) + a user symlink into `~/Library/Java/JavaVirtualMachines/` so
  `/usr/libexec/java_home -v 21` finds it. JDK 17 stays fine for `sdkmanager`/`avdmanager`.
- **Cap 8 SDK pins:** compileSdk/targetSdk **36**, minSdk **24**, Gradle **8.14.3**, AGP **8.13.0**
  (`ui/android/variables.gradle`). Install `platforms;android-36` — Android Studio's newer
  `android-36.1` does NOT satisfy compileSdk 36.
- **SDK is Android Studio's** at `~/Library/Android/sdk` (no `cmdline-tools` by default → `brew install
  --cask android-commandlinetools`, then `sdkmanager`/`avdmanager --sdk_root=$HOME/Library/Android/sdk`).
  Gradle finds it via **`ANDROID_HOME`** (`local.properties` is gitignored, machine-specific).
- **Build recipe:** `cd ui && pnpm build && node_modules/.bin/cap sync android`; then `cd ui/android &&
  ANDROID_HOME=~/Library/Android/sdk JAVA_HOME=$(/usr/libexec/java_home -v 21) ./gradlew assembleDebug`
  → `ui/android/app/build/outputs/apk/debug/app-debug.apk`. Prefer the local `ui/node_modules/.bin/cap`
  (dodges the corepack flake).
- **Emulator smoke:** existing AVDs `Pixel_10` / `Pixel_4_Android_10_API_29` / `Pixel_Android_9`;
  `emulator -avd <name>`, `adb install -r <apk>`, `adb shell monkey -p ru.eruditgame.app -c
  android.intent.category.LAUNCHER 1`, `adb exec-out screencap -p > x.png`. The boot wait needs `sleep`
  → run it as a **background** Bash task (foreground `sleep` is blocked). Browsers/WebView are cached, so a
  full offline vs_ai turn is drivable via `adb shell input tap` (tap through the first-run coachmark tour —
  each tap advances one step — then Hint places a suggested word; commit → the robot replies).
- **Android 15+ edge-to-edge safe-area (WebView-version-dependent, bit me on API 37).** targetSdk 36 forces
  edge-to-edge — the WebView draws behind the status bar (top) and the gesture-nav home indicator (bottom).
  On Android **WebView < 140**, `env(safe-area-inset-*)` wrongly reports **0**, so chrome relying on it draws
  under the bars and is untappable: the top nav under the clock, and the game's bottom action bar's **centre**
  button (Hint) under the home-indicator pill (side buttons still work; `navigation_mode`=2 is gesture nav).
  Fix is CSS-only, in TWO parts: (1) Capacitor 8's **SystemBars** plugin (built into `@capacitor/core`,
  `insetsHandling:'css'` default — no dep, no config) injects correct `--safe-area-inset-*`; consume them as
  `--tg-safe-*: var(--safe-area-inset-*, env(safe-area-inset-*, 0px))` (`ui/src/app.css`) — fixes any consumer
  that ALREADY applies the token (the bottom bars: `Game.svelte`/`Screen.svelte` `--tg-safe-bottom`).
  (2) But a consumer that never applied the top inset on the native path is NOT fixed by the token alone — the
  **header's** top inset was Telegram-fullscreen-scoped only, so the native header sat under the status bar on
  EVERY WebView; it needed its own `.bar { padding-top: calc(var(--safe-area-inset-top, 0px) + 5px) }`
  (`Header.svelte`, native-only via the plugin var; tg-fullscreen still overrides via specificity). **Measure
  per element, don't eyeball** — a centred title at y=11 under a 54px bar reads as "fine" in a screenshot but
  is overlapping; an emulator WebView auto-updated to ≥140 (Chrome 149) also hides the `env()`=0 half (so the
  BOTTOM looks fine there while the user's < 140 device overlaps). **Inspect a live debug WebView** over CDP:
  `adb forward tcp:9222 localabstract:$(adb shell cat /proc/net/unix | grep -o 'webview_devtools_remote_[0-9]*'
  | head -1)`, then Playwright `chromium.connectOverCDP('http://localhost:9222')` → `page.evaluate` (read each
  element's `getBoundingClientRect().top`, and `--safe-area-inset-*` vs `env(...)`).
- **`sharp` is whitelisted** in `ui/pnpm-workspace.yaml` (`allowBuilds: sharp: true`) — `@capacitor/assets`
  uses it for `pnpm android:assets` (launcher icon/splash); else pnpm 11 raises `ERR_PNPM_IGNORED_BUILDS`.
- **Bundled offline dicts come from the `scrabble-dictionary` release** (`scrabble-dawg-<DICT_VERSION>.tar.gz`,
  keyed on the `DICT_VERSION` Gitea var — the same source the backend image + CI `curl`), NOT
  `scrabble-solver/dawg` (those are the solver's pinned test fixtures).

## Wire / schema evolution (client ↔ gateway ↔ backend)

- **FBS is additive-only.** Add **trailing** fields ("added trailing — backward-compatible"). Never
  delete or reorder a mid-table field — **deprecate** it (`(deprecated)`); deleting shifts field IDs
  and breaks older readers.
- **Retiring a domain field must also retire the WIRE field it fed** — by deprecation, not deletion,
  and do not just zero it: a dead `0` the client dutifully syncs will clobber the real value.
- **The gateway transcodes FBS ↔ backend JSON DTOs in lockstep.** A wire change usually means editing
  both the FBS schema and the backend DTO.
- **Seat display name is built in two places** — `game.Service` live events **and** the server REST
  DTOs. Change both or they drift.
- **The client codec UPPER-CASES history words** (`lib/codec.ts` `decodeMove`) for display, while the
  engine/evaluator decodes candidate words to lower. Anything comparing the two must case-fold, or it
  silently never matches — this is how the Erudit no-repeat rule shipped dead on the online path
  while working offline (offline compares alphabet-index keys, so it has no case at all). A test that
  hand-builds such a set instead of deriving it the way the screen does will pass vacuously.
- **A per-viewer flag is computed only in the per-viewer REST DTO**; the client seeds it from REST,
  then bumps it from the live event. Don't expect it on the shared broadcast.

## UI / Svelte 5

- **Never name a `$state` variable `state`.** `svelte-check` then misreads `$state` as a store
  subscription. Rename (e.g. `view`).
- **Svelte trims literal edge whitespace** in markup. To keep a separator space, emit an expression:
  `{' : '}`.
- **No global `.btn` / `.ghost` button classes.** Style buttons per-component with scoped CSS + design
  tokens (mirror `NewGame`'s `.invite`).
- **A `$state` proxy fails structured-clone** when persisted to IndexedDB. Snapshot at the call site
  with `$state.snapshot(...)` before storing.

## Platform / WebView quirks (Telegram, VK, iOS, native, old Android)

- **Telegram `showPopup` eats the user-activation.** Share / clipboard called from inside a
  `showPopup` callback fail (no gesture). Use your own `Modal` for gesture-gated Web APIs.
- **iOS Telegram `<a download blob:>` navigates away** and strands the SPA. Deliver files by **Web
  Share on mobile**, and only use a `<a download>` Blob path on **desktop**.
- **Android TG/VK WebViews** lack `navigator.share` and ignore `<a download>`. Deliver a
  client-generated file by **copying it to the clipboard**.
- **VK Android WebView ignores `target=_blank`.** Open external links through `lib/links.ts`
  (routes to `vk.com/away.php`). Verify on-device.
- **Per-platform file-delivery last hop differs** (TG / VK / plain browser) — there is a delivery
  matrix; do not re-litigate it without new on-device facts.
- **Old Android System WebView floor ≈ Chrome 67.** The bundle targets **es2019** (esbuild lowers
  syntax), a conditional `core-js` polyfill loads only on old engines, and `index.html` has a boot
  gate (BigInt / Proxy are the hard block → the unsupported-engine screen). There's also a `vmin`
  glyph fallback for old rendering.
- **iOS WKWebView overscroll and Telegram swipe-to-close are not reproducible in Playwright.** Verify
  those live on the deployed contour, not in the e2e.
- **Telegram Desktop Mini App shows a persistent bottom-right loader** — that's the long-lived
  Subscribe stream, cosmetic, deliberately left as-is.

## Testing

- **UI test layers:** vitest (node env, pure logic, **no jsdom**) + Playwright **mock** e2e. The mock
  e2e **bypasses the codec**, so wire/codec bugs need **codec unit tests**, not e2e coverage.
- **Mock overlay blocks e2e.** The cold-load overlay must be instant under the mock build or it
  intercepts Playwright taps.
- **Mock tile pools lack a blank `'?'`.** The seeded game `G1` hard-codes one; flip `G1`'s variant to
  eyeball per-variant tiles.
- **Durable Playwright MCP servers** (chromium + webkit) exist for UI inspection (there's a
  plugin-config gotcha in wiring them).
- **The `playwright test` runner can't *fetch* browsers in this sandbox** — `playwright install` dies
  with `EBADF` against the Playwright CDN (blocked network), even with the sandbox off. Run the e2e in
  **CI** (the `ui` job installs chromium+webkit), or drive a state live through the **Playwright MCP**
  browser against a local `vite --mode mock` server for visual verification. **But if chromium/webkit are
  already cached** in `~/Library/Caches/ms-playwright/`, `playwright test` runs locally fine (only the CDN
  fetch is blocked) — the native offline-first e2e was run green locally this way (chromium + webkit),
  its dawgs from the sibling `../../scrabble-solver/dawg` via the webServer's `bundle-dicts.mjs` fallback.
- **A native (Capacitor) e2e must inject `window.androidBridge`, NOT `window.Capacitor.getPlatform`.**
  `@capacitor/core` (pulled in during boot by `initNativeShell`'s dynamic `@capacitor/app` import)
  **replaces** any pre-set `window.Capacitor` with its own shim and derives the platform from
  `window.androidBridge` (android) / `window.webkit.messageHandlers` (ios) — so a bare injected
  `Capacitor.getPlatform: () => 'android'` is clobbered to `web` and the boot falls to `/login`. Inject
  `window.androidBridge = { postMessage(){} }` in an `addInitScript` (see `e2e/native.spec.ts` `simulateNative`);
  `initNativeShell` is written to tolerate the stub bridge (`try/catch` round the `@capacitor/app` addListener).
- **`docker run -p ...` boot tests fail from the shell** (published ports unreachable in this env).
  Use **testcontainers** for container-backed tests.
- **Distroless images run as UID 65532 (nonroot).** Bind-mounted TLS keys must be **0644** (not 0600)
  or the service crash-loops on start.

## Deploy / test contour (operational)

- **The TEST contour runs on THIS dev host.** Inspect it via `docker` / Prometheus. A host-side
  `curl` to caddy **hangs** (NAT hairpin) — don't debug the edge that way.
- **The contour's client IP is the home-router SNAT address**, not the real external IP. Correct in
  prod, not a bug — which is why the IP ban / blocklist are **prod-only**.
- **The contour is one shared env, last-deploy-wins.** Keep a multi-PR batch a **linear stack** (one
  PR, one deploy) so deploys don't clobber each other.
- **A schema/wire PR breaks the contour** until a `DROP SCHEMA` + backend restart. Note such a
  prerelease step in `PRERELEASE.md`.
- **A contour DB wipe resets the account but not the client's stored locale**; the reconciler then
  syncs the stale locale, masking a fresh TG `language_code` seed. Clear client prefs too when testing
  locale.
- **`DNS=` in `TEST_AWG_CONF` pins the VPN netns to 1.1.1.1** → internal names go NXDOMAIN → the
  bot-link silently dies. Diagnose from inside the netns.
- **Swap one contour service to a local image** without deploy secrets via a busybox-in-the-netns
  socket-inspect trick (single-service recreate).
- **A rolling deploy did NOT recreate caddy on a config-only change.** Force `--force-recreate` for
  caddy; don't trust "deploy green" for an edge-config change.
- **A new gateway edge route MUST be added to the Caddyfile `@gateway` matcher** or it falls through
  to the landing catch-all. Add a CI probe for the route.
- **Prod caddy logs warnings only, no access log.** Trace a request via the backend "http request"
  telemetry log or Tempo, not caddy.
- **Confirm a release is live without SSH** by grepping the served SPA: `__APP_VERSION__` inside
  `/assets/main-*.js`.
- **"App hangs on load" was a dead HTTP/3 advert:** caddy sent `Alt-Svc: h3` with no UDP/443 open;
  clients cache it ~30 days. Fix with `Alt-Svc: clear`.
- **Config-poison deploy loop:** a crash-looping config-mounted service + a missing bind source
  produces a root-owned directory that then fails deploys. Break it by removing the root-owned dir.
- **Maintenance-window contract** (planned-deploy 503): a marker header, `/_gm` exempt, the flag spans
  the whole roll, the SPA overlay reloads on recovery. Don't break these invariants.
- **A new dictionary goes live via a `/_gm/dictionary` upload, NOT a redeploy.** In-flight games keep
  their pinned version. The **owner** does the upload.
- **Renderer deploy job flakes** on the skia-canvas GitHub binary download when its lockfile changes —
  re-run, it's not your code.

## Repo workflow

- **PR-based, zero issues.** Work is tracked via PRs + `PRERELEASE.md`. "заведи задачу" means *do it*
  / add a plan line — not file a tracker issue.
- **`tea` CLI for all Gitea ops** (PRs, secrets, variables, dispatch). `gh` does **not** work here. The
  agent **cannot self-approve** a PR but **can merge** it after the owner approves. Watch the
  stale-mergeable trap (re-check mergeability right before merging).
- **Watch every push/merge/deploy to green** with `python3 ~/.claude/bin/gitea-ci-watch.py`, launched
  **bare** under a background task. It polls run-level conclusions; its `ALL GREEN` already covers the
  gated deploy job. Pass `--no-runs 600` when the runner is busy. A **merge** is the most-forgotten
  case — watch the post-merge runs too.
- **After a merge, switch to the merged-into branch** (`development`/`master`), pull, and prune the
  local feature branch.
- **The contour deploy probe checks the backend `/readyz`**; a PR deploy builds the PR's own code; a
  wedged contour can be recovered by recreating the host container set.

## Domain semantics (not obvious from the code)

- **Account deletion must NOT delete any user messages** — including feedback / support. Interview the
  owner on every deletion point before wiring it.
- **`account.time_zone` is `NOT NULL DEFAULT 'UTC'`, seeded from a detected ±HH:MM offset.** An email
  account row is created at the **code-request** step, not at confirmation.
- **The robot has two distinct time windows:** a **sleep window** (~00:00–07:00, gates its moves and
  nudges) vs the **player away window** (turn-timeout only). "окно отсутствия" in dialogue = the
  **sleep** window.

## Production topology

- **Two prod hosts.** `main` — full stack + ACME/edge (Selectel); `tg` — Telegram bot only (vdsina).
  SSH aliases `scrabble-main-ops` / `scrabble-tg-ops`. Deploy is **manual dispatch only**, rolling +
  health-gated + auto-rollback. Hosts are provisioned by `deploy/ansible/` (inventory + vars there —
  the source of truth for IPs/roles).
- **The agent has targeted root SSH** to the hosts (and may run the prod Ansible). Surface every
  owner-side step **before** a deploy, not after.

## Feature-area pointers (state lives in the linked docs/plans, not here)

- **Monetization** — «Фишка» currency, per-platform wallets, ads. Agreements in
  `docs/PAYMENTS_DECISIONS_ru.md`; a phased plan (E0–E9). go-jet money rule above applies.
  - **YooKassa (the direct rail since v1.x) does NOT sign its webhooks.** Authenticity is the
    confirming `GET /v3/payments/{id}` — never the notification body. Two extra guards ride on the
    confirmed object: its `metadata.order_id` must name the order, and its `test` flag must match the
    shop's `IsTest` (a test-shop payment must never credit real chips). Answer 200 for anything
    durably decided, 5xx only to be redelivered (YooKassa retries 24h).
  - **Do NOT gate the notification on the sender IP** — tried, reverted. The contour sits behind a
    tunnel and sees every caller as an internal address (`10.77.0.1`), so the allowlist rejected every
    genuine notification and the credit fell back to the reconcile sweep. Same root cause as the
    IP bans being prod-only. It bought nothing anyway: the order is resolved from the notification
    metadata **before** any provider call, so a forged id costs one indexed read, and guessing a live
    order id means guessing a uuid.
  - **The console refund and the `refund.succeeded` notification race, benignly.** YooKassa fires the
    event the moment `POST /v3/refunds` returns, so the notification often records the reversal before
    the console's own write lands. Both name the same refund id, so the ledger's idempotency index
    catches the second and nothing is revoked twice — but the console must not then report "already
    refunded", which reads as "you clicked twice" for a refund that just succeeded.
  - **Never key a re-check interval off the order TTL.** The order lifetime answers "how long may a
    customer take to pay"; the re-check answers "how soon do we notice a lost callback". Tying them
    together made a failed notification cost the customer the full 30-minute TTL before the chips
    landed. `payments.ReconcileAfter` is its own constant for that reason.
  - **Subscribe to `refund.succeeded`, not just the payment events.** The YooKassa cabinet lets an
    operator refund without touching our API, so without that event the money goes back while the
    chips stay credited. The reversal engine is **full-refund-only** (it rejects any amount other than
    the order's), so a partial refund must record nothing and be logged for a human — there is no
    non-arbitrary chip cost for a part-refund. Also: a refund is only recorded in status `succeeded`;
    a `pending` one can still be canceled and the ledger is append-only.
  - **YooKassa DOES have a test mode** — a separate *test shop* (own shopId/secret, test cards, up to
    20 of them), and webhooks/receipts/refunds all work there. The whole rail is verifiable on the
    contour without real money; don't plan around "no test payments".
  - **We send NO receipt: the merchant is on НПД, which is outside 54-ФЗ**, and YooKassa does not
    serve receipts for that regime (checked with their support). Reporting goes to «Мой налог».
    The fiscal code is kept dormant behind `BACKEND_YOOKASSA_VAT_CODE` — unset = send nothing, a
    54-ФЗ rate code turns «Чеки от ЮKassa» back on (the НПД income ceiling makes that foreseeable).
    Gotcha if you ever switch it on: `payment_subject`/`payment_mode`/`vat_code` are fields *inside*
    `receipt.items[]` (54-ФЗ tags 1212/1214/1199), documented under the receipts section, not the
    payment API — easy to miss; and a malformed receipt is an API error at payment creation, so it
    breaks purchases rather than silently skipping the fiscal document.
  - **The gateway cannot import `backend/internal/*`** (separate module + Go's internal rule), so a
    security list shared by both hops either lives in `pkg/` or is enforced backend-side. The YooKassa
    sender allowlist is enforced in the backend, with the gateway forwarding the real peer IP as
    `X-Forwarded-For` — do not duplicate the CIDR list into the gateway.
  - **«Мой налог» (НПД) has NO test environment.** Every call lands on the owner's real tax
    account, so the rail is verified against an `httptest` stub plus one real purchase the owner
    makes himself. Its API (`lknpd.nalog.ru`) is reverse-engineered — no contract, no sandbox.
  - **`POST /api/v1/income` is NOT idempotent.** A timeout does not mean nothing was filed. The
    only recovery is searching the taxpayer's income list by the exact `services[].name`, so that
    name is frozen in the DB *before* the call and carries a marker from the **tail** of the order
    id. Do not "simplify" it to the head: our order ids are UUIDv7, whose leading hex digits are a
    millisecond clock — the first eight are shared by every order in the same ~65 seconds, which
    would make two purchases indistinguishable to the probe. An unresolved outcome halts the
    whole queue on purpose; a blind retry is the one thing that must never happen.
  - **The printable receipt link IS public** — `GET /api/v1/receipt/<ИНН>/<uuid>/print` opens with
    no authentication (verified on prod 2026-07-28; online sources contradict each other on this,
    so do not "fix" it back). It is the same URL the «Мой налог» app hands out under «копировать в
    буфер», which is why the buyer's receipt letter can simply carry it.
  - **The receipt's time zone decides the tax month.** The ledger is UTC; `BACKEND_MYNALOG_TZ`
    (default `Europe/Moscow`) is what a payment is filed under, so a 23:30 UTC payment on the last
    of the month belongs to the *next* one.
  - **A bank card is an электронное средство платежа**, so ст. 14 ч. 3 ФЗ-422 wants the receipt
    at settlement; the до-9-го-числа delay applies only to settlements *without* an ЭСП. Do not
    "relax" the cadence on the strength of the 9th — the automatic mode satisfies both readings,
    which is why the ambiguity never had to be settled.
  - **A bank does not auto-register income for an ИП на НПД** unless that service is explicitly
    enabled with the bank. Money landing on the account is not a чек. VK payouts are therefore a
    separate, still-manual stream (and VK is a legal entity: 6%, its INN in the receipt).
  - **Robokassa is retired but wired.** The direct rail resolves YooKassa → else Robokassa, and no
    deployment sets the Robokassa credentials. Reviving it is a credentials change, not a code change;
    `backend/internal/robokassa/README.md` holds the retired vars, cabinet URLs and the known gap
    (the per-channel `…_WEB_*`/`…_ANDROID_*` vars never reached any workflow).
  - **VK payment title trap:** a product title with an HTML-special char (`& < > " '`) silently kills
    VK purchases. VK HTML-escapes it in the `order_status_change` callback echo (`"`→`&quot;`) but
    signs a different form → the gateway logs `vk callback: bad signature` and rejects it (get_item
    passes — its request carries no title). `validateProduct` now forbids those chars; keep titles on
    « » guillemets. Diagnose a raw VK callback body by tcpdumping the plaintext caddy→gateway hop
    (`tcp port 8081`, the h2c DATA frame is unencrypted); VK retries `order_status_change` for ~30 min.
- **PITR** — pgBackRest → Selectel S3 (encrypted, 30-day), currently **gated off**; runbook in
  `deploy/README.md`. Gotcha: run pgBackRest via docker-exec **as the `postgres` user** with
  `--pg1-user=scrabble`.
- **Offline mode** — the robot brain, move generator/validator/scorer and DAWG reader are **JS ports**
  of the Go engine, bundled client-side and **parity-pinned** by golden tests. Multi-phase; native
  offline-first bundles the dictionaries.
- **VK ID web login** — raw OAuth 2.1 against `id.vk.com`, a **separate VK "Web" app** from the Mini
  App, server-side confidential code exchange.
- **Email relay** — Selectel SMTP + confirm / link / unlink / deletion codes + alerts.
- **Native Android** — Capacitor bundle model, client-version gate, offline-first, RuStore; the
  design lives in `docs/ARCHITECTURE.md` (§2 gate, §3 identity, §13 native build).
