# Этапы

Полный план — `C:\Users\Administrator\.claude\plans\polymorphic-frolicking-lamport.md`.
Здесь — состояние и содержание этапов.

## Готово

### M0 — Фундамент

Laravel 13 + PHP 8.3 + MySQL 8 + Redis. Ядро модулей с манифестом, реестром,
топологической сортировкой и генератором. Сайты, домены, резолв хоста,
канонизация одним 301, контекст арендатора. Кэш на счётчиках версий. Настройки:
типизированное хранение, декларативные схемы, шифрование секретов. Реестр таблиц
арендатора. Гейты: Deptrac + Pint + PHPUnit.

**Критерий пройден:** два хоста отдают разные сайты, неизвестный хост —
платформенную 404.

### M1 — Структура и роутинг

Разделы с материализованным путём, транслит-слаггер, переименование
с пересчётом путей ветки одним запросом и авто-301. Редиректы со схлопыванием
цепочек и позиционными шаблонами. Реестры компонентов, шаблонов компонентов
и типов полей. `PageResolver`, `UrlBuilder`. Модули `news` и `textpage`.

**Критерий пройден:** дерево резолвится по реальным URL **без единого запроса
в БД на роутинг**, переименование отдаёт 301 со всей ветки.

### M2 — Дизайн-система

Зоны с настройками в колонках, блоки, правила видимости с каскадом,
24-колоночная сетка на CSS Grid, реестр типов содержимого блока (html, objects,
menu, breadcrumbs, copyright), кэш раскладки, `LayoutWriter`.

**Критерий пройден:** страница собирается из зон и блоков, ширина блока едет
в `grid-column: span N`, раскладка отдаётся из кэша.

### M3 — Админка

Vue 3 + Inertia, дизайн KORZILLA 1:1. Отдельный бандл: посетитель сайта
не тянет Vue ради страницы, отрисованной Blade на сервере.

Универсальный рендерер форм настроек из `SettingsSchemaRegistry` — экранов
настроек не пишется вообще. Редактор дерева разделов поверх `SectionWriter`,
карта зон и блоков с перетаскиванием и изменением ширины мышью, медиатека
с относительными путями и шардированием по uuid.

Вход — временный гвард на одном пароле платформы; полноценная авторизация в M6.

**Критерий пройден:** сайт собирается end-to-end без единой строки SQL.
Подробности — [admin.md](admin.md).

### M4 — Контентные модули и конструктор

Экран содержимого раздела — один на все компоненты: список и форма строятся
из объявленных полей. `ObjectWriter` в ядре: слаги, приведение типов, привязка
файлов и порядок делаются один раз, а не в каждом модуле.

Семь модулей: документы, галерея, контакты, сотрудники, отзывы, преимущества,
портфолио. Конструктор типов страниц: определения и поля в базе, значения —
в типизированных колонках `component_values`. Визуальный редактор текста
с картинками из медиатеки по относительным путям.

**Критерий пройден:** новый тип страницы создаётся из админки и сразу
работает на сайте — без единой строки кода.

## Дальше

### M5 — Каталог

Готово: города и город запроса, схема каталога, цены по типам/городам/группам
и конкретным пользователям, склады и остатки, характеристики со справочником
и витриной фасетов, `CatalogQueryPlanner`, `FacetEngine`.

**Гейт пройден:** 100 тыс. товаров, фасетная страница p95 127 мс.
Подробности и разбор оптимизации — [catalog.md](catalog.md).

Плюс ЧПУ-адреса фильтров через общий механизм «компонент разбирает остаток
пути» и админка каталога: справочники и карточка товара с ценами, остатками,
характеристиками и вариантами.

Не доделано и вынесено дальше: связи товара и цены вариантов в интерфейсе,
пересчёт витрины фасетов очередью (вместе с импортом, M7).

### M6 — Коммерция и авторизация

Готово: **персайтовые администраторы**. Таблица `admins` — `tenant`,
уникальность почты по `(site_id, email)`, две роли (владелец ведёт учётные
записи, администратор — всё остальное), экран учётных записей, команда
`admin:create`. Платформенный пароль `KZ_ADMIN_PASSWORD` снят: он открывал
админку любого из сотен сайтов установки.

Заодно закрыт долг по куке: у админки собственная кука сессии с путём `/admin`,
и сессия посетителя больше не является сессией администратора. Полная развязка
(отдельный хост админки) остаётся за M9 — XSS на домене сайта по-прежнему
может дёрнуть `/admin` тем же источником. Подробности — [admin.md](admin.md#вход).

Готово: **покупатели сайта и вход по звонку**. `users` стал персайтовой
таблицей (долг M0 закрыт), появились `user_phones`, `auth_identities`,
`phone_verifications`; поставщик учётных записей фильтрует по сайту, поэтому
сессия одного сайта не поднимает покупателя другого. Обратный дозвон
через voicepassword.ru, провайдер выбирается настройкой сайта; блок «Вход
покупателя» работает без JavaScript. Подробности — [auth.md](auth.md).

Готово: **корзина** в модуле `shop`. Хранится в базе — собранная на телефоне
корзина находится на компьютере после входа; гость опознаётся токеном в куке,
при входе корзины сливаются по бо́льшему количеству, при выходе гость получает
новый токен. Цена в корзине живая, `price_at_add` — только отметка «цена
изменилась». Заодно у товара появился **способ покупки** (`cart` / `request` /
`none`): каталог остаётся витриной и работает без магазина. Подробности —
[shop.md](shop.md).

Готово: **заказы и оплаты**. Позиции — снимок (название, артикул и цена копируются),
номер — персайтовый счётчик с единицы, статусы — справочник сайта с системными
флагами, история смен в `order_events`. Оформление из корзины с подтверждением
телефона звонком (или без него, по настройке сайта), «купить в один клик»
для товаров с режимом `request`, админка: список с фильтром, карточка заказа
и справочник статусов.

Оплата: справочник способов сайта поверх реестра провайдеров, ЮKassa
и «при получении». Уведомлению банка не верим — из него берётся только
идентификатор платежа, а состояние перезапрашивается у API; идемпотентность
по `(site_id, provider, external_id)`, сверка суммы, фильтр по сайту.

Доставка: справочник способов с расчётом за интерфейсом, цены по городам,
порог бесплатной доставки. Стоимость считается на сервере и из формы
не принимается.

Внешние входы: Яндекс ID и VK ID поверх `auth_identities`. Порядок поиска —
удостоверение, потом подтверждённый телефон, потом почта: человек, входивший
звонком, попадает в ту же учётку.

Группы покупателей: справочник и привязка в каталоге, цена персонализируется
везде — резолвер сам спрашивает группы текущего покупателя. Экраны «Покупатели»
и вкладка групп в справочниках закрывают долг «правятся только в базе».

Личный кабинет: «мои заказы» снимком, повтор заказа живой корзиной, профиль
без телефона (он ключ входа и подтверждается звонком).

**Этап M6 закрыт.** Дальше — M7: импорт CommerceML и поиск.

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

⚠️ Требование, ради которого порт делался не копированием: `user_phones.phone`
и `auth_identities` уникальны **по `(site_id, …)`**, а не глобально —
в `teeu` они были глобальными. Иначе телефон, зарегистрированный на одном
сайте, заблокировал бы регистрацию на другом, а персайтовый дамп нарушил бы
собственный уникальный ключ при восстановлении. Это же правило действует
для всего, что появится дальше: корзин, заказов, адресов.

### M7 — Импорт и поиск

Готово: **импорт CommerceML 2**. Конвейер разбор → склад → слияние → зачистка,
потоковый XMLReader, слияние одним `INSERT … SELECT … ON DUPLICATE KEY UPDATE`,
зачистка одним `UPDATE … LEFT JOIN`, возобновление по `resume_offset`.

**Гейт пройден:** 100 000 позиций, 3324 поз./с, пик памяти 36 МБ (константный),
`kill -9` на середине продолжается без дублей. Подробности — [import.md](import.md).

Готово: **поиск по сайту**. Общий индекс `search_index` с ngram-парсером,
обновляется вместе с содержимым, движок за интерфейсом `SearchEngine`.

⚠️ **Гейт по поиску не пройден на 100 тысячах документов**: однословный запрос
0,5–1 с, двухсловный — 41 с. Причина в самом ngram: в булевом режиме запрос
становится фразовым поиском по биграммам с проверкой каждой строки-кандидата,
а естественный режим молча выбрасывает частые слова и потому неприменим.
Для каталога в тысячи позиций текущий движок годится, для сотни тысяч нужен
Meilisearch — интерфейс для замены и существует. Замеры и разбор —
[search.md](search.md).

Готово: **приёмник обмена** `/1c/exchange` — checkauth, init с чисткой
каталога, приём кусков, разбор очередью. Живой прогон: 43 МБ тремя кусками,
100 000 позиций за 1 мин 22 с вместе с фасетами и переиндексацией.

Готово: **индексация остального содержимого** — новости, страницы, объекты
любого компонента, включая собранный в конструкторе. Какие поля индексировать,
объявляет сам компонент (`searchable()`), ядро о новостях ничего не знает.
Ремонтная команда — `search:reindex {сайт}`.

Готово: **сторож импорта** — ежечасная команда закрывает прогоны, переставшие
подавать признаки жизни, и убирает брошенный склад. Мёртвый прогон опознаётся
молчанием (`updated_at`), а не временем старта: легаси резал по старту
и убивал живые выгрузки. Заодно исправлено: `failed` больше не значит
«нельзя продолжить», а склад успешного прогона удаляет сам конвейер — на
разработке от пяти прогонов накопилось 302 500 брошенных строк.

Готово: **подсказки при вводе, подсветка совпадений, глобальный поиск
в админке**. Подсказки идут по отдельному индексу заголовков (`ft_title`):
на 100 тыс. документов «шаровой кран» — 1,4 с против 106 с по всему документу.
Выдержка режется вокруг совпадения, а не с начала текста. Поиск админки —
реестр `AdminSearchProviders` (разделы, содержимое, товары, заказы) и ходит
в базу, а не в индекс: в индексе только опубликованное, а в админку заходят
за черновиком.

Meilisearch отложен решением владельца: текущий движок годится для каталогов
в тысячи позиций, а гейт по сотне тысяч остаётся непройденным.

### M8 — SEO, формы, API

Готово: **SEO разделов с настоящим наследованием**. `section_seo`, значение
ищется вверх по цепочке предков до первого установленного, последнее звено —
умолчание сайта из настроек. Шаблоны с подстановками, падежи города, отдельные
наборы для списка и карточки, тумблер «и для подразделов». Мета-теги печатает
платформа, а не тема, поэтому корзина и кабинет закрыты от индексации всегда.
Разбор — [seo.md](seo.md).

Готово: **карта сайта и robots.txt** — оглавление плюс файлы по 20 000 адресов,
потоком и мимо моделей; домен разработки и черновик закрыты от индексации
целиком. Файл `public/robots.txt` из скелета Laravel удалён: веб-сервер отдавал
его раньше маршрута.

Готово: **schema.org** — JSON-LD одним `@graph`: организация, хлебные крошки
из дерева, `Product` с ценой и наличием, `NewsArticle` с датой. Что описывать,
объявляет сам компонент через `ProvidesStructuredData`.

Готово: **формы и заявки** — модуль `forms`. Форма собирается в админке
и ставится блоком; заявка хранится снимком и переживает форму. Защита без
капчи: приманка плюс подписанная метка времени, пойманный бот получает вид
успеха. Письмо — дополнение к записи в базе, а не способ доставки.
Разбор — [forms.md](forms.md).

Готово: **JSON API v1 и Sanctum** — поверх тех же сервисов, что и сайт.
Токен помечен сайтом (иначе токен одного арендатора поднимал бы покупателя
у другого), сессия сайта к API не допущена, гвард по умолчанию переключается
на токен. Разбор — [api.md](api.md).

**Этап M8 закрыт.**

### M9 — Платформенные модули

Готово: **супер-админ платформы** — модуль `modules/platform/superadmin`
на собственном домене. Учётки платформы в отдельной таблице `platform_admins`
(`global`, не `tenant`): строка в персайтовой `admins` уехала бы клиенту
вместе с выгрузкой его сайта. Список сайтов, создание сайта через то же
действие `CreateSite`, что и консоль, смена статуса. Разбор —
[platform.md](platform.md).

Готово: **фабрика сайтов** — копия из шаблона. Разделы, зоны, блоки, настройки,
SEO и медиатека переносятся с пересборкой идентификаторов; домены,
администраторы и данные посетителей не переносятся никогда. Разбор —
[platform.md](platform.md).

Готово: **персайтовое подключение модулей** — основа маркетплейса. Модуль
объявляет себя отключаемым, сайт получает его адреса, пункты меню и кнопки
только если он подключён; данные при отключении остаются. Разбор —
[platform.md](platform.md).

Готово: **персайтовый код** — `SiteOverrideBooter` и каталог `overrides/{ключ}/`.
Один вход, явный контракт, маршрут переопределения привязан к своему сайту.
Попутно резолвер страниц стал фолбэк-маршрутом: обычный catch-all перехватывал
адрес раньше всего, что объявлено позже.

Готово: **кабинет разработчика** — правка персайтового кода из браузера.
Только супер-админ платформы: редактор пишет PHP на сервер. Путь сверяется
с корнем каталога, правятся только `.php` и `.blade.php`, синтаксис
проверяется до записи.

Готово: **панель редактора на живом сайте** — ссылка на правку открытого
раздела, переход в зоны и блоки, подсветка блоков. Вставляется middleware
в готовый ответ, поэтому работает на любой вёрстке и не попадает в кэш блоков.
У панели своя кука-метка: сессия админки живёт с путём `/admin` и на страницах
сайта не передаётся.

**Этап M9 закрыт.** Биллинг маркетплейса обсуждается отдельно и к функциям CMS
не относится. Дальше — M10: сборщик выгрузки.

### M10 — Сборщик выгрузки ← текущий

`ExportContributor`, дампер на консистентном снапшоте, урезание кода по
`module.json`, упаковка медиа, режим `single`, деградация на файловый кэш.

**Гейт:** выгруженный реальный сайт поднимается на обычном php/mysql-хостинге
и проходит smoke-тесты. Отдельно проверяется, что разделы SUPERADMIN
и создание сайта **отсутствуют, а не скрыты**.

#### На что этап уже опирается

Всё это делалось заранее и ради него:

| Что готово | Где | Зачем выгрузке |
|---|---|---|
| Категории таблиц `tenant` / `root` / `global` / `ignore` / `platform` | `TenantTables` | дампер знает, что фильтровать по `site_id`, что копировать целиком, а что не трогать |
| Тест-инвариант в обе стороны | `TenantTableInvariantTest` | забытая таблица ломает сборку на тестах, а не у клиента |
| `tier=platform` + запрет Deptrac | `deptrac.yaml`, `module.json` | платформенные модули вырезаются целиком, и в остатке нет битых ссылок |
| Относительные пути медиа | `media.path` = `img/3f/2a/{uuid}.webp` | архив распаковывается в любой каталог |
| Кэш на счётчиках версий | `SiteCache` | автономная копия работает на файловом драйвере, который тегов не умеет |
| `platform_admins` вне персайтовых таблиц | [platform](platform.md) | учётки платформы не уезжают клиенту |
| Персайтовые переопределения в `overrides/{ключ}/` | [platform](platform.md#персайтовый-код) | код клиента едет с его сайтом, а не остаётся на платформе |

#### Сделано

**Дамп базы на консистентном снимке** — модуль `modules/platform/exporter`,
команда `site:export`. `REPEATABLE READ` + `START TRANSACTION WITH CONSISTENT
SNAPSHOT` на собственном соединении: все выборки видят одну точку времени
и никого не блокируют. Живой прогон: 200 116 строк в 74 таблицах, 87,5 МБ,
6,6 с, пик памяти 34 МБ (константный); восстановление в чистую базу — 47 с,
счётчики строк сошлись. Разбор — [export.md](export.md).

⚠️ **Первый прогон отправил клиенту учётку владельца платформы.**
`platform_admins` была объявлена `global`, а это означает «справочник, который
копии нужен, и он едет целиком». Появилась пятая категория `platform` — «не
едет вовсе», и тест-инвариант теперь читает миграции платформенных модулей
и требует её для каждой их таблицы. Заодно уточнён `ignore`: в копию едет
схема без строк, потому что таблица `jobs` там нужна, а задания платформы нет.

**Правила выгрузки от модулей** (`ExportRules` в ядре). Модуль объявляет, что
из его таблиц не везти и чем это достроить на той стороне: поисковый индекс
занимал 58% дампа и строится из содержимого, которое едет рядом; временный
склад импорта — сотни тысяч строк черновика; история прогонов увезла бы
в копию прогон в статусе `parsing` и заперла бы приёмник обмена. Правило
означает «едет пустой», а не «таблицы не будет»: код модуля в копии есть.
Дамп сайта на 100 тыс. товаров: 87,5 → **36,8 МБ**, восстановление 47 → 24 с.

**Урезание кода.** Набор считается по `module.json`, исходники не правятся
ни одной строкой — лишние каталоги просто не копируются. Копируется
разрешённое, а не всё кроме запрещённого: список того, что нельзя отдавать
клиенту, невозможно держать полным. Не уезжают `.env`, содержимое
`bootstrap/cache`, платформенные модули, **чужие `overrides` и `themes`**,
содержимое `storage`. Зависимости выжившего модуля возвращаются в набор,
даже если сайт их отключил. Режим `single` и файловый кэш — в сгенерированном
`.env.example`: мультисайтовые ветки убираются конфигом, а не правкой кода.
Живой прогон: 12 047 файлов, 91 МБ, 145 с; `grep` по копии на пространство
имён платформенных модулей — пусто.

**Медиа и `EXPORT.json`.** Файлы уезжают с теми же относительными путями,
что лежат в базе, — ни одной строки не переписывается, ради этого и заведён
второй инвариант; проверяется он до сборки первого файла. Рабочие каталоги
диска (`imp`, `cache`) остаются на платформе, суммы пишутся отдельным файлом
в формате `sha256sum`, а для медиатек на десятки гигабайт есть режим
`manifest`. `EXPORT.json` читает установщик: по нему он знает, какие таблицы
пусты намеренно и чем их достроить, причём шаги вырезанных модулей в копию
не попадают.

**`korzilla:install`.** Живёт в ядре, а не в платформенном модуле: выполняется
он в копии, откуда те вырезаны. Дамп разбирается на операторы разборщиком
с состояниями (точка с запятой посреди текста страницы не должна резать
оператор) и потоком: 36 МБ в `memory_limit=128M` не поднять. Всё после записи
`.env` идёт отдельными процессами — текущий поднялся до появления конфигурации.
Секреты настроек в копию не едут: они зашифрованы ключом платформы, и владелец
вводит их заново.

⚠️ Первая установка упёрлась в то, что **приложение вообще не поднималось
без `APP_KEY`**: реестры резолвились в `boot()` и тянули настройки,
а те — шифровальщик. Исправлено ленивым шифровальщиком и `callAfterResolving`;
попутно вылечена регистрация кнопок покупки, которая не пережила бы сброс
scoped-объектов в воркере очередей.

**Гейт пройден локально:** сайт на 100 001 товар выгружен, поднят на чистой базе
с файловым кэшем и очередью в базе, смоук-набор зелёный (главная, разделы,
карточка, поиск, корзина, вход в админку, карта сайта), `/sites`
и `/superadmin` — 404. Остаётся прогон на настоящем хостинге клиента.

### M11 — Первый живой сайт ← текущий

Боевой сайт, нагрузочный тест, регламент эксплуатации, бэкапы, мониторинг,
`gh-ost` в деплое.

Готово: **регламент эксплуатации** — [operations.md](operations.md): деплой
по шагам и что в нём обязательно (`module:discover`, `queue:restart`), правило
«таблица больше ста тысяч строк — не `ALTER` напрямую, а `gh-ost`», воркеры
под supervisor с `--max-jobs`, бэкапы (`mysqldump --single-transaction` плюс
`storage/app/sites`, и почему своего инструмента не написано), разбор частых
инцидентов.

Готово: **`/health`** — адрес для внешнего наблюдателя. 200 или 503, тело
без токена состоит из одного слова: открытая страница состояния подсказывает
прохожему, какие драйверы подняты и сколько свободного места. Проверки ядра —
база, кэш (пишем и читаем, а не пингуем), очередь и диск. Очередь считается
больной не по длине, а по **возрасту старшей задачи**: длинная очередь — это
нагрузка, старая — умерший воркер. Модуль добавляет свою проверку сам.

Готово: **почта сайта** — отправитель и SMTP персайтовые (письмо с адреса
платформы не проходит SPF домена клиента), письма о заказе владельцу
и покупателю, письмо о смене статуса по флагу `notify_customer` (о каждом
внутреннем переходе писать нельзя — покупатель перестанет читать вовсе),
кнопка проверки почты в настройках. Разбор — [shop.md](shop.md#письма-о-заказе).

Готово: **восстановление пароля админки** — долг M6. Ссылка письмом, час
жизни, одноразовая, токен в базе хэшем, ответ формы одинаков на любую почту.
Разбор — [admin.md](admin.md#забыли-пароль).

Готово: **русские тексты валидации** — долг M6. Переведены все стандартные
правила и названия полей: наполовину переведённый каталог означает, что первое
же новое правило снова покажет посетителю `validation.min.string`.

Готово: **справочник городов РФ** — 1111 городов общей таблицей `geo_cities`,
персайтовая `cities` осталась на месте. Экран выбора городов сайта —
следующий шаг. Разбор — [geo.md](geo.md).

Готово: **витрина и раскладка по замечаниям со стенда** — то, что всплыло,
когда по сайту начали ходить руками:

- **баннеры** — своя база, выбор блока вывода, период показа, города,
  лента со стрелками, точками и перетаскиванием ([layout.md](layout.md#баннеры));
- **показ подразделов** плиткой или кнопками и **товары из подразделов**
  на денормализованных связях — 7 мс против 137 мс у `IN (…)`
  ([structure.md](structure.md#показ-подразделов), [catalog.md](catalog.md#товары-из-подразделов));
- **зоны слева и справа** с шириной центра, которая не хранится, а считается
  от видимых боковых ([layout.md](layout.md#зоны-слева-и-справа-от-контента));
- **шаблоны меню**, выравнивание содержимого блока, строка поиска с подсказками
  товаров и разделов, фон зоны картинкой и «по стакану», прижатый подвал;
- **карточка объекта из блоков** — шаблоны карточки общие и свои у компонента,
  наследование по дереву разделов, вкладки и колонка вложенностью блоков,
  части карточки товара от каталога ([layout.md](layout.md#карточка-объекта-из-блоков));
- **цвета текста, ссылок, кнопок и значков** у зоны и блока переменными CSS
  ([layout.md](layout.md#цвета-зоны-и-блока));
- **админка сайта из персайтового кода** — меню, экраны настроек и адреса
  под входом вместо легаси-манифестов `/b/{login}/manifest/`
  ([overrides/README.md](../overrides/README.md#админка-сайта));
- **свой CSS сайта** с редактором кода — три вкладки по границам сетки X,
  разворот на весь экран с сохранением по Ctrl+S, отдача адресом
  `/kz-site.css` с версией-хэшем ([layout.md](layout.md#свой-css-сайта));
- **дерево разделов как в легаси** — значки типов, тип по-русски, номер
  вместо адреса, кнопки строки по наведению: переключатель, «изменить»,
  «+ Подраздел», «удалить» ([admin.md](admin.md#строка-дерева)).

Готово: **хост платформы больше не отвечает 500** на адреса сайта — по журналу
стенда это было ~150 ошибок в сутки от ботов ([platform.md](platform.md#отдельный-домен)).

Следующее по админке: страница Blade внутри оболочки админки для экранов
клиента — вместе с набором классов, из которого такие экраны верстаются.
Свои Vue-экраны клиента — только под конкретный заказ.

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

## Отложено сознательно

- **Миграция легаси-данных** — решение владельца: новые сайты создаются здесь,
  300–500 старых остаются на предыдущей системе.
- **Meilisearch** — интерфейс `SearchEngine` готовит замену, но в первой версии
  поиск на MySQL FULLTEXT.
- **Laravel Octane** — не в первой версии.
- **Полностраничный HTML-кэш** — запасной ход без изменений в коде — nginx
  microcache на 1–5 секунд с обходом по сессионной куке.
- **Мультиязычность контента** — колонки `lang` зарезервированы, в первой версии
  одна локаль.
- **Вложенные блоки внутри блоков** — колонка `parent_block_id` зарезервирована.
- **Многоосевые варианты товара.**

## Известный технический долг

| Что | Где | Когда |
|---|---|---|
| Нет типа блока «создатель сайта» | нужны платформенные настройки | по мере надобности |
| `predis` вместо `phpredis` | нет расширения в сборке PHP | до продакшена |
| Админка на домене сайта | XSS на домене дёргает `/admin` тем же источником; кука своя, но хост общий | **не сделано в M9**; `KZ_ADMIN_HOST` в конфиге зарезервирован |
| ~~Восстановления пароля почтой нет~~ | закрыто: ссылка письмом, час жизни, одноразовая | M11 |
| ~~Формы валидируются сырыми ключами~~ | закрыто: `lang/ru` с полным набором правил и названиями полей | M11 |
| Поиск не держит сотню тысяч документов | ngram + булев режим: два слова = 41 с. **Гейт M7 не пройден** | Meilisearch за `SearchEngine`, отложен решением владельца |
| Пересчёт витрины фасетов из админки синхронный | импорт уже считает их в очереди, правка товара — нет | по мере надобности |
| Связи товара и цены вариантов без экранов | таблицы есть | по мере надобности |
| Экрана городов сайта нет | справочник РФ уже есть ([geo.md](geo.md)), не хватает экрана выбора | ближайшее |
| Блоки и провайдеры поиска отключённого модуля не спрятаны | ключ модуля помнят только кнопки покупки и меню | когда появится модуль со своим блоком в продаже |
| SEO у отдельного объекта нет | только шаблон раздела с подстановками | по мере надобности |
| Журнала действий супер-админа нет | создание сайтов и правка чужого кода не записываются | вместе с входом от имени владельца |
| Двухфакторной аутентификации платформы нет | пароль открывает доступ ко всем сайтам установки | до первого живого клиента |

