# Функциональная спецификация Galaxy

Документ описывает, что делает платформа Galaxy в терминах пользовательских
операций и логики каждого сервиса, которая их реализует. Каждый раздел
проводит читателя по одному доменному сценарию: кто инициирует операцию,
что `gateway` проверяет и форвардит, что `backend` валидирует и сохраняет,
что возвращается клиенту, и какие побочные эффекты при этом запускаются
(почта, push, операции с контейнерами).

Это отправная точка для любого изменения, затрагивающего поведение системы.
Точные форматы протоколов, словарь кодов ошибок, переменные окружения,
значения по умолчанию, лимиты троттлинга, имена таблиц и колонок,
field-level-валидация — всё это лежит в нижнеуровневых источниках:

- [`ARCHITECTURE.md`](ARCHITECTURE.md) — глобальная архитектура,
  модель безопасности, транспортный контракт.
- `galaxy/<service>/README.md` — структура сервиса, конфигурация,
  эксплуатация.
- `galaxy/<service>/openapi.yaml`, `*.proto` — wire-контракты.
- `galaxy/<service>/docs/flows.md` — sequence-диаграммы.

Этот файл сознательно опускает такие детали. Если этот файл расходится
с нижнеуровневым источником, см. правило синхронизации в проектном
`CLAUDE.md`.

> **Внимание.** Этот файл — перевод английского
> [`FUNCTIONAL.md`](FUNCTIONAL.md) и **не является источником истины**.
> Авторитетна английская версия; при расхождении выигрывает она.
> Каждое точечное изменение в `FUNCTIONAL.md` должно быть зеркально
> внесено сюда в том же патче (переводить только затронутые абзацы).
> Полный перевод заново выполняется только по явному запросу владельца
> проекта.

Документ организован по доменным сценариям, не по группам HTTP-маршрутов.
Публичные, user-аутентифицированные и admin-операции могут оказаться в
одном разделе, если все они участвуют в одном бизнес-флоу.

## Содержание

1. [Аутентификация и устройство-сессия](#1-аутентификация-и-устройство-сессия)
2. [Управление аккаунтом](#2-управление-аккаунтом)
3. [Жизненный цикл игры в лобби](#3-жизненный-цикл-игры-в-лобби)
4. [Участие в лобби](#4-участие-в-лобби)
5. [Реестр названий рас](#5-реестр-названий-рас)
6. [Игровая сессия](#6-игровая-сессия)
7. [Канал push](#7-канал-push)
8. [Уведомления и почта](#8-уведомления-и-почта)
9. [Гео-сигнал](#9-гео-сигнал)
10. [Администрирование](#10-администрирование)
11. [Дипломатическая почта](#11-дипломатическая-почта)

---

## 1. Аутентификация и устройство-сессия

Раздел описывает, как анонимный клиент становится аутентифицированным
и остаётся таковым, пока серверное действие не отзовёт эти полномочия.

### 1.1 Состав

В составе: выпуск e-mail-вызова на вход, его подтверждение (с созданием
аккаунта при первом входе и регистрацией публичного ключа клиента),
создание устройства-сессии, поиск сессии для каждого аутентифицированного
запроса, отзыв сессии со стороны сервера.

Вне состава: формат конверта и схема подписи, которые используются
каждым аутентифицированным запросом — определены однажды в
[ARCHITECTURE.md §15](ARCHITECTURE.md#15-transport-security-model-gateway-boundary)
и переиспользуются всеми последующими разделами; хранение ключа
на стороне клиента; маршрутизация push-событий внутри gateway к
конкретному стриму подписчика.

### 1.2 Выпуск вызова на вход

Клиент отправляет e-mail на публичный auth-маршрут gateway.
Маршрут не аутентифицирован — ещё нет устройства-сессии, к которому
можно было бы привязаться.

Gateway относится к этому маршруту как к более строгому классу
"public auth": применяет per-IP и per-identity (per-email) anti-abuse,
ограничение на размер тела, allow-list HTTP-методов, после чего
форвардит запрос в backend. Сбои upstream-адаптера проецируются обратно
клиенту с тем же статусом и envelope-ошибкой; транспортные сбои —
обобщённым ответом "недоступно".

Backend выпускает непрозрачный идентификатор вызова и отправляет
письмо подтверждения через durable mail outbox. **Форма ответа
идентична независимо от того, принадлежит ли e-mail существующему
аккаунту, новому или попадает под троттлинг** — endpoint нельзя
использовать для перечисления аккаунтов.

Ветки внутри backend:

- **Permanent block.** Если адрес заблокирован на уровне аккаунта,
  запрос отклоняется. Это единственная account-state-ветка, которая
  отдаёт отдельный код ошибки; все прочие ветки возвращают стандартную
  форму с challenge-id.
- **Throttle.** Если для одного e-mail в окне троттлинга уже
  существует слишком много непогашенных и не истёкших вызовов,
  backend переиспользует последний имеющийся вызов вместо создания
  нового. Клиент получает ту же форму ответа и не знает о повторе.
- **Иначе.** Backend создаёт новый вызов с разрешённым preferred_language
  (выводится из опционального поля `locale` в JSON-теле — оно имеет
  приоритет — либо, если оно отсутствует или пустое, из заголовка
  `Accept-Language`, форварднутого gateway, с откатом на дефолт) и
  в той же транзакции ставит auth-mail-строку прямо в outbox.
  SMTP-доставка асинхронна; auth-ответ возвращается, как только
  строки challenge и outbox durably закоммитены. Поле в теле — это
  канонический канал, потому что Safari молча сбрасывает выставляемые
  из JS заголовки `Accept-Language`; клиентам не на Safari достаточно
  одного заголовка.

### 1.3 Подтверждение вызова

Клиент отправляет challenge id, код из письма, свежий публичный ключ
Ed25519 и выбранную IANA-таймзону. Gateway применяет тот же
public-auth anti-abuse-класс, но per-identity-бакет ключуется по
challenge id, а не по e-mail. `Accept-Language` на этом endpoint
не учитывается — preferred_language был зафиксирован на этапе send
и проигрывается из строки challenge.

Backend валидирует challenge под row lock: отклоняет неизвестные,
истёкшие или уже погашенные id, инкрементирует счётчик попыток и
сжигает challenge при достижении потолка. После того как код
сошёлся, backend перепроверяет permanent-block-флаг — это ловит
случай, когда админ применил блок между send и confirm — и отклоняет
запрос, если флаг выставлен. На успешном пути backend гарантирует
существование аккаунта (синтезирует неизменяемый display-handle
только при первом входе и заполняет declared_country по source IP),
после чего помечает challenge consumed и в той же транзакции создаёт
устройство-сессию, привязанную к публичному ключу вызывающего.
Ответ несёт идентификатор новой устройства-сессии.

Challenge — single-use. Повторное подтверждение того же id возвращает
ту же непрозрачную форму `invalid_request`, что и подтверждение
неизвестного или истёкшего id; API сознательно не различает эти
три случая, чтобы атакующий не мог майнить состояние challenge.
Throttle-переиспользование на стороне send означает, что клиент,
попавший под троттлинг, получит обратно последний существующий
`challenge_id` вместо свежего, но каждый id всё равно гасится ровно
один раз.

### 1.4 Поиск сессии для каждого запроса

Когда у клиента есть идентификатор устройства-сессии и приватный ключ,
каждый аутентифицированный вызов — это подписанный запрос к gateway
по аутентифицированному edge-листенеру (Connect / gRPC / gRPC-Web на
одном HTTP/h2c-порту). Gateway — единственный компонент, который видит
подпись запроса; backend доверяет вердикту gateway.

Gateway нужен публичный ключ сессии для проверки подписи, поэтому
каждый аутентифицированный запрос разрешает устройство-сессию через
in-memory LRU-кэш (с ограничением на число записей плюс TTL-страховка).
При промахе кэш зовёт endpoint backend для поиска сессии и заполняет
запись. Gateway отклоняет запрос, если кэш сообщает "сессия неизвестна"
или "отозвана"; иначе он проверяет конверт согласно
[ARCHITECTURE.md §15](ARCHITECTURE.md#15-transport-security-model-gateway-boundary)
и форвардит проверенный payload в backend по обычному REST,
инжектируя в заголовке резолвлёный user_id. Backend никогда не
выводит identity из тела запроса.

Backend обновляет `last_seen_at` в строке сессии при каждом успешном
поиске — это даёт админам видимость того, когда каждая закэшированная
сессия в последний раз резолвилась на edge. Обновление — часть
транзакции поиска; сбои логируются, но не пропагируются вызывающему.

Кэш инвалидируется через push-канал, а не через периодический
рефреш: событие `session_invalidation` переключает статус закэшированной
записи на revoked, после чего последующие запросы, привязанные к
этой сессии, отклоняются без повторного похода в backend. TTL — это
страховка на случай потерянных событий (курсор устарел, gateway
перезапустился) — в установившемся режиме push-события являются
авторитетным источником инвалидации.

### 1.5 Отзыв

Отзыв делает устройство-сессию неспособной аутентифицировать любой
будущий запрос и принуждает закрыться все push-стримы, привязанные
к ней. Триггеры разделяются на две группы.

**Инициированный пользователем (logout).** User-surface предоставляет
три операции: получить свои активные сессии, отозвать одну и отозвать
все. Gateway форвардит их в backend как обычные аутентифицированные
запросы. Backend проверяет, что целевая сессия принадлежит
вызывающему (иначе возвращает ту же форму, что и отсутствующая
сессия — чужие session id не могут быть зондированы), атомарно
переключает `device_sessions.status` на `revoked` и вставляет строку
в `session_revocations`, после чего публикует одно
`session_invalidation`-событие на каждую отозванную сессию.

**Инициированный админом и lifecycle.** Санкции, подразумевающие
отзыв сессий (сейчас — `permanent_block`), admin-инициированный
soft-delete и пользовательский self-soft-delete — все они приводят
к in-process-вызову внутри backend. Действуют те же атомарные
UPDATE + audit-insert + push-эмиссия; audit-строка несёт другой
`actor_kind`
(`admin_sanction` / `soft_delete_admin` / `soft_delete_user`).

Когда backend опубликовал push-событие, gateway переключает
закэшированную запись сессии на revoked и закрывает все активные
push-стримы, привязанные к ней. Per-request internal-поиск против
backend остаётся durable-страховкой: если push-событие потеряно,
следующий поиск (после истечения TTL кэша) вернёт уже отозванную
запись.

`session_revocations` — это аудит-журнал. Каждая строка несёт
`revocation_id`, `device_session_id`, `user_id`, `actor_kind`, пару
полей актора (`actor_user_id` для user-driven kind'ов,
`actor_username` для admin-driven kind'ов — ровно одно из двух
заполнено в каждой строке), `reason` и `revoked_at`. Операторы могут
запрашивать её, чтобы ответить "кто и почему отозвал эту сессию";
таблица append-only.

Endpoint `/api/v1/internal/sessions/{id}` в backend — read-only:
он несёт per-request session lookup, который gateway использует
для проверки подписанных конвертов. Internal revoke-endpoints
больше не существуют; revoke инициируется либо пользователем
(через user-surface), либо админом (через in-process-вызов внутри
backend).

### 1.6 Перекрёстные ссылки

- Wire-конверт, подпись, окно свежести, anti-replay:
  [ARCHITECTURE.md §15](ARCHITECTURE.md#15-transport-security-model-gateway-boundary).
- Зоны ответственности backend-модулей `auth`, `user`, `geo`, `mail`,
  `push`: [ARCHITECTURE.md §4](ARCHITECTURE.md#4-backend-domain-modules)
  и `backend/README.md`.
- Семантика mail outbox для шаблона auth login-code:
  [ARCHITECTURE.md §11](ARCHITECTURE.md#11-mail-outbox).
- Фрейминг push-канала и правила переподключения:
  [ARCHITECTURE.md §8](ARCHITECTURE.md#8-backend--gateway-communication).
  Пользовательская семантика push — в
  [Разделе 7](#7-канал-push) этого документа.

---

## 2. Управление аккаунтом

Раздел описывает, что аутентифицированный пользователь может читать
или менять в своём аккаунте и как удалить аккаунт.

### 2.1 Состав

В составе: чтение агрегата аккаунта, обновление мутабельного слайса
профиля, обновление настроек (preferred_language, time_zone,
declared_country), пользовательский soft-delete.

Вне состава: admin-side-мутации того же аккаунта (санкции, лимиты,
изменения entitlement, admin-soft-delete) — описаны в
[Разделе 10](#10-администрирование). Переключение permanent_block —
только для админов.

### 2.2 Агрегат аккаунта

Backend предоставляет один read-endpoint, который возвращает агрегат
аккаунта вызывающего: durable-идентифицирующие поля (неизменяемый
display-handle, e-mail), мутабельные слайсы profile и settings, текущий
снимок entitlement, любые активные санкции и per-user-overrides
лимитов. Агрегат — авторитетный клиентский взгляд "что платформа
обо мне знает".

Display-handle синтезируется при первом входе
([Раздел 1.3](#13-подтверждение-вызова)) и никогда не перезаписывается
ни при последующих входах, ни при апдейтах профиля. Клиенты должны
относиться к нему как к стабильному идентификатору, а не как к
display-предпочтению.

### 2.3 Обновление профиля и настроек

Два различных мутирующих endpoint'а разделяют user-управляемые поля
по природе изменения. Оба следуют PATCH-семантике — отсутствующие
поля не трогаются, присутствующие заменяют сохранённое значение —
и оба возвращают обновлённый агрегат.

Profile несёт одно display-ориентированное поле: `display_name`.
Явно пустое значение очищает сохранённое имя; пропуск поля
оставляет его нетронутым.

Settings несёт locale- и timezone-предпочтения:
`preferred_language` (BCP 47-тег) и `time_zone` (IANA-идентификатор).
Оба должны быть непустыми после trim, если они присутствуют;
timezone валидируется по IANA-базе перед коммитом.

`declared_country` **не** входит ни в один из patch'ей. Backend
пишет его один раз при регистрации из source IP
([Раздел 9](#9-гео-сигнал)) и считает неизменяемым после; нет
user-видимого пути его изменить.

### 2.4 Удаление аккаунта пользователем

Пользователь может попросить backend soft-delete'нуть свой аккаунт.
Backend помечает строку аккаунта удалённой и запускает in-process-каскад,
описанный в [ARCHITECTURE.md §7](ARCHITECTURE.md#7-in-process-async-patterns).
Конкретно:

- Каждая устройство-сессия пользователя отзывается
  ([Раздел 1.5](#15-отзыв)) — одна audit-строка на сессию и одно
  `session_invalidation`-push-событие на сессию.
- Активные membership'ы переходят в `removed` (admin-инициированный
  блок переключает их в `blocked`); pending-заявки переходят
  в `rejected`; входящие приглашения — в `declined`; исходящие
  приглашения — в `revoked`.
- Race-name-записи, принадлежащие пользователю — registered,
  reservation или pending_registration — удаляются одной cascade-
  записью.
- Owned-игры в не-running-статусах (`draft`, `enrollment_open`,
  `ready_to_start`, `start_failed`, `paused`) отменяются. Owned-игры
  уже в `running` каскадом **не** отменяются — engine-контейнер
  продолжает выпускать ходы, пока не завершится естественно;
  только membership-cleanup отвязывает пользователя.
- Один `lobby.membership.removed`-веер уведомлений уходит
  пользователю с `reason=removed` (или `reason=blocked` для
  admin-block-пути).

Endpoint не возвращает тела. Каскад best-effort внутри одного
процесса: если downstream-модуль падает, ошибка логируется,
но аккаунт остаётся помеченным удалённым.

### 2.5 Перекрёстные ссылки

- Admin-аналоги (sanction, limit, entitlement, soft delete):
  [Раздел 10](#10-администрирование).
- Контракт каскада "user blocked / user deleted":
  [ARCHITECTURE.md §7](ARCHITECTURE.md#7-in-process-async-patterns).
- Виды уведомлений, эмитящихся каскадом:
  [`backend/README.md` §10](../backend/README.md#10-notification-catalog).

---

## 3. Жизненный цикл игры в лобби

Раздел описывает жизнь одной игры от создания до терминального
состояния. [Раздел 4](#4-участие-в-лобби) описывает, как игроки
присоединяются к существующей игре; этот раздел сосредоточен на
самой игре.

### 3.1 Состав

В составе: создание игры (private или public), обновление её
мутабельной конфигурации, переходы по машине состояний лобби,
отмена, повтор failed-старта, терминальные переходы (`finished`,
`cancelled`).

Вне состава: заявки, приглашения, membership'ы
([Раздел 4](#4-участие-в-лобби)), Race Name Directory-промоушен
при завершении ([Раздел 5](#5-реестр-названий-рас)), engine-команды
во время running-фазы ([Раздел 6](#6-игровая-сессия)).

### 3.2 Машина состояний

Машина состояний лобби — закрытый граф, описанный в
[ARCHITECTURE.md §7](ARCHITECTURE.md#7-in-process-async-patterns):

```text
draft → enrollment_open → ready_to_start → starting → running ↔ paused → finished
                                                       ↳ start_failed → ready_to_start (retry)
cancelled достижим из любого pre-finished-состояния.
```

Два базовых правила:

- **Тип владения определяет surface.** Privatе-игры несут
  `owner_user_id`; переходы инициирует владелец через user-surface.
  Публичные игры — collective ownership админов
  (`owner_user_id IS NULL`); их переходы и изменения конфигурации
  идут через admin-surface.
- **Runtime-callback владеет одним переходом.** `starting → running`
  и `starting → start_failed` — единственные переходы, которые
  производит runtime-модуль, после того как engine-контейнер
  полностью поднялся или подтвердил сбой. Каждый прочий переход —
  user- или admin-действие.

### 3.3 Создание

Пользователь создаёт private-игру через user-surface. Backend
записывает новую игру с `owner_user_id`, равным вызывающему,
visibility `private`, в состоянии `draft`, с конфигурацией из
тела запроса в качестве начальных значений.

User-surface гейтится платным тарифом вызывающего. Backend читает
`EntitlementProvider.IsPaid(userID)` перед вызовом lobby-сервиса;
free-tier-вызовы отклоняются с HTTP `403 forbidden`
(канонический код ошибки `forbidden`), и `draft`-запись не
создаётся. Соответствующие UI-аффордансы — подраздел
`private games` в сайдбаре и кнопка `create new game` внутри него —
скрыты в lobby-shell для free-tier-сессий; build-флаг
`VITE_GALAXY_DEV_AFFORDANCES` переопределяет UI-гейт, чтобы owner
мог в DEV-сборке проверять обе ветки с одного тестового аккаунта.
Admin-создание public-игр ([Раздел 10](#10-администрирование))
обходит тир-гейт.

Public-игры создаются исключительно через admin-surface
([Раздел 10](#10-администрирование)). User-surface никогда не
производит public-игру; асимметрия enforced в backend, не на
уровне маршрута.

### 3.4 Прямые переходы

Владельцы инициируют прямые переходы через специальные endpoint'ы
(`open-enrollment`, `ready-to-start`, `start`, `pause`, `resume`,
`retry-start`). Каждый endpoint:

- проверяет владение игрой (или admin-scope для public-игр);
- проверяет, что исходное состояние совпадает с предусловием
  перехода, отклоняя с conflict иначе;
- обновляет lobby-запись и публикует все user-видимые уведомления,
  привязанные к переходу.

`start` ставит в очередь runtime-job (длинный pull / start /
init контейнера) и сразу возвращает "queued". Финальное движение
состояния (`starting → running` или `starting → start_failed`)
приходит позже через runtime-callback. `retry-start` возвращает
`start_failed`-игру в `ready_to_start` и позволяет владельцу
снова дёрнуть `start`.

`pause` и `resume` переключают между `running` и `paused`. Запущенный
engine-контейнер не сносится при pause; меняются только lobby-расписание
и флаги приёма команд.

`ready-to-start` всегда — explicit-действие владельца (или админа),
никогда не auto-fired. Переход проверяет, что число одобренных
участников не меньше `min_players`, и иначе отклоняет с conflict.

### 3.5 Отмена и завершение

`cancel` достижим из любого pre-finished-состояния. Владельцы
могут отменять свои игры; админы — любые. Отмена примиряет
оставшиеся заявки, приглашения и membership'ы; она не повышает
race-name-резервации.

`finished` производится внутри backend, после того как engine
сообщает о завершении игры. Переход сносит engine-контейнер,
замораживает lobby-запись и триггерит Race Name Directory-промоушен
для capable-finishes ([Раздел 5](#5-реестр-названий-рас)). Оба
терминальных состояния поглощающие.

### 3.6 Админские оверрайды

Администраторы могут делать `force-start`, `force-stop` и
`ban-member` на любой игре (public или private), независимо от
состояния. `force-stop` переводит игру в stopped-состояние и
сносит engine-контейнер; `ban-member` удаляет membership и
запрещает пользователю снова присоединиться
([Раздел 4](#4-участие-в-лобби)).

### 3.7 Перекрёстные ссылки

- Словарь машины состояний и правила переходов:
  [ARCHITECTURE.md §7](ARCHITECTURE.md#7-in-process-async-patterns).
- Жизненный цикл runtime-job (асинхронная работа за `start`):
  [ARCHITECTURE.md §13](ARCHITECTURE.md#13-container-lifecycle-in-process)
  и `backend/docs/flows.md`.
- Public-vs-private-инварианты и поддерживающий их частичный индекс:
  [ARCHITECTURE.md §4](ARCHITECTURE.md#4-backend-domain-modules).

---

## 4. Участие в лобби

Раздел описывает всё, что связано с присоединением и выходом из
существующей игры: заявки (для public), приглашения (для private)
и membership'ы (после успешного присоединения).

### 4.1 Состав

В составе: подача заявки в public-игру, одобрение / отклонение
заявки владельцем или админом, выпуск и активация приглашений,
отказ получателя и отзыв выпустившим, листинг membership'ов
для игры, удаление или блокировка участника.

Вне состава: сама машина состояний игры
([Раздел 3](#3-жизненный-цикл-игры-в-лобби)) и in-game-команды,
когда участник уже играет ([Раздел 6](#6-игровая-сессия)).

### 4.2 Заявки (public-игры)

Пользователь подаёт заявку в игру по id. Заявки **принимаются
только в public-играх**; попытка против private-игры отклоняется
с conflict. Игра должна дополнительно быть в `enrollment_open`
(единственное enrolment-принимающее состояние для заявок).
Backend также отклоняет запрос, если пользователь уже member или
в block-листе игры (через `ban-member`). Иначе сохраняет заявку
как `pending` и эмитит уведомление в admin-канал.

Владелец — или администратор для public-игр — одобряет или
отклоняет заявку через специальные endpoint'ы. Одобрение создаёт
membership для подающего и эмитит соответствующее уведомление.
Отклонение просто записывает терминальное состояние; membership
не появляется.

### 4.3 Приглашения (private-игры)

Приглашения **принимаются только в private-играх**; попытка
выпустить для public-игры отклоняется с conflict. Владелец
выпускает приглашение, пока игра в `draft`, `enrollment_open`
или `ready_to_start`.

Сосуществуют две разновидности:

- **User-bound** — установлен `invited_user_id`; погасить может
  только этот пользователь. Эмитится уведомление
  `lobby.invite.received` получателю.
- **Code-based** — `invited_user_id` пуст; backend минтит hex-код
  при выпуске, и любой вызывающий, знающий код, может погасить.
  При выпуске уведомление не эмитится (получатель ещё не привязан).

Каждое приглашение несёт срок действия (по умолчанию из
конфигурации, если тело пропускает `expires_at`). Получатель
гасит (создаёт membership) или отклоняет; выпустивший может
отозвать выданное приглашение в любое время до погашения.

### 4.4 Membership'ы

Membership'ы перечисляют игроков, прикреплённых к игре. Владельцы
могут удалить или заблокировать члена; член может также удалить
сам себя. Удаление чисто прекращает участие; блок дополнительно
запрещает тому же пользователю снова подаваться или гасить
будущее приглашение для той же игры.

Admin-surface предоставляет `ban-member` как cross-game-policy-
аналог owner-блока.

### 4.5 Личные списки

User-surface предоставляет три "my"-листинга (games, applications,
invites). Они проецируют участие вызывающего по всем играм без
необходимости заранее знать game-id'ы — это даёт возможность для
dashboard- и inbox-вью.

### 4.6 Уведомления

Каждое изменение состояния в этом разделе эмитит уведомление из
каталога: `lobby.invite.received`, `lobby.invite.revoked`,
`lobby.application.submitted`, `lobby.application.approved`,
`lobby.application.rejected`, `lobby.membership.removed`,
`lobby.membership.blocked`. [Раздел 8](#8-уведомления-и-почта)
описывает веер.

### 4.7 Перекрёстные ссылки

- Жизненный цикл игры: [Раздел 3](#3-жизненный-цикл-игры-в-лобби).
- Каталог уведомлений и веер: [Раздел 8](#8-уведомления-и-почта)
  и [`backend/README.md` §10](../backend/README.md#10-notification-catalog).

---

## 5. Реестр названий рас

Раздел описывает, как игрок выбирает имя своей in-game-расы и в
итоге получает это имя зарегистрированным платформенно.

### 5.1 Состав

В составе: трёхуровневый реестр (registered, reservation,
pending_registration), промоушен через "capable finish",
пользовательский промоушен pending_registration в registered,
sweeper-релиз по истечению TTL, уникальность через canonical-key-
модель.

Вне состава: как движок реально потребляет выбранное имя — это
живёт в [Разделе 6](#6-игровая-сессия).

### 5.2 Три уровня

- **Registered** — platform-unique. У одного canonical-key —
  не более одного живого binding к одному пользователю.
- **Reservation** — per-game. Один и тот же canonical-key может
  быть зарезервирован одним и тем же пользователем в нескольких
  активных играх одновременно, но два разных пользователя не
  могут зарезервировать один canonical-key в одной игре.
- **Pending registration** — переходный уровень между reservation
  и registered. Выпускается автоматически после "capable finish"
  (игра завершилась с тем, что игрок вырастил начальные значения
  планет и популяции) и даёт пользователю окно времени, чтобы
  превратить reservation в постоянную registered-запись.

### 5.3 Канонизация

Каждое имя (введённое пользователем или зарегистрированное
платформой) сворачивается в canonical key. Канонизация confusable-
aware (latin-cyrillic-look-alikes, цифро-буквенные подмены) и
применяется единообразно по реестру; уникальность enforced
по canonical-key, не по отображаемому имени. Cross-tier-конфликты
по одному и тому же canonical-key блокируются на write через
per-canonical advisory lock.

### 5.4 Путь продвижения

Reservation появляется, когда игрок именует свою расу во время
игры. Когда игра capable-завершается, backend автоматически
конвертирует reservation в pending_registration с TTL. Пока
pending-запись жива, пользователь может вызвать registration-
endpoint, чтобы продвинуть запись в `registered`. Если TTL
истёк раньше, периодический sweeper освобождает запись;
canonical-key снова доступен.

Pending registration может claim'нуть только пользователь,
который её заработал; backend отклоняет попытку другого
пользователя, даже если canonical-key совпадает.

### 5.5 Уведомления

Реестр эмитит `lobby.race_name.registered`,
`lobby.race_name.pending` и `lobby.race_name.expired`
владеющему пользователю. [Раздел 8](#8-уведомления-и-почта)
описывает веер.

### 5.6 Перекрёстные ссылки

- Библиотека канонизации и записи глоссария
  ("canonical key", "capable finish"):
  [ARCHITECTURE.md §19](ARCHITECTURE.md#19-glossary).
- Триггер промоушена внутри lobby-модуля:
  [ARCHITECTURE.md §7](ARCHITECTURE.md#7-in-process-async-patterns)
  (`lobby.OnGameFinished`) и `backend/docs/flows.md`.

---

## 6. Игровая сессия

Раздел описывает, что делает активный игрок, пока идёт игра:
посылает команды и приказы, читает отчёты по ходам.

### 6.1 Состав

В составе: подача команд, подача приказов, чтение отчёта,
turn-cutoff-поведение, которое закрывает окно команд во время
генерации.

Вне состава: как сам engine-контейнер запускается, планируется
или останавливается — это runtime-вопросы, описанные в
[Разделе 3](#3-жизненный-цикл-игры-в-лобби) (start / stop) и
[Разделе 10](#10-администрирование) (admin-runtime-оверрайды).
Wire-формат команд, приказов и отчётов — собственный контракт
движка, здесь не дублируется.

### 6.2 Роль backend: pass-through с авторизацией

Подписанный конвейер аутентифицированного edge для in-game-трафика
использует три message types на аутентифицированной поверхности —
`user.games.order`, `user.games.order.get`, `user.games.report` —
у каждого типизированный FlatBuffers-payload.
Gateway транскодирует FB-запрос в JSON-форму, которую ждёт backend,
форвардит её REST'ом в соответствующий
`/api/v1/user/games/{game_id}/*` endpoint, после чего транскодирует
JSON-ответ обратно в FB перед подписью.
`user.games.order.get` — read-back-компаньон для `user.games.order`:
клиент использует его, чтобы восстановить локальный черновик приказа
после потери кэша (свежая установка, очищенное хранилище, новое
устройство).

Для каждого in-game-endpoint user-surface работает как
авторизующий pass-through к engine-контейнеру. Backend:

- проверяет, что вызывающий — активный member целевой игры
  и что игра в состоянии, принимающем операцию;
- ребиндит поле `actor` в теле на race-name вызывающего из
  runtime-player-mapping (клиент не несёт доверенный actor);
- резолвит engine-endpoint (запущенный контейнер для `game_id`)
  и форвардит вызов;
- возвращает payload-ответа движка клиенту без переинтерпретации.

Backend не парсит содержимое payload команд или приказов сверх
того, что требует авторизация. Движок — источник истины
о валидности и порядке in-game-решений. Gateway знает типизированную
FB-форму только чтобы транскодировать wire-формат; per-command-
семантика живёт в движке.

Специально для `user.games.order` движок валидирует каждую команду
приказа на транзиентном слепке текущего состояния игры и записывает
итог по каждой команде в её мету (`cmdApplied`, `cmdErrorCode`) в
том же ответе `UserGamesOrder`. Приказ сохраняется с этими
per-command-вердиктами даже если часть команд была отклонена —
например, удаление команды «создать класс корабля X» из приказа,
в котором остаётся «строить X», приводит к тому, что вторая команда
возвращается с `cmdErrorCode` «сущность не существует», а остальные
команды приказа остаются сохранёнными, и ответ остаётся
`202 Accepted`. `400` возвращается только для структурных отказов
на уровне приказа (`quit` не последняя команда, неизвестный
command type, малформированный вход); `500` — только для реальных
внутренних сбоев движка.

### 6.3 Окно хода и auto-pause

Запущенная игра постоянно чередуется между окном приёма команд
и фазой генерации, управляемой cron-выражением из
`runtime_records.turn_schedule`. Backend-планировщик
(`backend/internal/runtime/scheduler.go`) оборачивает каждый
engine `/admin/turn` двумя `runtime_status`-флипами:

- Перед engine-вызовом: `running → generation_in_progress`.
  User-games-handler'ы команд/приказов
  (`backend/internal/server/handlers_user_games.go`) на каждом
  запросе сверяются с per-game runtime-записью и отклоняют с
  HTTP 409 + `code = turn_already_closed`, пока runtime в
  `generation_in_progress`. Тело ошибки — стандартный
  `httperr`-конверт: `{"error": {"code": "turn_already_closed",
  "message": "..."}}`.
- После успешного тика: `generation_in_progress → running`.
  Окно приказов открывается на новый ход, следующий тик идёт
  как обычно.
- После провалившегося тика (`engine_unreachable` /
  `generation_failed`): `lobby.OnRuntimeSnapshot` переводит игру
  `running → paused` и публикует push-эвент `game.paused`
  (см. §6.6). Order-handler'ы отклоняют запросы с HTTP 409 +
  `code = game_paused`, пока админ не выполнит resume.

`force-next-turn` (admin) планирует one-shot-доп-тик, который
сдвигает следующий запланированный ход на один cron-шаг; те же
правила status-flip и отклонения применимы.

Клиенты различают два варианта отказа по `code`:
`turn_already_closed` — «дождись следующего `game.turn.ready` и
отправь ещё раз», `game_paused` — «дождись resume администратором».
Web-клиент реализует оба сценария согласно
`ui/docs/sync-protocol.md`.

### 6.4 Отчёты

Per-turn-отчёты — read-only-вью, забираемые из движка по запросу.
Backend авторизует вызывающего и форвардит запрос; в этом пути
нет ни кэширования, ни денормализации.

Web-клиент рендерит отчёт как одну секцию на каждый FBS-массив
(общие сведения, скоро покидающие игру расы, голоса, статус
игроков, мои / чужие науки, мои / чужие классы кораблей, сражения,
бомбардировки, приближающиеся группы, мои / чужие / необитаемые /
неопознанные планеты, корабли в производстве, грузовые маршруты,
мои флоты, мои / чужие / неопознанные группы кораблей). Пустые
секции получают явную копию empty-state; исключение — секция
«скоро покидающие игру расы»: она полностью скрывается, когда ни
одна раса не близка к исключению. Если же близка к исключению за
неактивность сама локальная раса (осталось не более пяти ходов),
над списком секций показывается персональный
баннер-предупреждение (стиль danger) с числом оставшихся ходов;
публичная секция «скоро покидающие игру расы» перечисляет все
прочие расы, до исключения которых осталось не более трёх ходов.
Навигация по секциям — sticky icon-popup в правом
верхнем углу колонки отчёта (анкорный popover на десктопе и фикс.
bottom-sheet на мобильном); подпись на кнопке отслеживает раздел,
который сейчас в зоне видимости, выбор пункта меню — скролл к
нужной секции. При возврате в активный вью отчёт перемонтируется,
позиция скролла сбрасывается к началу, а IntersectionObserver
заново рассчитывает подсветку при прокрутке.

Секция бомбардировок — это плоская read-only-таблица: одна строка на
событие, колонки `attacker`, `attack_power`, признак `wiped` и
ресурсный снимок после удара. Секция сражений — список ссылок в
Battle Viewer (см. [§6.5](#65-battle-viewer)).

### 6.5 Battle viewer

Battle Viewer — отдельное представление, заменяющее карту и
показывающее одну битву. Входы:

- Строка в секции «сражения» в Reports (ссылка с пиннингом
  текущего хода через `?turn=`).
- Battle-marker на карте (жёлтый крест через противоположные углы
  квадрата, описанного вокруг круга планеты; толщина линий растёт
  с длиной протокола).

Сам Viewer — логически изолированный компонент, потребляющий
`BattleReport` в форме `pkg/model/report/battle.go`. Страница-обёртка
(`ui/frontend/src/lib/active-view/battle.svelte`) забирает отчёт
подписанной ConnectRPC-командой `user.games.battle` на
аутентифицированном edge: gateway переводит верифицированный
envelope в `GET /api/v1/user/games/{game_id}/battles/{turn}/{battle_id}`
к backend-у, тот в свою очередь проксирует engine-эндпоинт
`GET /api/v1/battle/:turn/:uuid`. Для synthetic-игр загрузчик
короткозамыкает запрос на in-memory карту фикстур, наполненную из
synthetic-report envelope (см. ниже), и не обращается к gateway.

Визуальная модель — радиальная: планета в центре, расы по внешней
окружности на равных угловых интервалах, внутри расы — облако
кружков по классам кораблей, выложенное Vogel-спиралью с биасом к
планете (самая многочисленная группа по NumberLeft — ближе к
планете, остальные раскручиваются спиралью позади). Tech-варианты
одного `(race, className)` схлопываются в один визуальный нод
`<className>:<numLeft>`; детали по тех-уровням остаются в Reports.
Радиус кружка масштабируется по FullMass корабля (диапазон
`[6, 24] px`, нормировка на самую тяжёлую группу в битве), так что
тяжёлые корабли визуально доминируют. Наблюдатели (`inBattle:
false`) не рисуются. Выбывшие расы убираются из сцены, оставшиеся
перераспределяются на следующем кадре. Viewer закреплён по высоте
viewport-а: сцена растягивается, лог скроллит внутри — никаких
скроллов на уровне страницы.

Каждый кадр — одна запись протокола; выстрел рисуется тонкой линией
от атакующего к защитнику, красной при `destroyed`, зелёной иначе.
Непрерывное воспроизведение: 1x / 2x / 4x (400 / 200 / 100 мс на
кадр), плюс play/pause, шаг вперёд/назад, rewind. Текстовый протокол
доступности под сценой дублирует те же события построчно.

Бомбардировки и сражения умышленно не смешиваются: бомбардировки
остаются статической таблицей в Reports; bombing-marker на карте —
тонкая окружность вокруг планеты (жёлтая при damaged, красная при
wiped), клик скроллит соответствующую строку в Reports.

Текущая wire-форма отчёта несёт `battle: [{ id, planet, shots }]`
на каждую битву, чтобы map-маркеры могли расположиться без
дополнительного запроса полного `BattleReport`.

Для DEV / e2e легаси-CLI
(`tools/local-dev/legacy-report/cmd/legacy-report-to-json`) выдаёт
envelope `{version: 1, report, battles}`, где `battles` несёт полные
`BattleReport`-ы, распарсенные из `Battle at (#N)`-блоков. Synthetic-
загрузчик в лобби разбирает envelope и регистрирует каждую битву
через `registerSyntheticBattle`, так что Battle Viewer открывает
любой UUID без сетевого запроса.

### 6.6 Побочные эффекты

Успешная генерация хода публикует runtime-snapshot в lobby-модуль,
который обновляет денормализованное вью (текущий ход, runtime-
status, per-player-stats). Engine-отчёт "game finished" гонит
переход `running → finished`
([Раздел 3.5](#35-отмена-и-завершение)) и триггерит Race Name
Directory-промоушен ([Раздел 5](#5-реестр-названий-рас)).

Из `game.*`-видов уведомлений подключены `game.turn.ready` и
`game.paused`:

- `game.turn.ready` —
  `lobby.Service.OnRuntimeSnapshot` (`backend/internal/lobby/runtime_hooks.go`)
  выпускает один intent на каждое увеличение `current_turn`,
  адресуя его всем активным membership-ам игры, с
  idempotency-ключом `turn-ready:<game_id>:<turn>` и
  JSON-payload-ом `{game_id, turn}`.
- `game.paused` — тот же хук публикует один intent на каждое
  выставление статуса `paused` по runtime-снапшоту
  (`engine_unreachable` / `generation_failed`), адресуя его всем
  активным membership-ам игры, с idempotency-ключом
  `paused:<game_id>:<turn>` и JSON-payload-ом
  `{game_id, turn, reason}`. `reason` несёт runtime-статус,
  спровоцировавший переход, чтобы UI смог в будущем
  дифференцировать копию.

Оба вида направляются только в push-канал; email-фан-аут
сознательно опущен, чтобы избежать спама на каждом ходе/паузе.

Остальные `game.*`-виды (`game.started`, `game.generation.failed`,
`game.finished`) и `mail.dead_lettered` зарезервированы без поставщика;
добавление поставщика чисто аддитивное (зарегистрировать вид в
каталоге, расширить `CHECK`-констрейнт миграции и вызвать
`notification.Submit` из подходящего доменного модуля).

### 6.7 Управление видимостью карты

Карта (`ui/frontend/src/lib/active-view/map.svelte`) несёт
попап-«шестерёнку» (`map-toggles.svelte`) в правом верхнем углу
канваса — посекторный интерфейс видимости для конкретной игры.
Попап содержит три группы элементов; любое изменение применяется
в пределах одного кадра (без перемонтирования Pixi):

- **Объекты** — шесть независимых чекбоксов: группы в
  гиперпространстве, входящие группы, неопознанные группы,
  грузовые маршруты, метки сражений, метки бомбардировок.
- **Планеты** — четыре строки: чужие / необитаемые /
  неопознанные виды планет плюс выключатель «показывать
  недостижимые планеты», который при выключении прячет каждую
  не-LOCAL планету, отстоящую дальше
  `FlightDistance(localPlayerDrive)` от любой LOCAL-планеты
  (метрика учитывает торическую развёртку).
- **Вид** — переключатель «видимое гиперпространство» (лёгкая
  заливка, подобранная под фон карты активной темы, вне
  объединения окружностей
  `VisibilityDistance(localPlayerDrive)` вокруг LOCAL-планет;
  LOCAL-планеты всегда вне фильтра — тоггл назван по видимой
  области карты, а не по затемнённой). Рендерер всегда работает
  в торическом режиме; прежняя радиогруппа «торус / без
  переноса» была удалена в полишинге F8 (issue #48 п.8),
  поскольку топология карты — серверная сущность, а не
  per-session UI-настройка. Код-путь без переноса в рендерере
  оставлен на день, когда движок выставит режим bounded plane.

LOCAL-планеты отрисовываются всегда — для них тоггла нет.
Остальные тогглы по умолчанию включены. Скрытие планеты
каскадно прячет все привязанные к ней примитивы: метки сражений
и бомбардировок на этой планете, точки in-space и incoming
ship-групп вместе с траекториями, летящих *к* этой планете, и
грузовые стрелки, чей источник или назначение — эта планета.
Каскад не оставляет на карте «осиротевших» меток, указывающих
в пустоту.

Состояние видимости сохраняется по игре в namespace кеша
`game-map-toggles/{gameId}` (см.
[`ui/docs/storage.md`](../ui/docs/storage.md)). При каждом
новом серверном ходе, ставшем текущим — либо через `setGame`,
обнаруживший `currentTurn` сервера выше последнего сохранённого
`lastResetTurn`, либо при явном клике пользователя по
аффордансу «новый ход» — все тогглы принудительно
сбрасываются в дефолт, чтобы новое содержимое хода не оказалось
скрытым устаревшими настройками. Навигация по истории
(`viewTurn`) общее состояние тогглов не сбрасывает.

### 6.8 Перекрёстные ссылки

- Backend ↔ engine wire-контракт (`pkg/model/{order,report,rest}`):
  [ARCHITECTURE.md §9](ARCHITECTURE.md#9-backend--game-engine-communication).
- Жизненный цикл контейнера, дисциплина меток, согласование:
  [ARCHITECTURE.md §13](ARCHITECTURE.md#13-container-lifecycle-in-process)
  и `backend/docs/flows.md`.

---

## 7. Канал push

Раздел описывает, как платформа пушит real-time-события
аутентифицированным клиентам (turn-ready-сигналы, изменения
состояния лобби, инвалидации сессий).

### 7.1 Состав

В составе: server-streaming-подписка, которую клиент открывает к
gateway (Connect / gRPC / gRPC-Web фреймы все маршрутизируются на
одну точку), bootstrap-событие, фрейминг форварднутых событий,
control-канал backend → gateway, который производит эти события.

Вне состава: каталог видов событий — см.
[Раздел 8](#8-уведомления-и-почта) для notification-стороны и
[`backend/README.md` §10](../backend/README.md#10-notification-catalog)
для закрытого списка.

### 7.2 Подписка клиента

Аутентифицированный клиент открывает server-streaming-вызов
`SubscribeEvents` на gateway. Gateway проводит ту же envelope-
проверку, что и для unary-запросов
([Раздел 1.4](#14-поиск-сессии-для-каждого-запроса)), затем
регистрирует стрим в своём внутреннем хабе. Первый фрейм,
получаемый клиентом — это gateway-подписанное bootstrap-событие
с текущим серверным временем, чтобы клиент мог калибровать свои
локальные часы без отдельного запроса.

Пока стрим открыт, gateway отслеживает таймер тишины; если за
`GATEWAY_PUSH_HEARTBEAT_INTERVAL` (по умолчанию `15s`, `0s`
отключает) не пришло ни одного реального события, gateway
отправляет неподписанное `gateway.heartbeat`-событие, чтобы
браузерные fetch-streaming слои не закрыли response body как idle.
Реальные события сбрасывают таймер, поэтому на нагруженных
стримах heartbeat срабатывает редко. UI-клиент короткозамыкает
heartbeat-тип до верификации подписи и никогда не дотягивает его
до handlers — см.
[`docs/ARCHITECTURE.md` § 15](ARCHITECTURE.md#15-transport-security-model-gateway-boundary)
для расчёта траффика и обоснования отсутствия подписи у heartbeat.

### 7.3 Управление backend → gateway

Backend хостит единственный gRPC-сервис `Push.SubscribePush`,
потребляемый gateway. На каждую client-id gateway одновременно
существует ровно одна логическая подписка; переподключение с тем
же id заменяет старую подписку. Каждый фрейм в стриме несёт
монотонный курсор и одну из двух форм payload:

- **Client event.** Типизированный payload, адресованный одному
  пользователю (и опционально одной устройства-сессии). Поставщики
  передают в `push.Service` объект `push.Event` (Kind + Marshal),
  сервис сам вызывает Marshal и кладёт байты в
  `pushv1.ClientEvent.Payload`. Gateway форвардит байты внутри
  подписанного клиентского конверта без переинтерпретации.
  Поставщики прикрепляют correlation-id'ы, которые gateway
  пробрасывает as-is. Новые виды событий поставляются с
  FlatBuffers-реализацией Event; виды, ещё не мигрировавшие,
  используют fallback `push.JSONEvent`, чтобы pipeline продолжал
  отправлять их без задержек на миграцию.
- **Session invalidation.** Говорит gateway сбросить активные
  стримы и отклонить in-flight-запросы для затронутых сессий —
  путь распространения отзыва, описанный в
  [Разделе 1.5](#15-отзыв).

### 7.4 Надёжность и переподключение

Backend держит in-memory ring-буфер недавних событий. При
переподключении gateway шлёт последний потреблённый курсор;
backend возобновляет с следующего события, если курсор всё ещё
внутри freshness-window-TTL, или начинает с головы, если курсор
устарел. Per-connection-обратное давление — drop-oldest:
медленное gateway-соединение теряет старые события первыми, при
каждом дропе пишется log-строка, чтобы обе стороны могли
скоррелировать дыру.

Push-канал — best-effort. Durable-запись "мы попытались сообщить
этому пользователю об этом событии" живёт в `notifications` /
`notification_routes` ([Раздел 8](#8-уведомления-и-почта));
потерянное push-событие не значит, что платформа забыла событие.

### 7.5 Поставщики

Backend-поставщики, эмитящие в push-канал, — это: notification-
диспатчер (push-маршруты из каталога) и session-модуль
(события отзыва). Никакой другой доменный модуль не эмитит
client-события, кроме notification-диспатчера.

### 7.6 Перекрёстные ссылки

- Wire-конверт, используемый для push-фреймов:
  [ARCHITECTURE.md §15](ARCHITECTURE.md#15-transport-security-model-gateway-boundary).
- Семантика переподключения и ring-буфера:
  [ARCHITECTURE.md §8](ARCHITECTURE.md#8-backend--gateway-communication)
  и `backend/docs/flows.md` "Push gRPC".
- Notification-диспатчер: [Раздел 8](#8-уведомления-и-почта).

---

## 8. Уведомления и почта

Раздел описывает, как платформа сообщает пользователю о событии
через push или e-mail (или оба).

### 8.1 Состав

В составе: подача notification-намерения, веер по push- и email-
каналам, durable mail outbox, dead-letter-обработка, оператор-
инициированный resend.

Вне состава: per-event-семантика — когда срабатывает каждый вид,
описано в соответствующем feature-разделе
([Раздел 4](#4-участие-в-лобби) для lobby-видов,
[Раздел 5](#5-реестр-названий-рас) для race-name-видов,
[Раздел 6](#6-игровая-сессия) для game-видов).

### 8.2 Notification-намерение и веер

Доменные поставщики (lobby, runtime, geo) подают типизированное
намерение в notification-модуль вместо передачи сообщения в
конкретный канал. Модуль затем:

- enforced'ит идемпотентность по виду намерения плюс
  идемпотентному ключу от поставщика;
- резолвит получателей;
- материализует один маршрут на получателя на канал, по политике
  каталога, специфичной для типа (только push, только email,
  оба, или admin email);
- эмитит push-маршруты в gRPC push-стрим, потребляемый gateway;
- вставляет email-маршруты прямо в mail outbox.

Малформ-намерения карантинизируются в выделенную таблицу и
никогда не блокируют поставщика.

### 8.3 Каталог

Каталог — это закрытый набор видов. Каждый вид специфицирует свои
каналы и payload-поля, потребляемые шаблонами и клиентами.
Три категории записей заслуживают отдельного упоминания:

- **`auth.login_code`.** Это единственный вид, обходящий
  notification-pipeline целиком. Auth пишет email-строку прямо
  в outbox, чтобы commit challenge был атомарен с mail-enqueue.
- **`runtime.*`-виды.** Они доставляются на сконфигурированный
  admin email. Если admin email не сконфигурирован, маршруты
  ложатся со статусом `skipped` и оператор-логом — запрос никогда
  не падает из-за отсутствия operator-конфига.
- **Зарезервированные виды без поставщика.** `game.*` и
  `mail.dead_lettered` перечислены в каталоге, но текущий код
  никаких из них не эмитит. Добавление поставщика чисто аддитивно.

### 8.4 Mail outbox

Email — это Postgres-backed durable outbox. Поставщики
(notification-маршруты и auth login-code-путь) пишут delivery-
строку плюс рендеренные payload-байты в одной транзакции.
Worker-горутина дренит outbox: подбирает строки под row-lock,
пытается SMTP-доставку, записывает попытку и либо помечает строку
sent, либо планирует следующую попытку с exponential backoff
и jitter.

Доставка, превысившая бюджет попыток, переходит в dead-letter-
таблицу; сам dead-lettering эмитит admin-notification-намерение.
На старте worker дренит всё, что в pending или retrying — нет
отдельного recovery-флоу.

Операторы могут переслать non-`sent` доставку с admin-surface
([Раздел 10](#10-администрирование)). Resend по `sent`-доставке
отклоняется, чтобы оператор случайно не пере-отправил почту,
которая уже ушла из relay.

### 8.5 Видимость для оператора

Admin-surface перечисляет deliveries, attempts на delivery,
dead-letters, notifications, notification-dead-letters и malformed
notification-намерения. Ничего из этих листингов недоступно с
user-surface.

### 8.6 Перекрёстные ссылки

- Таблица notification-каталога (виды, каналы, payload):
  [`backend/README.md` §10](../backend/README.md#10-notification-catalog).
- Внутренности mail outbox (таблицы, лог попыток, worker pickup):
  [ARCHITECTURE.md §11](ARCHITECTURE.md#11-mail-outbox) и
  `backend/docs/flows.md` "Mail outbox".
- Push-транспорт для client_event-маршрутов:
  [Раздел 7](#7-канал-push).

---

## 9. Гео-сигнал

Раздел описывает, что backend записывает о source IP
аутентифицированного запроса и что он сознательно с этим
не делает.

### 9.1 Состав

В составе: one-shot declared_country при регистрации,
fire-and-forget per-request country-counter, оператор-only
read-endpoint.

Вне состава: любой автоматический flagging, обнаружение account-
takeover, geo-fencing, enforcement санкций, история версий.
Гео-сигнал — это пассивная запись, не enforcement-механизм.

### 9.2 Что backend записывает

При регистрации ([Раздел 1.3](#13-подтверждение-вызова)) backend
ищет source IP в GeoLite2-country-базе и сохраняет полученный
ISO-код страны на аккаунте. Это значение пишется ровно один раз
на аккаунт; последующие входы из другой страны не перезаписывают
его.

При каждом аутентифицированном запросе через user-surface
fire-and-forget-горутина выполняет тот же поиск против request
IP и инкрементирует per-(user, country)-счётчик. Сам запрос
никогда не блокируется этой работой; горутина запускается
после возврата handler'а.

Оба пути fail-open: ошибка geoip-поиска логируется, но никогда
не блокирует пользователя.

### 9.3 Что backend НЕ делает

- Никакой агрегации по пользователям.
- Никакого автоматического flagging при смене страны.
- Никаких уведомлений из geo-сигнала.
- Никакой истории версий `declared_country`.
- Никакой корреляции с санкциями, лимитами или entitlement.

### 9.4 Доступ оператора

Admin-surface предоставляет один read-endpoint, перечисляющий
per-user-country-счётчики. Данные предназначены для ручного
inspect'а во время оператор-triage; UI-флоу поверх этого нет.

### 9.5 Дисциплина source IP

Backend читает source IP из самого левого `X-Forwarded-For`-
entry, откатываясь на connection peer, если заголовок отсутствует.
Backend доверяет значению, потому что сетевой сегмент между
gateway и backend — это платформенный trust boundary, edge уже
саниТизировал его. Это сделано намеренно и переутверждено в
[ARCHITECTURE.md §10](ARCHITECTURE.md#10-geo-profile-reduced) и
[§16](ARCHITECTURE.md#16-security-boundaries-summary).

E-mail-адреса никогда не пишутся в логи как есть. Backend пишет
process-scoped HMAC-truncated hash, чтобы операторы могли
скоррелировать log-строки внутри одного process lifetime без
сохранения PII.

### 9.6 Перекрёстные ссылки

- Обоснование trust-boundary:
  [ARCHITECTURE.md §10](ARCHITECTURE.md#10-geo-profile-reduced),
  [§15](ARCHITECTURE.md#15-transport-security-model-gateway-boundary),
  [§16](ARCHITECTURE.md#16-security-boundaries-summary).
- Контракт one-shot-write при регистрации vs per-request-counter:
  [`backend/README.md` §11](../backend/README.md#11-geo-profile).

---

## 10. Администрирование

Раздел описывает каждую admin-only-операцию. Многие из них
упоминались в предыдущих разделах (admin-overrides для лобби,
admin-soft-delete и санкции, mail- и notification-inspection);
этот раздел — консолидированное вью.

### 10.1 Состав

В составе: admin-аутентификация, cross-domain-admin-операции,
их побочные эффекты на остальную часть платформы.

Вне состава: end-user-флоу, разделяющие домен с admin-операцией —
они в своём собственном разделе.

### 10.2 Аутентификация и bootstrap

Admin-surface использует HTTP Basic Auth против backend-owned
admin-account-таблицы; пароли хешированы bcrypt'ом. На старте,
если bootstrap-admin-username и пароль сконфигурированы и
таблица ещё не содержит строки с этим username, backend вставляет
её. Insert идемпотентен: последующие рестарты ничего не делают.

Failed Basic Auth-ответ запрашивает оператор-tooling за
credentials стандартным способом; realm-строка фиксирована,
чтобы password manager оператора мог матчить её через
deployments.

После первого деплоя bootstrap-пароль должен быть ротирован
через admin-surface.

### 10.2.1 Операторская консоль (`/_gm`)

Администраторы выполняют эти операции либо программно через JSON
admin-API, либо через серверно-рендеримую веб-консоль на `/_gm`.
Консоль аутентифицируется теми же Basic Auth-учётными данными:
открытие любой страницы `/_gm` вызывает нативный диалог браузера для
ввода учётных данных, и оператор остаётся залогинен на время сессии.
Навигация — обычными ссылками и query-параметрами; каждое изменение
отправляется формой и завершается редиректом обратно на затронутую
страницу.

Консоль — единственная admin-поверхность, достижимая извне
доверенной сети. Она проксируется через gateway, поэтому наследует те
же edge-rate-limiting и лимиты запросов, что и публичный API, и несёт
анти-CSRF-токен на каждом изменении. JSON admin-API остаётся
внутренним для деплоя.

Стартовая страница консоли — дашборд, сводящий операционное
здоровье: готов ли backend и доступна ли БД, сколько игровых рантаймов
в каждом состоянии, какова глубина очередей почты и уведомлений. Это
read-only-срез на текущий момент для быстрой диагностики, не история
метрик.

### 10.3 Управление admin-аккаунтами

Существующие админы могут перечислять других админов, создавать
новых, искать конкретного, отключать или включать обратно
админа, и сбрасывать пароль. Отключённая admin-строка не может
аутентифицироваться; строка сохраняется, чтобы сохранить
audit-references, а не удаляется.

Reset-password принимает новый пароль в теле запроса. Backend
bcrypt-хеширует его, заменяет `admin_accounts.password_hash` и
возвращает обновлённую `AdminAccount`-форму — сам новый пароль
никогда не возвращается. "Delivered out-of-band" поэтому
означает: админ, инициирующий reset — тот, кто должен сообщить
новое значение target-админу через какой-то канал вне платформы
(защищённый мессенджер, голос и т.п.); платформа не email'ит
и не auto-доставляет.

### 10.4 Администрирование пользователей

Для любого user-аккаунта админ может:

- перечислять и инспектировать аккаунты;
- применить санкцию;
- применить per-user-limit-override, корректирующий конкретную
  квоту;
- обновить entitlement (план, paid-флаг, source, validity);
- soft-delete'нуть аккаунт (тот же in-process-каскад, что и
  [Раздел 2.4](#24-удаление-аккаунта-пользователем)).

Каталог санкций сознательно минимален в MVP: единственный
поддерживаемый `sanction_code` — это `permanent_block`. Применение
переключает `accounts.permanent_block`, отзывает все активные
сессии ([Раздел 1.5](#15-отзыв)) и запускает тот же lobby-каскад,
что и soft-delete, со membership-статусом `blocked`
([Раздел 2.4](#24-удаление-аккаунта-пользователем)). OpenAPI-схема
кодирует это как закрытый enum, чтобы будущие добавления были
явным breaking-изменением. Soft-delete всегда отзывает сессии;
санкции отзывают только когда вид документирует этот побочный
эффект (сегодня — только `permanent_block`).

### 10.5 Администрирование игр

Админы создают public-игры, перечисляют и инспектируют любую игру,
делают force-start или force-stop игре и баннят member'а.
Force-stop сносит запущенный engine-контейнер для игры; ban-member
добавляет пользователя в block-лист игры и удаляет любой
активный membership ([Раздел 4.4](#44-членства)).

Public-game-владение коллективное: строка несёт `owner_user_id IS
NULL` и любой админ может действовать с ней. User-surface никогда
не производит и не транзишнит public-игру.

### 10.6 Администрирование runtime

Админы инспектируют runtime-запись для игры, рестартят engine-
контейнер, патчат его image на более новый semver-патч в той же
major / minor-линии и форсят one-shot-доп-тик хода.

Patch сознательно ограничен patch-компонентом. Major- или minor-
смена версии требует явного stop / start игры, не in-place-апгрейда.
Регистрация версий движка и disable — рядом.

### 10.7 Реестр версий движка

Реестр версий движка — источник разрешённых engine-image'ов.
Поставщики (start, restart, patch) никогда не выбирают image-
references сами; они читают из реестра. Disable версии — forward-
looking-решение: существующие запущенные контейнеры держат свой
текущий image до stop / start, но disabled-версия больше не
eligible для новых стартов или патчей.

### 10.8 Администрирование почты и уведомлений

Операторы могут перечислять и инспектировать mail-deliveries,
attempts на delivery, dead-letters, notifications, notification-
dead-letters и malformed notification-намерения. Они также могут
переслать non-sent mail-delivery ([Раздел 8.4](#84-mail-outbox)).

Эти вью — единственный путь к видимости почты и уведомлений
вне телеметрии.

### 10.9 Администрирование geo

Единственный geo admin-endpoint перечисляет per-user-country-
счётчики ([Раздел 9.4](#94-доступ-оператора)). Admin-write-
доступа к geo-данным нет; declared_country устанавливается раз
при регистрации и не меняется, счётчики заполняются runtime'ом,
а операторы могут только читать.

### 10.10 Перекрёстные ссылки

- Контракт каскада soft-delete:
  [ARCHITECTURE.md §7](ARCHITECTURE.md#7-in-process-async-patterns).
- Жизненный цикл контейнера и арбитраж версий:
  [ARCHITECTURE.md §13](ARCHITECTURE.md#13-container-lifecycle-in-process).
- Mail outbox и notification-диспатчер:
  [ARCHITECTURE.md §11](ARCHITECTURE.md#11-mail-outbox),
  [§12](ARCHITECTURE.md#12-notification-pipeline) и
  [Раздел 8](#8-уведомления-и-почта).

---

## 11. Дипломатическая почта

Сценарий описывает обмен сообщениями между игроками одной партии и
адресные / широковещательные уведомления от администрации и
владельца партии. Подсистема концептуально часть лобби (сообщения
переживают рестарты движка), но видна только внутри игрового UI;
в лобби виден лишь счётчик непрочитанного.

### 11.1 Состав

В составе: отправка персональной почты между активными участниками
одной партии; ответы на персональную почту; чтение, отметка
«прочитано» и soft-удаление своей входящей почты; адресные и
широковещательные уведомления от админов и владельцев; платный
broadcast от игроков; мультигеймовая admin-рассылка; ручная
массовая чистка почты завершённых партий; авто-перевод тела
сообщения на `preferred_language` получателя с кэшированием.

Вне состава: чат вне партии, групповые чаты с участниками разных
партий, вложения, редактирование / отзыв сообщения,
end-to-end-шифрование.

### 11.2 Модель сообщения

Каждая отправка порождает ровно одну строку в `diplomail_messages`
плюс по одной строке на получателя в `diplomail_recipients`.
Broadcast на N получателей — одно сообщение и N recipient-строк;
строка перевода, если материализована, общая для всех получателей
с одинаковым целевым языком.

`diplomail_messages.kind` — закрытое множество
`{personal, admin}`. Персональные сообщения допускают ответ
(получатель отправляет новое персональное сообщение);
admin-сообщения не предполагают ответа — это уведомления о смене
состояния или операторском действии. `sender_kind` — это
`{player, admin, system}` и определяет роль отправителя: игрок-
владелец партии (admin-уведомление от owner), site-администратор
(admin-уведомление от оператора) или собственно автомат лобби
(`game.paused`, `game.cancelled`, `membership.removed`,
`membership.blocked`).

`broadcast_scope` фиксирует тип отправки: одному получателю
(`single`), рассылка по одной партии (`game_broadcast`) или
admin-рассылка по нескольким партиям (`multi_game_broadcast`).
Получатели multi_game-рассылки видят отдельную, независимо
удаляемую запись inbox в каждой адресованной партии.

Снимки сохраняются прямо в строках сообщения и получателя:
`game_name`, `sender_username`, `sender_ip` и на стороне
получателя — `recipient_user_name`, `recipient_race_name` и
`recipient_preferred_language`. Они переживают переименование
партии, отзыв членства, soft-delete аккаунта и итоговый
bulk-purge — admin observability отрисовывается корректно даже
после исчезновения «живых» строк.

Тела и subject — plain UTF-8 текст. Сервер не парсит, не санитайзит
и не экранирует HTML; клиент рендерит тело через `textContent`.
Максимум размера тела — `BACKEND_DIPLOMAIL_MAX_BODY_BYTES`
(по умолчанию `4096`); максимум для subject —
`BACKEND_DIPLOMAIL_MAX_SUBJECT_BYTES` (по умолчанию `256`).

### 11.3 Отправка почты

Персональная отправка требует активного членства в партии и от
отправителя, и от получателя. Игроки free-tier отправляют одно
персональное сообщение за запрос. Игрокам платных тиров доступен
и игровой broadcast — одна отправка на всех остальных активных
участников партии; ответы возвращаются автору broadcast.

Владельцы (приватных партий) и site-администраторы отправляют
admin-уведомления. Endpoint владельца находится на user-поверхности
(аутентификация по `X-User-ID`, проверка владельца в обработчике);
endpoint администратора — на admin-поверхности (HTTP Basic). Оба
принимают `target=user` (один получатель) или `target=all`
(broadcast в одной партии). Site-администратору доступен
дополнительный multi-game endpoint, принимающий
`scope=selected` со списком game_id или `scope=all_running` —
перебор всех партий в нетерминальных состояниях.

Состав получателей broadcast параметризуется полем `recipients`:
`active` (по умолчанию), `active_and_removed` или `all_members`
(включает блокированных, для аудит-уведомлений). Собственная
recipient-строка автора broadcast не создаётся.

Player-broadcast от free-tier пользователя отклоняется кодом
`403 forbidden`.

### 11.4 Получение почты

Получатель видит сообщение в своём inbox только после того, как
асинхронный worker перевода обработал его (см.
[§11.6](#116-перевод)). До этого строка невидима: не выводится в
inbox-листинге, не учитывается в badge непрочитанного, push-событие
не доставляется. Это исключает ситуацию «строка появилась, перевод
не подъехал, badge мигает».

Badge непрочитанного в лобби агрегируется по партиям. Endpoint
`/api/v1/user/lobby/mail/unread-counts` возвращает по одной записи
на каждую партию с ненулевым unread плюс общий total; UI лобби
отображает общий badge и плитки по партиям, не раскрывая самих
сообщений.

Mark-read идемпотентен. Soft-удаление требует, чтобы сообщение уже
было помечено прочитанным — клиент не может стереть неоткрытое
сообщение. Soft-удаление действует только для одного получателя:
строка самого сообщения переживает удаление вплоть до admin
bulk-purge всей почты соответствующей партии.

Ответ message-detail содержит и оригинальное тело, и (если есть
кэш) перевод; UI по умолчанию показывает перевод и предлагает
переключение «показать оригинал».

Внутриигровой UI группирует личную почту по веткам по расам —
каждая личная переписка между локальным игроком и другой расой
оказывается в одной ветке, ключевая по расе собеседника.
Системные сообщения, административные уведомления и собственные
рассылки игрока (платный тариф) показываются отдельными
автономными записями в том же списке и никогда не группируются.
`read_at` и `deleted_at` поддерживают локальный счётчик
непрочитанного и кнопку удаления, но не показываются игроку —
дипломатическая почта не обещает уведомления о прочтении. Форма
compose выбирает получателя по имени расы (сервер резолвит через
`Memberships.ListMembers(game_id, "active")`); клиент не тянет
отдельный список членов. Подробнее — в
[`ui/docs/diplomail-ui.md`](../ui/docs/diplomail-ui.md).

### 11.5 Хуки жизненного цикла

Три транзитных перехода в лобби порождают system mail в inbox
затронутых игроков:

- **Пауза / отмена игры.** Когда автомат партии проходит через
  `paused` или `cancelled`, лобби эмитит system-сообщение всем
  активным членам. Текст рендерится сервером по шаблону, чтобы
  игрок, открывший inbox позже, нашёл объяснение даже без
  одновременной push-сессии.
- **Удаление / блокировка членства.** Сам-выход, удаление
  владельцем и admin-бан порождают system-сообщение только для
  затронутого игрока. Это письмо переживает переход членства в
  `removed` / `blocked` — игрок сохраняет к нему read-доступ
  навсегда (правило soft-доступа).

Будущее удаление по неактивности должно вызывать тот же publisher,
чтобы объяснение дошло до затронутого игрока; README пакета
прибивает этот контракт для следующего реализатора.

### 11.6 Перевод

`diplomail_messages.body_lang` заполняется на стороне сервера в
момент отправки внутрипроцессным детектором языка, работающим
только по телу. Subject наследует язык тела для ключа кэша
перевода. Когда детектор не может уверенно классифицировать тело
(слишком короткое, пустое, смешанные скрипты), значение —
плейсхолдер BCP 47 `und` ("неопределённый"), и pipeline перевода
обходится стороной — получатели видят оригинал.

Перевод выполняется асинхронно. Каждая recipient-строка содержит
снимок `preferred_language` получателя плюс метку `available_at`.
Получатель, чей язык совпадает с детектированным `body_lang` (или
чей preferred_language пуст / язык тела — `und`), получает
`available_at = now()` сразу при вставке, и push-событие
отправляется в момент `POST`. Получатель с отличающимся языком
вставляется с `available_at IS NULL` и ждёт worker.

Worker (`internal/diplomail.Worker`) тикает каждые
`BACKEND_DIPLOMAIL_WORKER_INTERVAL` (по умолчанию `2s`) и
обрабатывает по одной паре `(message_id, target_lang)` за тик. Он
сначала смотрит в кэш переводов; на miss дёргает настроенный
`Translator`. Дефолт production-сборки — LibreTranslate HTTP
клиент; пустой `BACKEND_DIPLOMAIL_TRANSLATOR_URL` оставляет
noop-translator, который доставляет сообщение в оригинале.

Исходы перевода:

- **Успех.** Строка в `diplomail_translations` создаётся (или
  переиспользуется, если параллельная попытка успела раньше),
  все pending-получатели пары переключаются на
  `available_at = now()`, и по каждому отправляется push.
- **Неподдерживаемая пара языков** (HTTP 400 от LibreTranslate).
  Строка перевода не сохраняется; получатели доставляются с
  оригинальным телом. Последующие чтения возвращают оригинал.
- **Транзитный сбой** (timeout, 5xx, network error). Счётчик
  попыток увеличивается, следующая попытка планируется по
  экспоненциальному backoff `1s → 2s → 4s → 8s → 16s`
  (с потолком 60s). После
  `BACKEND_DIPLOMAIL_TRANSLATOR_MAX_ATTEMPTS` (по умолчанию `5`)
  worker fallback'ит на оригинальное тело. Длительный отказ
  переводчика тормозит доставку максимум на ~30 секунд на пару
  до того, как получатель увидит оригинал.

Кэш переводов общий: broadcast на N получателей с одинаковым
preferred_language порождает одну строку кэша и один вызов
переводчика, не N.

### 11.7 Хранение и purge

Сообщения живут в `diplomail_messages`; per-recipient state — в
`diplomail_recipients` с FK-каскадом на сообщение; переводы — в
`diplomail_translations` тоже с каскадом. IP-адрес отправителя
снимается из `X-Forwarded-For` (форвардит gateway) и хранится в
сообщении для сохранения доказательств.

Автоматического retention нет. Admin bulk-purge endpoint удаляет
все сообщения, чья партия завершилась более `older_than_years`
лет назад (минимум `1`); каскад удаляет recipient- и
translation-строки той же транзакцией.

### 11.8 Видимость для оператора

Admin-поверхность экспонирует постраничный листинг всех сообщений
(`/api/v1/admin/mail/messages`) с фильтрами по `game_id`, `kind`
и `sender_kind`. Bulk-purge endpoint
(`/api/v1/admin/mail/cleanup`) принимает порог
`older_than_years`. Per-game admin-отправки и multi-game
broadcast'ы доступны через `/api/v1/admin/games/{game_id}/mail`
и `/api/v1/admin/mail/broadcast`.

### 11.9 Перекрёстные ссылки

- Обзор пакета и карта стадий:
  [`backend/internal/diplomail/README.md`](../backend/internal/diplomail/README.md).
- Рецепт развёртывания LibreTranslate для локальной разработки:
  [`backend/docs/diplomail-translator-setup.md`](../backend/docs/diplomail-translator-setup.md).
- Детали хранения:
  [ARCHITECTURE.md §12.1](ARCHITECTURE.md#121-diplomatic-mail-subsystem).
- Push-транспорт для событий доставки: [Раздел 7](#7-канал-push).
- Notification-каталог: kind `diplomail.message.received`:
  [`backend/README.md` §10](../backend/README.md#10-notification-catalog).
