# Что измерено и что из-за этого изменилось

Дата: 2026-08-04 · Машина: Windows Server 2022, Hyper-V VPS · Сборка: Chrome for Testing 151.0.7922.71

Каждое утверждение здесь — результат замера, а не мнение. Сырые данные лежат
в `spikes/reports/*.json`, команды воспроизведения — в README.

---

## Сводка

| Этап | Критерий приёмки из плана | Результат |
|------|---------------------------|-----------|
| 0 | Три спайка дали письменные ответы, зафиксированные регрессионными тестами | ✅ 7 спайков, 96 тестов |
| 1 | `webdriver === false`, ноль `Runtime.enable`, `Browser.close` флашит, нет зомби | ✅ 6/6 |
| 2 | Снимок ≤ 2000 токенов, все элементы верхнего экрана, hit-тест 99% | ✅ 5/5, попадания 100% |
| 3 | Отношение hover/click и доля времени с ненулевой скоростью как у человека | ✅ 8/8 |

---

## Хост стал архитектурной сущностью

Замер боевой машины: **2 ядра, 5.86 ГБ RAM, GPU отсутствует** (Microsoft
Hyper-V Video), экран 1536×960 при масштабе 125%, сессия RDP.

Пользователь уточнил, что продукт будет работать и на VPS без GPU, и на
домашних машинах с настоящей видеокартой, «и нигде не должен растеряться».
Это перевело наличие GPU из свойства окружения во **входной параметр
правдоподобия**:

* `core/host.py` снимает `HostProfile` и классифицирует графику;
* профиль привязан к `host_id` — переезд между классами хостов запрещён
  молча: у «того же устройства» не меняется видеокарта;
* размер флота считается из RAM и ядер. **Для этой машины — 1 профиль.**

Машина пригодна для разработки, но не для боевых прогонов.

---

## Спайк A — `navigator.webdriver`

| Конфигурация | Значение |
|--------------|----------|
| `--remote-debugging-port=41000` | **`false`** |
| `--remote-debugging-port=0` | **`true`** |

**Решение §3 п.4 подтверждено эмпирически.** Это была единственная развилка,
ошибка в которой стоила бы всего лаунчера.

Побочно: **Chrome 151 больше не пишет `DevToolsActivePort`** при явно
заданном порте. Транспорт переписан на HTTP `/json/version` с проверкой
версии сборки и PID владельца слушающего сокета.

---

## Спайк A2 — CfT ≠ обычный Chrome 🔴

Решение §3 п.2 гласило: «Тот же релизный конвейер… отличие одно и нужное».
Отличий оказалось три.

**1. UA-CH расходится с UA.**

| Источник | Значение |
|----------|----------|
| `navigator.userAgent` (CfT) | `…Chrome/151.0.0.0 Safari/537.36` |
| `userAgentData.brands` (CfT) | `Chromium`, `Not=A?Brand` |
| `userAgentData.brands` (обычный Chrome) | `Chromium`, **`Google Chrome`**, `Not;A=Brand` |

UA говорит «Chrome», client hints — «только Chromium». Проверяется одной
строкой.

**2. WebGL недоступен вовсе.** `getContext('webgl')` вернул `null` — не
SwiftShader, а именно отсутствие. С M136 Chrome не отдаёт WebGL без
аппаратного ускорения, если не передан `--enable-unsafe-swiftshader`.
Настоящий Chrome без WebGL не существует ни у кого.

**3. `TZ` не работает.** `TZ=Asia/Tokyo` → страница видит зону машины.
Механизм подмены таймзоны из `LaunchSpec` был построен на непроверенном
допущении, а §10.13 требует считать расписание в таймзоне профиля.

---

## Спайк A3 — обходные пути ✅

**UA-CH.** `Emulation.setUserAgentOverride` с полными `userAgentMetadata`
выравнивает и JS, и все десять заголовков `Sec-CH-UA*`.

Найденный по дороге дефект: `acceptLanguage: "en-US,en;q=0.9"` превращается
в `en-US,en;q=0.9;q=0.9` — Chrome добавляет q-значения сам. Такой заголовок
не отправит ни один настоящий браузер. Правило зафиксировано проверкой
в `CdpPolicy`.

**Таймзона.** `Emulation.setTimezoneOverride` работает согласованно, включая
семантику перехода на летнее время — самый частый способ поймать наивную
подмену смещения:

| Зона | `dst_delta` | Длинное имя | Верно? |
|------|-------------|-------------|--------|
| `America/New_York` | +60 | Eastern Daylight Time | ✅ северное полушарие |
| `Asia/Tokyo` | 0 | Japan Standard Time | ✅ перехода нет |
| `Australia/Sydney` | **−60** | Australian Eastern Standard Time | ✅ южное полушарие |

**WebGL.** С флагом появляется
`ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero)), SwiftShader driver)`.
Software-рендерер наблюдаем, но он существует у части реальных пользователей;
браузер вообще без WebGL — ни у кого. Флаг ставится только на хостах класса
`SOFTWARE`.

---

## Спайк B — полнота событий ввода ✅

Блокирующих ограничений нет: `isTrusted` всегда `true`, `screenX ≠ clientX`,
`movementX/Y` ненулевые, `pointerId = 1`, `pointerrawupdate` приходит.

**Главный инженерный вывод — не про траектории, а про структуру потока.**
Одна и та же траектория из 61 точки, три способа доставки:

| Способ | `pointermove` | Гистограмма `getCoalescedEvents().length` |
|--------|---------------|-------------------------------------------|
| `await` на каждую команду | 61 | `{1: 122}` |
| все команды разом | 2 | `{1:2, 11:1, 49:1, 60:1}` |
| **пачками, `timestamp` через 8 мс** | 15 | `{1:10, **2:12, 3:4, 4:3, 5:1**, 29:1}` |

Реальная мышь на 125 Гц даёт 2–8 событий на кадр. То есть **идеальная кривая,
отправленная по событию с ожиданием ответа, палится, а грубая ломаная,
отправленная правильными пачками, — нет.** Приоритет этапа 3 сместился
с биомеханики на структуру доставки.

Второе: `Input.insertText` даёт **один** `input` и **ноль** событий
клавиатуры. Это не «быстрая печать», а другой инструмент.

---

## Спайк C — качество AX-дерева Grailed ✅

Первый замер дал 63% именованных на странице поиска. Это оказался артефакт:
мерилось через фиксированные 4 секунды после `load`, а Grailed — SPA.
При честном ожидании стабильности:

| Страница | Интерактивных | Именованных | Без чекбоксов фильтра |
|----------|---------------|-------------|-----------------------|
| главная | 71 | 96% | **96%** |
| поиск | 623 | 63% | **99%** |
| каталог | 348 | 80% | **99%** |

229 безымянных узлов на поиске — чекбоксы фильтра, не нужные в сценариях
прогрева. По элементам, по которым надо кликать, дерево пригодно.

`Accessibility.getFullAXTree` **работает без `Accessibility.enable`** — на
один включённый домен меньше.

**Карточка листинга** полностью адресуема:
`role=link`, имя из содержимого (бренд, размер, название, возраст),
`DOM.getContentQuads` даёт геометрию. Класс якоря — `UserItem_link__kgEWg`,
то есть ровно тот хэш CSS-модуля, из-за которого §3 п.7 запрещает селекторы.

**Цена лежит вне якоря**, в соседнем узле контейнера (`$129 $160 19% off`).
Понятие `listing_card` в адаптере должно быть контейнером, а не ссылкой.

---

## Прокси ✅

```
Chromium --proxy-server=socks5://127.0.0.1:41000
   → локальный инбаунд (identity/forwarder.py)
   → апстрим SOCKS5 с авторизацией
   → выходной IP 142.252.8.33
```

**Fail-closed подтверждён:** после остановки форвардера браузер получает
ошибку прокси, а не идёт напрямую.

Причина, по которой форвардер нужен, подтвердилась на практике: **Chromium
не умеет SOCKS5 с авторизацией**, а `Fetch.enable` запрещён (§6).

---

## Этап 2 — приёмка восприятия

| Страница | Токенов (бюджет 2000) | Попаданий | Достижимо |
|----------|----------------------|-----------|-----------|
| каталог | 448 | 32/32 = **100%** | 1.0 |
| поиск | 270 | 26/26 = **100%** | 1.0 |
| главная | 359 | 27/27 = **100%** | 1.0 |

Пример снимка:

```
url: https://www.grailed.com/categories/outerwear  page_kind: unknown  ready: ax_stable
[e25] link "Japanese Brand Vintage Toyota Racing Ski Custom Bomber Jacket … 1 day ago" @(604,519)
[e26] button "Add to Favorites" @(685,687)
[e14] button "WOMENSWEAR" expanded=false @(509,94)
[e10] combobox "Search for anything" expanded=false @(574,35)
```

**Что пришлось переделать по дороге.** Первая версия соединяла AX-дерево
с проверкой кликабельности по округлённому прямоугольнику. Соединение
разваливалось дважды:

* `round()` в Python — банковское, `Math.round` в JS — вверх; на `.5` ключи
  расходились;
* у элементов внутри внутренних скролл-контейнеров координаты `DOMSnapshot`
  (документа) не совпадают с `getBoundingClientRect` (вьюпорта).

На странице поиска так протекли 29 кнопок свёрнутых фильтров — они попадали
в снимок как достижимые цели. Эвристика заменена точной проверкой каждого
кандидата (`DOM.resolveNode` в изолированный мир + один вызов, считающий
прямоугольник и `elementFromPoint`). Стоит два вызова CDP на узел,
ограничено пятьюдесятью пятью целями на снимок с сортировкой по близости
к центру экрана.

Известное ограничение v1: содержимое внутри iframe целями не является.
Число пропущенных документов пишется в отчёт снимка, а не прячется.

---

## Этап 3 — приёмка ввода

| Проверка | Результат |
|----------|-----------|
| Все события `isTrusted` | ✅ |
| Пачки объединения, 95-й процентиль | ≤ 12 (мода 1–2, хвост до 4) |
| Отношение наведений к кликам | ≥ 3 (у бота ≈ 0) |
| `screenX ≠ clientX`, `movementX/Y ≠ 0`, `pointerId = 1` | ✅ |
| Давление при нажатии > 0 | ✅ |
| Печать даёт `keydown`/`keyup` на символ | ✅ |
| Скачков курсора > 120 px | ≤ 2 за сессию |
| Разброс межклавишных интервалов | > 0.25 |

Размер пачки объединения задаётся не нами, а длительностью кадра рендерера.
На машине без GPU кадр длится сотни миллисекунд, и настоящая мышь дала бы
там такие же крупные пачки — поэтому порог по 95-му процентилю, а не по
максимуму.

---

## Ревью Codex

Две волны адверсариального ревью, 38 находок, все разобраны.

**Первая волна (транспорт, форвардер, дедлайн):** дедлок в цикле чтения CDP
(асинхронный обработчик события вызывал `send` и ждал ответ, который приходит
из того же цикла); штатное закрытие сокета не будило ждущие команды;
не обрабатывались `Target.targetCrashed` и `Inspector.detached`; форвардер
предлагал апстриму анонимный метод при заданных кредах; TOCTOU на портах;
неатомарная миграция.

**Вторая волна (восприятие, хранилище):** реестр `ref` содержал элементы,
срезанные бюджетом; снимок собирался из пяти источников без проверки, что
документ не сменился; группировка сворачивала кнопки действий вместе
с карточками; оценка токенов занижала кириллицу втрое; сигналы готовности
латчились навсегда, включая уровневые; невидимый элемент (`opacity: 0`)
проходил hit-тест; не хватало внешних ключей.

Отдельно: тест поймал **пятиминутное зависание** `ProxyForwarder.stop()` —
с Python 3.12.1 `Server.wait_closed()` ждёт завершения всех обработчиков,
и вызов его до отмены соединений вешал закрытие каждого профиля на
`idle_timeout`.

---

## Упаковка альфы

Продукт собран до состояния, в котором его можно отдать чужому человеку:
ярлык на рабочем столе → нативное окно (WebView2) → создание профиля,
проверка прокси, запуск профиля, задача агенту.

**Что пришлось сделать помимо кода функций.** Альфа поедет на машины, где
нет ничего из того, что здесь считалось данностью. Каждая недостающая вещь
давала невнятную ошибку глубоко внутри — человек увидел бы трассировку
вместо продукта:

| Не хватало | Что было бы видно | Что сделано |
|---|---|---|
| сборки CfT | `LaunchError` из глубины запуска | `mantia fetch-chromium` + экран первого запуска |
| UA-CH эталона | профиль представлялся бы Chromium при UA «Chrome» | проверка + подсказка `mantia capture-ua` |
| WebView2 | pywebview молча падал в MSHTML, стеклянный интерфейс разваливался | установка в `install.ps1`, запасной вариант — окно Edge |
| базы таймзон | `ZoneInfoNotFoundError` на любом IANA-имени | зависимость `tzdata` |
| свободного порта | `OSError 10048` при повторном запуске | `_free_port()` |
| консоли у `pythonw` | по иконке не происходило **ничего** | `_ensure_streams()` в логгере |

Проверки собраны в `cli/setup_wizard.py` и отдаются двумя путями: командой
`mantia check` и ручкой `/api/readiness`, по которой окно показывает экран
первого запуска. Мягкие пункты (нет ключа модели, слабая машина) не держат
запуск, но названы вслух.

**Молчащая иконка.** Самая дорогая ошибка этого этапа — и единственная,
которую нельзя было увидеть в разработке. Ярлык запускает `pythonw.exe`, у
которого консоли нет вовсе: `sys.stdout` и `sys.stderr` равны `None`.
`configure()` спрашивал у stderr `isatty()` и падал с `AttributeError` **на
импорте модуля**, раньше `main()`. Снаружи: человек жмёт иконку, и не
происходит ничего — ни окна, ни ошибки, ни строки в логе. Из консоли тот же
`pythonw.exe` наследует её потоки и работает, поэтому при разработке ошибка
не воспроизводится ни разу. Найдено запуском ярлыка мышью, а не командой;
закрыто `_ensure_streams()` в логгере (там же, где форсируется кодировка — по
той же причине: точек входа несколько) и тремя тестами.

**Ревью Codex по пути установки.** Отдельная волна ревью, нацеленная только
на чужую машину: 12 находок, все приняты и закрыты.

* Установщик не проверял коды возврата (`$ErrorActionPreference` на внешние
  программы не действует): без интернета `pip` падал, а установщик печатал
  «зависимости установлены» и доводил до нерабочего ярлыка.
* `C:\mantia` требует прав администратора. Обычный пользователь получал
  `PermissionError` посреди установки — добавлен запасной путь
  `C:\Users\<имя>\mantia`, всё ещё короткий (лимит 260 символов).
* Наличие `chrome.exe` считалось признаком установленной сборки. Он лежит в
  архиве не последним, поэтому оборванная распаковка выглядела как готовая
  сборка и попадала в пул. Распаковка идёт в отдельный каталог и переносится
  целиком, завершённость отмечается меткой.
* Битый ZIP, нехватка места и обрыв закачки давали трассировку и оставляли
  мусор; пул перезаписывался неатомарно — усечённый JSON ломал запуск совсем.
* Окно открывалось по факту старта потока, а не привязки порта: на медленной
  машине человек видел «не удалось подключиться» вместо интерфейса.
* Закрытие окна обрывало процесс, **не закрывая профили**. Осиротевший Chrome
  с чужим отпечатком, сидящий на прокси после закрытия программы, — это не
  утечка ресурса, а работающая личность без присмотра. Проверено живьём:
  14 процессов Chrome → 0, в журнале `profile.closed`, процесс вышел сам.

**Нестабильный `host_id`.** Профили привязаны к хосту внешним ключом, а
`host_id` считался в том числе от объёма RAM. На гипервизоре с динамической
памятью `ullTotalPhys` меняется на ходу — замеры на одной машине давали 4.8
и 5.9 ГБ, `host_id` разъезжался между запусками, и профили падали на внешнем
ключе. RAM убрана из хэша.

---

## Этапы 6 и 10 — Eval Lab и компилятор

**Компилятор (этап 10).** Журнал удачного прогона агента → обобщение →
YAML → реплей-гейт → канареечная ревизия. Скомпилированный YAML разбирается
**штатным парсером** DSL — это проверяется тестом, иначе компилятор
производил бы текст, который некому исполнять. Ревизия не появляется в базе,
пока не воспроизвела журнал, из которого выведена: строка в `scenarios`
доступна раннеру немедленно, и непроверенную ревизию флот взял бы сразу.

**Eval Lab (этап 6).** Корпус записанных наблюдений, реплей без браузера,
мутации вёрстки, инъекция отказов, метрика ложного успеха. Golden-набор из
пяти кейсов; один из них — **негативный контроль**: тот же подавляющий
корпус и тот же путь, но без строки `expect`. Прогон завершается «успешно»,
не сделав ничего. Метрика обязана его увидеть; метрика, всегда возвращающая
ноль, неотличима от неработающей. Замер: 5 прогонов, 0 регрессий, ровно
1 ложный успех (20%) — и это ровно тот кейс, который для него заведён.

**Живой `ScenarioDriver` — недостающее звено.** Обнаружилось при сборке:
реализации протокола существовали только для корпуса (`eval.replay`) и
журнала (`compiler.replay`), а исполнить сценарий в настоящем Chrome было
нечем. То есть скомпилированный сценарий не экономил ни одного вызова
модели, потому что не запускался в бою. Написан `runner/live_driver.py`;
семантика повторена за реплеем буквально, два неизбежных расхождения
названы в докстринге. Golden-тесты стоят ровно столько, насколько живой
драйвер ведёт себя как записанный.

**Связывание.** Движок при запуске задачи сначала ищет годную ревизию
сценария и идёт ей — без модели вообще. Отказ сценария не проваливает
задачу: управление возвращается агенту, ревизия получает отметку о падении.
Неуспешное, но **штатное** завершение (challenge, квота) агенту не
передаётся: он попробовал бы сделать ровно то, от чего сценарий отказался.

Добавлены: тик доставки результатов в приёмники (без него результаты копятся
в базе и до CSV не доезжают никогда), оценка стоимости контракта до запуска
(`/api/harvest/estimate` — замер: 2 детерминированных поля даром, 1 модельное
= 3 вызова ≈ $0.0008) и панель готовых сценариев в интерфейсе.

**Не сделано, честно.** Детектор мягкого подавления §10.12 как ряд
health-метрик во времени. Сейчас неподтверждённое ожидание даёт
`internal_error` — карантин профиля и вызов человека. Это безопасно, но
названо неточно. Правильный код `SOFT_SUPPRESSION_SUSPECTED` останавливает
**весь флот**, и вешать его на единственный несохранившийся лайк нельзя:
размыкатель срабатывает с первого сигнала, без накопления. Нужен ряд
замеров, а не одно событие.

---

## Интерфейс: из одного экрана в программу

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

Стало пять разделов:

| Раздел | Что делает |
|---|---|
| Профили | таблица, папки, поиск, массовая смена статуса, редактор на пять вкладок |
| Прокси | своя сущность: список, добавление, правка, удаление, проверка одиночная и массовая, видно кем занят |
| Отпечатки | честная карта покрытия §5.1 — 20 параметров с объяснениями |
| Задачи | агент, готовые сценарии, история прогонов |
| Настройки | ключ модели, потолок расходов, лимит вызовов, проверка готовности |

**Что нельзя менять — названо, а не спрятано.** Вкладка «Опасное» перечисляет
неизменяемые поля с причиной: сборка Chromium пиннится на жизнь профиля
(смена версии меняет отпечаток целиком), каталог профиля — состояние личности
(§10.1). Пустое поле без объяснения читается как недоделанный интерфейс.

**Карта покрытия вместо тумблеров-пустышек.** Конкуренты рисуют
переключатели «подменить canvas» и «подменить шрифты». Сделать это можно
только скриптом в странице, а он обнаруживается тривиально и у нас запрещён
архитектурно. Поэтому такие параметры показаны со статусом «не управляем» и
человеческим объяснением. Замер на этой машине: управляем 11, от браузера 4,
выдаёт нас 1 (software WebGL), не управляем 4.

**Ошибка, найденная этим же заходом.** Живой прогон после связывания
сценариев падал на **каждой** задаче: `ScenarioStore` конструировался с
неверной сигнатурой, а он создаётся до всякой проверки наличия сценария.
Юнит-тесты подсистем были зелёными, тесты интерфейса тоже — ломался ровно шов
между ними. Закрыто `tests/unit/test_engine_wiring.py`: движок обязан уметь
позвать каждую подсистему, которую обещает его API.

**История прогонов стала постоянной.** Раньше жила в памяти и исчезала с
окном, хотя нужна как раз назавтра. Теперь пишется в `runs`/`tasks`; одна
задача на пару «цель + площадка», чтобы повторный запуск копил статистику в
одной строке, а не плодил близнецов.

### Окна: свои, а не чужие

**Окна профилей.** Три открытых профиля давали три одинаковых окна
«… — Chrome for Testing». Перепутать их — значит сделать действие не от того
имени; для антидетекта это худший сценарий, и он делается руками оператора,
а не кодом. Теперь окно получает иконку продукта и заголовок
«user@grailed · grailed.com — Mantia».

Бинарь браузера при этом не трогается: его sha256 проверяется пулом сборок, и
правка ресурсов ради иконки сломала бы проверку подлинности. Иконка ставится
сообщением `WM_SETICON` уже запущенному окну. Заголовок Chrome переписывает
при каждой смене `document.title`, поэтому наблюдатель возвращает его раз в
секунду и только когда он действительно сменился.

Странице ничего из этого не видно: заголовок окна и его иконка живут в
оконном менеджере, а не в DOM, и на отпечаток не влияют.

**Окно программы.** Было с системной рамкой и потому похоже на браузер.
Стало безрамочным со своим заголовком: логотип, название раздела, кнопки
свернуть/развернуть/закрыть.

Две вещи пришлось чинить в обход pywebview:

* перетаскивание сперва сделал через `WM_NCLBUTTONDOWN` — плавнее, но
  `ReleaseCapture()` освобождает мышь **только у вызывающего потока**, а вызов
  из JS-моста приходит на другой. Перешёл на штатный `pywebview-drag-region`;
* безрамочное окно pywebview создаёт **без `WS_THICKFRAME`**, и Windows
  отказывается его растягивать в принципе. Стиль дописал — и получил светлые
  полосы сверху и снизу окна: замер показал по семь пикселей неклиентской
  области со всех сторон, которую система красит своим цветом. Проверено
  снятием стиля на живом окне: с ним поля `(7,7,7,7)`, без него `(0,0,0,0)`.

  Убрать полосы, сохранив стиль, можно только перехватом `WM_NCCALCSIZE` —
  то есть подменой оконной процедуры чужого GUI-фреймворка из Python. Лезть в
  цикл сообщений ради изменения размера — плохая сделка, поэтому стиль снят,
  а размер считается сам: край тянется за курсором, вызовы шлются не чаще
  кадра. Стоимость решения названа вслух: перетаскивание края идёт через мост
  и потому не такое плавное, как системное.

Проверено настоящей мышью: клик по своей кнопке «Свернуть» сворачивает окно,
протяжка правого края на 120 пикселей меняет ширину на 122.

**Кэш интерфейса.** WebView2 держит кэш в `storage_path` и переживает
перезапуск программы: сервер отдавал новый интерфейс, а окно показывало
старый — и это выглядело как «обновление не приехало». Теперь `Cache-Control:
no-store` на странице плюс отпечаток версии в адресе (`/?v=<хэш index.html>`),
чтобы обойти уже накопленный кэш.

### Ревью бэкенда: 18 находок, все закрыты

Три модуля (редактирование, раздел прокси, карта покрытия с настройками)
прошли адверсариальную ревизию. Найденное стоит перечислить, потому что
половина — это ровно тот класс брака, ради которого ревизия и делается:
**настройка есть, а не работает**.

* `l7_calls_per_run` и `max_profiles` были **пустышками**: интерфейс их
  показывал и сохранял, а продукт читал константу модуля. Для антидетекта это
  худший вид ошибки — человек уверен, что настроил, а поведение прежнее.
* `.env` читался из рабочего каталога, а экран настроек писал в `MANTIA_HOME`.
  При запуске с ярлыка это разные места: ни бюджет, ни лимит не доезжали.
* Оба чтения `.env` были строгими по кодировке. Файл, сохранённый блокнотом
  «в ANSI» на русской Windows, ронял чтение на первом русском комментарии.
* `clone()` создавал каталог профиля до транзакции: отказ по занятому имени
  оставлял пустой каталог навсегда.
* Отвязка профиля от прокси «старого» образца уничтожала единственную копию
  пароля.
* `check_all()` ловил только `MantiaError` при `gather` без
  `return_exceptions` — чужое исключение отменяло соседние проверки.

**Две находки чинил сам** — агентам было запрещено трогать движок:

* **без ключа модели продукт не запускался вообще.** Конструктор шлюза бросал
  исключение прямо в `startup()`, сервер не поднимался, и человек, поставивший
  программу и ещё не вписавший ключ, получал пустоту вместо экрана первого
  запуска — который создан ровно для этого случая. Теперь профили, прокси и
  настройки работают без ключа, недоступны только задачи агенту. Закрыто
  тестом: это первый экран каждого нового пользователя.
* `ProfileRepo.mark_running` писал `state='running'`, которого нет в
  CHECK-констрейнте: первый вызов упал бы с `IntegrityError`. Не падал только
  потому, что метод никто не звал — мёртвый код с миной. Удалён; «работает» и
  дальше выводится из активного прогона, а не хранится в строке, иначе после
  аварийного закрытия остался бы вечно «работающий» профиль.

---

## Плашка Chrome for Testing

Сборка CfT показывает несъёмную плашку «only for automated testing». Это
казалось косметикой, пока не замерили: плашка занимает высоту окна, а разница
`outerHeight − innerHeight` читается со страницы одной строкой.

| Браузер | Обвязка по высоте |
|---|---|
| обычный Chrome | 95 px |
| Chrome for Testing | **151 px** |
| CfT + `--disable-infobars` | 95 px |

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

`--disable-features=ChromeForTestingInfobar` проверен и **не работает** —
такого имени возможности нет. `--test-type` тоже убрал бы плашку, но он
остаётся запрещённым: это маркер автоматизации, снимающий часть проверок
безопасности. `--disable-infobars` затрагивает только интерфейс: режим
автоматизации не включает, `navigator.webdriver` не трогает, странице невидим.

Проверено на живом профиле после правки: `outerHeight 800 − innerHeight 705 =
95 px`, совпадает с обычным Chrome точно. Закреплено тестом.

---

## Изменения плана

| Пункт | Было | Стало | Основание |
|-------|------|-------|-----------|
| §3 п.2 | CfT идентичен Chrome | Не идентичен: brands без «Google Chrome» | A2 |
| §6 denylist | `Emulation.setUserAgentOverride` запрещён | RESTRICTED: полные метаданные, `acceptLanguage` без q | A3 |
| §6 denylist | `Emulation.setTimezoneOverride` запрещён | RESTRICTED: IANA-имя, до первой навигации | A2+A3 |
| `LaunchSpec` | таймзона через `TZ` | через CDP-override | A2 |
| FlagBuilder | нет флагов GPU | `--enable-unsafe-swiftshader` на хостах без GPU | A2 |
| Транспорт | endpoint из `DevToolsActivePort` | `/json/version` + проверка версии и PID | A |
| FlagBuilder | нет `--disable-infobars` | флаг обязателен: плашка CfT даёт +56 px обвязки | замер |
| Этап 3 | 11 человеко-дней в биомеханику | приоритет на структуру доставки событий | B |
| §9.3 | оценка токенов `len/4` | с поправкой на не-ASCII | ревью |

---

## Продукт перестал быть привязан к машине

Задача пришла из жизни: разработка идёт на Windows-VPS, а хочется дома на
макбуке. Отсюда вопрос, который раньше не задавался: **что вообще значит
«мой профиль» на другой машине?**

Ответ оказался неочевидным и определил всю конструкцию. Профиль состоит из трёх
разных по природе вещей, и валить их в одну «синхронизацию» нельзя:

| | Переезжает | Почему |
|---|---|---|
| Метаданные | да | килобайты; имя, площадка, регион, прокси, заметки |
| Пароли прокси | да | иначе переезд означает завести всё заново |
| **Каталог профиля** | **нет** | перенос меняет отпечаток под живой сессией |

Последняя строка — суть. Куки переехали, а железо другое: для площадки это вход
в старую сессию с нового устройства, самая заметная из возможных аномалий.
Поэтому автоматически каталог не едет **никогда**, а едет отдельным действием
по одному профилю и с предупреждением, где человек выбирает между «скопировал
руками» и «начинаю здесь заново».

### Что заложено под облако, которого ещё нет

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

Ключ устроен в два слоя: фраза → Argon2id → ключ-обёртка → **случайный ключ
аккаунта**. Смена фразы переписывает 32 байта в одной строке базы и не трогает
ничего снаружи, поэтому вчерашний пакет читается и сегодня.

### Что нашла состязательная ревизия

Каждая из этих поломок тихо портит данные и ни одна не заметна на глаз.

1. **Ключ невозможно было передать на вторую машину.** Ключ аккаунта —
   случайные байты той машины, где он заведён; макбук завёл бы свои. Фраза
   верная, пакет целый, расшифровать нечем — то есть переезд не работал ни
   разу. Лечится конвертом ключа в открытом заголовке пакета.
2. **Порча ключа докладывалась как «неверная фраза».** Человек в момент
   восстановления данных начинает перебирать фразы вместо того, чтобы поднимать
   резервную копию, — и переберёт всё, потому что фраза правильная.
3. **Два одновременных нажатия «задать фразу»** заводили два разных ключа, и
   оба сообщали об успехе. Всё, что успели зашифровать проигравшим, теряло ключ
   навсегда.
4. **Параметры вывода ключа** брались из чужого заголовка без проверки
   пределов: `memory_kib: 16777216` означало попытку занять 16 ГиБ при загрузке
   пакета, то есть продукт, снятый OOM-killer'ом вместо сообщения об ошибке.
5. **Старый пароль прокси ложился поверх нового** молча: строка прокси честно
   проигрывала слияние, а секрет применялся отдельно и без сравнения возраста.
   Видно это было бы только отказом авторизации во время сессии — то есть уже
   на аккаунте.
6. **Машина ранжировала свою строку одним ключом, а публиковала другим.** Две
   машины после одновременной правки менялись состояниями местами, конфликт не
   показывался, и само это не сошлось бы никогда.

### Замер контраста: то, что считалось починенным

Контраст текста проверялся против цвета страницы, а подписи полей лежат на
стекле, подсказки — на светлой плашке. На настоящем композите они давали 4.45 и
3.91 при норме 4.5. Проверено 387 текстовых узлов во всех разделах, модалках и
вкладках редактора; после правки ниже нормы не осталось ничего.

Отдельно: у этой машины в базе **две** записи (формула идентификатора менялась),
и список машин считал профили только по одной — «2 профиля» там, где их три.

---

## Открытое

1. **CfT против пиннутого обычного Chrome.** Подмена UA-CH работает
   полностью, но это постоянно действующий механизм, а не разовая настройка.
   Отказ от CfT означает решать проблему автообновления другим способом.
   **Нужно решение пользователя.**
2. **Боевая машина.** Текущая непригодна: нет GPU, 2 ядра, 5.9 ГБ RAM.
3. **Спайк D (IMAP)** отложен по решению пользователя, вернуться перед этапом 7.
4. **Фокус при нескольких окнах** (§10.8) не воспроизводится на машине
   с пределом в один профиль — перепроверить там, где помещается 3+.
5. **Содержимое внутри iframe** не адресуемо в v1.
6. **`--remote-debugging-pipe`** не измерен; не блокирует, так как не используется.
