# Агентный антидетект-браузер — план реализации

> **Как пользоваться этим документом.** Это самодостаточное техзадание. Вставьте его целиком
> в новый чат и напишите «начинаем» — контекст восстановится полностью, объяснять заново ничего
> не нужно. Документ прошёл проектную проработку 9 агентов-специалистов, 3 адверсариальных
> критиков (интеграция, надёжность в проде, обнаружение ботов) и независимое ревью Codex.
> Все найденные противоречия разрешены в разделе «Решения по спорным местам».
>
> Версия: 1.0 · Дата: 2026-08-04 · Целевая площадка v1: grailed.com

---

## 1. Что строим

Антидетект-браузер со встроенным ИИ-агентом. Отличие от Octo / Dolphin / AdsPower: они продают
**инфраструктуру** (изолированные профили, отпечатки, прокси) и оставляют клиенту самую трудную
часть — автоматизацию. Мы продаём **результат**: агент получает задачу словами, сам находит путь
её выполнения на сайте, выполняет на N профилях и компилирует найденный путь в дешёвый
детерминированный сценарий.

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

**Экономика.** Модель работает один раз на задачу (компиляция), дальше крутится дешёвый
исполнитель. Модель просыпается только на поломках. Без этого стоимость токенов съедает подписку.
Оговорка — см. раздел 5.2.

---

## 2. Целевой сценарий (как это выглядит для пользователя)

Открывает программу. Слева профили, справа **Задачи**. Создаёт новую:

```
Название:      Прогрев спящих аккаунтов
Что сделать:   «Это мои аккаунты, давно не заходил. Зайди, поживи на сайте как обычный
                пользователь, посмотри ленту, поищи по своим интересам, полайкай что
                понравится. Собери <какую-то статистику> и запиши в таблицу.»
Аккаунты:      вставленный список email:password
Профили:       свой прокси и отпечаток на профиль
Результат:     CSV / Google Sheets / вебхук
Расписание:    окно суток, конкретное время выбирает персона профиля
Статус:        активна / пауза
```

Жмёт «Запустить», уходит. Утром — отработавшие профили, заполненная таблица, разбор падений
одной командой.

**Состав статистики не проектируется заранее.** Это динамическая часть задачи, которую
пользователь пишет текстом. Агенту нужна общая способность «собери указанное и запиши
в указанный приёмник», а не захардкоженный набор метрик. Как это устроено — раздел 9.4.

---

## 3. Жёсткие архитектурные решения

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

| № | Решение | Почему |
|---|---------|--------|
| 1 | Python 3.11+ / asyncio, сырой CDP через `websockets` | Playwright инжектит служебный isolated world и стартует Chromium с автоматизационными флагами; Selenium+chromedriver добавляет `cdc_*` переменные в документ. Оба палятся на ровном месте |
| 2 | Бинарь — **Chrome for Testing (CfT)**, версия пиннится | Тот же релизный конвейер, что стабильный Chrome: проприетарные кодеки H.264/AAC, UA `Chrome/<ver>`, Google API keys. Отличие одно и нужное — нет автообновления. Обычный Chrome обновится сам и сменит отпечаток всех профилей одновременно; Chromium/ungoogled палится отсутствием кодеков (`MediaSource.isTypeSupported('video/mp4; codecs="avc1.42E01E")` → `false`) |
| 3 | Один OS-процесс Chrome на профиль | Дисковая изоляция, изоляция краха, process-wide `--proxy-server`, готовность к форку в фазе 2 |
| 4 | `--remote-debugging-port` — **конкретный ненулевой**, диапазон 40000–48999 | По `content/child/runtime_features.cc`: `AutomationControlled` (то есть `navigator.webdriver === true`) включается для `kEnableAutomation`, `kHeadless`, `kRemoteDebuggingPipe` и для `--remote-debugging-port=0`. Конкретный порт — не включает. Диапазон ниже эфемерного Windows (49152+) |
| 5 | **`Runtime.enable` запрещён архитектурно** | Исторически самый известный CDP-сигнал. Восприятие строится на `Accessibility` + `DOM` + `Page.getLayoutMetrics`, ввод — на `Input`. Команды `Runtime.evaluate` работают и без `enable`, если брать `executionContextId` из `Page.createIsolatedWorld` |
| 6 | Ввод — только `Input.dispatchMouseEvent` / `dispatchKeyEvent` / `insertText` | Даёт `isTrusted: true`. Запрещены `element.click()`, `scrollIntoView()`, `window.scrollBy()`, `DOM.scrollIntoViewIfNeeded` |
| 7 | Восприятие — AX-дерево + координаты, не CSS-селекторы | CSS-модули дают хэши вида `.Search_input__5czNF`, меняются каждый билд фронта |
| 8 | Плоский режим таргетов: одно WS-соединение на профиль | `Target.setAutoAttach(flatten=true)` мультиплексирует по `sessionId`. При 12 профилях — 12 сокетов вместо 120+ |
| 9 | Пароли **никогда** не попадают в контекст модели | Логин — детерминированный сценарий, значение подставляется на уровне слоя ввода из зашифрованного хранилища |
| 10 | Завершение — `Browser.close`, kill только как эскалация | Kill теряет незафлашенные Cookies (SQLite WAL без checkpoint) и оставляет `exit_type="Crashed"` в Preferences — профиль выглядит как вечно падающий браузер |
| 11 | v1 на стоковом Chromium. Форк — фаза 2 | Интерфейсы под подмену отпечатка закладываем сразу, реализацию — потом |
| 12 | Инвариант **1:1** — аккаунт ↔ профиль ↔ прокси | Два аккаунта на одном профиле дают площадке один device-fingerprint на два аккаунта, то есть прямую связку аккаунтов. Закрепить UNIQUE-констрейнтами в БД |

---

## 4. Что переделано после ревью

Первый черновик плана был разобран тремя критиками и Codex. Пять вещей изменились принципиально.

### 4.1. Сначала детерминированный сценарий, агент — потом

Было: движок → агент → компиляция сценариев.
Стало: движок → **детерминированный сценарий, стабильно проходящий 100 раз** → агент.

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

### 4.2. Не 50 профилей на машину, а 8–12

Это не «пока не осилим», это архитектурное ограничение, и вот три независимых причины.

**Дилемма троттлинга.** 30 перекрытых headful-окон на одном рабочем столе Chrome троттлит как
фоновые вкладки: таймеры замедляются, rAF деградирует. Страница видит поведение фоновой вкладки
при `visibilityState === 'visible'` и летящих в неё доверенных событиях ввода — физически
невозможная комбинация. Отключить троттлинг флагами (`--disable-background-timer-throttling`,
`--disable-renderer-backgrounding`) — получить браузер, где перекрытое неактивное окно держит
60 fps: этого не бывает в настоящем Chrome, и это измеряется страницей за 200 мс. Оба исхода
наблюдаемы из JS.

**Корреляция таймингов.** Все профили в одном event loop одного процесса. Паузы GC, всплески при
одновременном `getFullAXTree`, конкуренция за семафор — общие для всех и происходят в один и тот
же момент настенного времени. Кросс-аккаунтная корреляция временных рядов межсобытийных
интервалов — дешёвый и очень надёжный линкер, и он работает, даже если каждая отдельная сессия
статистически человечна.

**Размер анонимного множества.** 50 аккаунтов на одной машине делят: один GPU-рендерер, один
набор шрифтов, один `devicePixelRatio`, одно реальное разрешение, ноль расширений, один набор
finch-экспериментов. Площадке не нужно детектировать автоматизацию — достаточно посчитать, что
эти 50 аккаунтов сидят на одном устройстве.

**Вывод:** 12 профилей с честно одинаковым железом безопаснее, чем 50 с расходящимися заявлениями
поверх одного GPU. Больше — только несколькими машинами или несколькими интерактивными сессиями
(RDP / лёгкие ВМ), где у каждой группы есть настоящее активное окно.

### 4.3. Сценарий прогрева спящих аккаунтов переработан

В исходном виде («новый прокси + новый отпечаток + вход») он **опасен для ваших же аккаунтов**.
Валидные креды + отсутствие ранее выданного device-id + новый IP — это каноническое определение
захвата аккаунта (ATO). Вся работа над правдоподобием отпечатка проигрывает одному отсутствующему
ключу в `localStorage`.

Обязательные изменения — раздел 10.

### 4.4. Шесть подсистем, которых не было

Durable Execution, Site Adapter, Data Contract, Intervention Manager, Evaluation Lab,
Policy/Risk Engine. Раздел 7.

### 4.5. Нулевой спринт перед кодом

Девять подсистем, спроектированных независимо, дали 34 критических и высоких противоречия на
стыках: пять форматов ссылки на секрет, пять SQLite-хранилищ, девять корней Python-пакетов, три
планировщика, четыре определения «страница успокоилась». Всё это чинится за 4 дня сейчас и за
месяц рефакторинга потом. Раздел 8.

---

## 5. Что честно не будет работать в v1

Формулируйте так же перед клиентами — иначе первые же продажи создадут ожидания, которые продукт
не выполнит.

### 5.1. Полноценная эволюция отпечатка

На стоковом Chromium управляемы: `user-data-dir`, cookies, locale, timezone, размер окна,
версия бинаря, UA-CH. **Неуправляемы:** WebGL vendor/renderer, canvas, аудиоотпечаток, список
шрифтов, реальное разрешение экрана. Это требует форка. Интерфейс `FingerprintSpec` закладываем
и заполняем поле `coverage_map`, честно показывающее пользователю, что покрыто, а что нет.

### 5.2. «Модель работает один раз»

Верно для детерминированного извлечения (цена, бренд, счётчики — читаются из AX-дерева без
модели). **Неверно** для смысловых суждений о новом контенте: «полайкай что понравится»,
«оцени, подходит ли вещь под эстетику». Такие поля компилятор обязан помечать:

```yaml
runtime_inference:
  required: true
  reason: semantic_classification_of_unseen_content
  estimated_calls_per_run: 30
```

Это должно быть видно в UI и учитываться в тарификации.

### 5.3. Разведка на боевых аккаунтах

Разведочная сессия агента — самая аномальная во всей системе: модель ходит без цели, кликает и
откатывается, простаивает по 3–10 секунд между шагами (латентность модели), делает 60 навигаций
там, где человек сделал бы 10. **Нужен отдельный расходный аккаунт для компиляции сценариев.**
Пользователь должен быть об этом предупреждён явно.

### 5.4. Гарантия, что аккаунт не получит challenge

Решением пользователя вход выполняется в чистом профиле, без переноса прежних cookies (10.1).
Значит **подтверждение устройства — штатный путь, а не исключение**: площадка почти наверняка
запросит код на первом входе каждого аккаунта.

Это принятый компромисс, а не ошибка проектирования. Но он переводит подсистему получения кодов
(`CodeSource`, 10.2) из «потом» в обязательную часть v1: без неё первая же ночь упрётся в экран
подтверждения на всех аккаунтах сразу, и система начнёт накапливать серию незавершённых входов
с нового устройства — что хуже, чем не запускать автоматизацию вовсе.

---

## 6. Решения по спорным местам

Девять подсистем противоречили друг другу. Разрешено так — это нормативная часть документа.

| Спор | Решение |
|------|---------|
| Порт 0 или конкретный | **Конкретный ненулевой.** Порт 0 в FORBIDDEN-списке, падение теста при попытке |
| `Runtime.enable` | **Запрещён.** Whitelist-исключения: `Runtime.runIfWaitingForDebugger`, `Runtime.evaluate` по `contextId` из `createIsolatedWorld`. CI-тест: греп по записи CDP-команд, ноль совпадений |
| Кто владеет командной строкой Chromium | **Один `FlagBuilder`** в транспорте. Identity отдаёт декларативный `LaunchSpec`, оркестрация — только геометрию. `--disable-features` собирается как множество и эмитится ровно одним флагом (Chromium не мержит повторы — побеждает последнее вхождение) |
| Прокси: `Fetch.enable` или локальный форвардер | **Форвардер на движке Xray-core (MPL-2.0), локальный SOCKS5 на профиль** — см. 10.7. Даёт fail-closed, снимает авторизацию без CDP, даёт учёт трафика на профиль. `Fetch.enable` с `urlPattern:'*'` вдобавок пропускает каждый подресурс через Python-раундтрип и вшивает джиттер нашего event loop в `PerformanceResourceTiming` каждой картинки |
| Две реализации восприятия | **Только `PageObserver`.** Из Agent Loop удалить свой `observation.py` |
| Кто ждёт готовности страницы | **Один `ReadinessDetector`** в транспорте, публичный API `wait_ready(spec) -> ReadinessReport`. Остальные передают свой `ReadinessSpec`, но не реализуют ожидание |
| Кто выбирает точку клика | `PageObserver` отдаёт `ResolvedTarget{point, safe_rect, hit_ok}`; `HumanInput` сэмплирует **строго внутри** `safe_rect` и делает ровно один финальный hit-test перед `mousePressed` |
| Формат ссылки на секрет | Один: `secret://<profile_id>/<kind>`. Один `SecretStore`, один `SecretHandle.feed(backend)`, одна таблица. Резолвер импортируется ровно одним модулем (проверяется тестом на граф импортов) |
| Пять схем БД | **Одна.** Каноническая — `Task → Run → Job → Scenario` (только она различает уровень задачи и уровень профиля). Отдельные `.db` файлы запрещены |
| Три планировщика | Расписание задачи задаёт **окно суток**; персона профиля задаёт **распределение внутри окна**; оркестрация делает только антикластер. Непересечение окна с активными часами персоны — ошибка валидации |
| Три порога «спящего аккаунта» | Один источник — `LaunchPassport` из identity: `session_mode`, `target_minutes`, `mutation_quotas`. Agent Loop и оркестрация своих порогов не имеют |
| `Emulation.setDeviceMetricsOverride` | **Запрещён.** Размеры окна только через `--window-size`/`--window-position` и `Browser.setWindowBounds`. Иначе координаты `Input.dispatchMouseEvent` разъедутся с эмулированными метриками, и клики будут «иногда попадать в соседнюю карточку» — неотлаживаемо |
| Кто пишет в приёмник результата | **Только `SinkDispatcher`.** `op emit` в DSL = транзакционная запись в локальный outbox. Ни сценарий, ни агент не ходят в сеть к приёмнику |
| Logout в конце сессии | **Запрещён** в режимах прогрева. Каждый logout уничтожает серверную сессию и гарантирует, что завтра будет «вход с нового устройства» вместо продления. Плюс люди почти никогда не жмут «выйти» на личном устройстве |
| Один пул версий Chrome или одна | **Пул** (`chromium.pool.json` со списком разрешённых версий и sha256). Одна версия на всех = все профили меняют UA в один день |
| Бюджет модели | Все вызовы Anthropic — **через один `LLMGate`**, без исключений (тест на граф импортов: `anthropic` импортируется ровно одним модулем). Один бюджетный объект на Run + глобальный суточный потолок на продукт |
| Коды «нужен человек» | **Один enum `StopReason`** на весь продукт + таблица «сигнал детектора → StopReason → действие» |

---

## 7. Архитектура

### 7.1. Подсистемы

```
┌─────────────────────────────────────────────────────────────────┐
│  TaskModel     Задача / Запуск / Job / Сценарий, CLI, HTTP API  │
├─────────────────────────────────────────────────────────────────┤
│  Runner        очередь, планировщик, circuit breaker, watchdog  │
│  PolicyEngine  решает ДО запуска: можно ли, сколько, как долго  │
├─────────────────────────────────────────────────────────────────┤
│  DurableExec   журнал, чекпойнты, leases, идемпотентность,      │
│                transactional outbox                              │
├─────────────────────────────────────────────────────────────────┤
│  ScenarioDSL   исполнитель сценариев БЕЗ модели                 │
│  AgentLoop     разведка и компиляция С моделью                  │
│  Intervention  CAPTCHA / MFA / подтверждение устройства          │
├─────────────────────────────────────────────────────────────────┤
│  SiteAdapter   семантическая модель площадки                    │
│  PageObserver  AX-дерево → компактный снимок + геометрия        │
│  HumanInput    траектории, тайминги, скролл                     │
├─────────────────────────────────────────────────────────────────┤
│  Identity      профиль, персона, отпечаток, прокси, секреты     │
│  CDPBrowser    процесс Chrome, транспорт, домены, готовность    │
└─────────────────────────────────────────────────────────────────┘
        EvalLab — сквозной тестовый контур, вне иерархии
```

### 7.2. Шесть подсистем, добавленных после ревью

**DurableExecution.** Каждый шаг имеет стабильный `step_id`, номер попытки и результат. После
рестарта процесс продолжает с подтверждённого чекпойнта. Lease с fencing-токеном не даёт двум
воркерам продолжить один запуск. Побочные эффекты имеют ключ идемпотентности. Статусы различают
`executed` / `verified` / `committed`. **Без этого ночной рестарт Chromium либо теряет работу,
либо повторно ставит лайки и дублирует строки в таблице.**

**SiteAdapter.** Общего AX-движка недостаточно, нужен слой знаний о площадке:

```
GrailedAdapter
├── классификатор страниц: login / feed / search / listing / challenge
├── семантические понятия: search_box, listing_card, favorite, account_menu
├── признаки успешных переходов
├── известные модалки и промежуточные состояния
├── нормализация цены, бренда, URL, listing_id
└── capability matrix по версиям сценариев
```

DSL ссылается на общие понятия, адаптер реализует их через AX-кандидатов. Иначе логика поиска
одинаковых элементов расползётся по каждому сценарию.

**DataContract.** Текстовая просьба «собери статистику» компилируется в строгую JSON Schema.
Для каждого поля фиксируются источник, тип, нормализация, допустимость `null`. Каждое значение
несёт provenance: URL, время, профиль, locator, сырой текст, confidence. До запуска валидируется,
какие поля извлекаемы детерминированно, какие требуют модели на каждом прогоне.

**InterventionManager.** CAPTCHA, MFA, подтверждение почтой, подозрительный вход, блокировка,
неожиданный logout → статус `requires_intervention`, не общий `failed`. Сохраняется сессия и
диагностический пакет. **Автоматический повторный логин при challenge запрещён политикой.**
Внутри — `CodeSource` (10.2): получение кодов подтверждения из почты по IMAP, из SMS-канала
пользователя или у человека. Обязателен в v1 (см. 5.4).

**EvaluationLab.** Корпус сохранённых AX-снимков; реплей резолвера без запуска браузера; golden
tests для классификатора страниц и семантических локаторов; мутации (переименовали кнопку,
переставили блок, добавили модалку, убрали секцию); fault injection (потерянный ввод, медленная
сеть, краш таргета); сравнение старого и нового сценария. **Ключевая метрика — не completion
rate, а доля ложных успехов.** Самая опасная ошибка — сценарий завершился успешно, но собрал
не то или кликнул не туда.

**PolicyEngine.** Решает до выполнения: допустима ли длительность сессии после простоя,
соответствует ли гео профиля прокси, разрешены ли мутации, каковы лимиты действий, что можно
отправлять во внешний вебхук. Разрешает противоречие «пользователь просит 40 минут, а политика
прогрева говорит: первая сессия короткая».

---

## 8. Нулевой спринт (~4 дня, до первой строчки продуктового кода)

### 8.1. Три спайка — обязательны, каждый может обнулить неделю работы

| Спайк | Что измерить | Что зависит |
|-------|--------------|-------------|
| **A. `navigator.webdriver`** | На закреплённой сборке CfT снять значение в трёх конфигурациях: `port=0`, фиксированный ненулевой порт, `--remote-debugging-pipe` | Весь лаунчер. Если ошиблись — все профили палятся на первом же скрипте площадки |
| **B. Полнота событий ввода** | Для `Input.dispatchMouseEvent` замерить: `screenX/screenY`, `movementX/movementY`, `pointerId`, `pressure`, `getCoalescedEvents().length`, наличие `pointerrawupdate`, `isTrusted`, `document.hasFocus()` в момент события | Вся траекторная математика. Реальная мышь на 125 Гц даёт пачки по 2–8 событий на кадр; если `getCoalescedEvents()` всегда длины 1 или `screenX == clientX` — это блокирующее ограничение v1, и 11 человеко-дней в кривые Безье вкладывать бессмысленно |
| **C. Качество AX-дерева на Grailed** | Доля интерактивных элементов с непустым accessible name; есть ли `aria-pressed` на кнопке Favorite; как выглядят карточки листингов в дереве | Реализуемость `PageObserver`, `AgentLoop` и DSL в описанном виде вообще |

**Спайк D. Статус IMAP у почтовых провайдеров.** Google и Microsoft последние годы закручивали
гайки на basic auth и пароли приложений. Нужно проверить **на конкретных ящиках пользователя**:
работает ли IMAP с паролем приложения, требуется ли включение 2FA для его создания, не нужен ли
OAuth2 (XOAUTH2) вместо пароля, надо ли включать IMAP в настройках ящика. От ответа зависит,
что именно пользователю придётся настроить руками до первого запуска, и не окажется ли, что
для части ящиков автоматическое получение кодов недоступно вовсе.
*Статус: не проверено — исследование прервано на лимите. Проверять перед этапом 7.*

Плюс проверить: **есть ли на боевой машине настоящий GPU.** SwiftShader в
`WEBGL_debug_renderer_info` обесценивает боевые прогоны независимо от качества кода.

### 8.2. Фундамент, который нельзя вкрутить задним числом

1. **Один корень пакета** — `src/abx/`, одно имя продукта, один `ABX_HOME`.
2. **Общий модуль доменных типов и ошибок:** `ProfileId`, `RunId`, `JobId`, `StopReason`,
   `FailureKind`, `ResolvedTarget`, `StepEvent`, `SecretRef`, `LaunchSpec`.
3. **Одна схема БД** (раздел 9.1), одна нумерация миграций. Отдельные `.db` запрещены.
4. **Один `FlagBuilder`** + FORBIDDEN/REQUIRED/ALLOWED списки флагов.
5. **Один Policy-фасад над CDP** с исполняемым denylist методов.
6. **Единый `run_id` (ULID)** в `contextvars`, попадающий в каждую строку каждого журнала.
   Все артефакты одного прогона — под `runs/<run_id>/`.
7. **`SecretStore` + `SecretHandle`** — вынести сюда, они нужны трём подсистемам сразу.
8. **Deadline propagation** — сквозной контекст с абсолютным дедлайном сессии, из которого
   выводятся все вложенные таймауты: `asyncio.timeout(min(local, remaining))`. Иначе один
   залипший шаг стоит 45 + 60 + 3×12 + 8 ≈ 150 секунд, и сорокаминутная сессия растягивается
   на два часа — сама по себе поведенческая аномалия.
9. **Инкрементальная персистентность** — счётчики и собранные данные флашатся в БД по ходу
   сессии. Иначе падение на 280-м шаге из 300 = полная потеря статистики при уже совершённых
   действиях на площадке.

Пункты 8 и 9 пронизывают сигнатуры функций. Добавить их потом = переписать всё.

---

## 9. Модель данных и форматы

### 9.1. Схема БД (SQLite, один файл, один писатель)

```sql
-- Иерархия: Task → Run → Job → (Steps, Results)
CREATE TABLE tasks (
  id TEXT PRIMARY KEY, name TEXT NOT NULL, goal_text TEXT NOT NULL,
  harvest_spec_json TEXT, sink_id TEXT, schedule_cron TEXT, schedule_tz TEXT,
  status TEXT NOT NULL CHECK(status IN ('active','paused','paused_by_breaker','archived')),
  scenario_pinned_version INTEGER, created_at TEXT, updated_at TEXT
);
CREATE TABLE runs (            -- один запуск задачи (все аккаунты)
  id TEXT PRIMARY KEY, task_id TEXT NOT NULL REFERENCES tasks(id),
  status TEXT NOT NULL,        -- queued|running|done|partial|failed|needs_attention
  started_at TEXT, finished_at TEXT, budget_usd REAL, spent_usd REAL
);
CREATE TABLE jobs (            -- один аккаунт внутри запуска
  id TEXT PRIMARY KEY, run_id TEXT NOT NULL REFERENCES runs(id),
  profile_id TEXT NOT NULL REFERENCES profiles(id),
  status TEXT NOT NULL, stop_reason TEXT,
  login_completed_at TEXT,     -- КРИТИЧНО: маркер запрета ретрая, см. 10.4
  planned_start_at TEXT, started_at TEXT, finished_at TEXT,
  checkpoint_json TEXT, lease_owner TEXT, lease_token INTEGER, lease_expires_at TEXT
);
CREATE TABLE profiles (
  id TEXT PRIMARY KEY, account_ref TEXT UNIQUE,   -- инвариант 1:1
  proxy_id TEXT UNIQUE REFERENCES proxies(id),
  persona_json TEXT, fingerprint_json TEXT, history_json TEXT,
  behavior_seed TEXT, last_session_at TEXT, dormancy_days INTEGER,
  risk_budget_json TEXT, state TEXT               -- ready|cooldown|quarantine|manual_review
);
CREATE TABLE steps (           -- ЕДИНЫЙ журнал шагов всех подсистем
  id TEXT PRIMARY KEY, job_id TEXT NOT NULL REFERENCES jobs(id),
  seq INTEGER NOT NULL, subsystem TEXT NOT NULL, step_id TEXT,
  action_json TEXT, expectation_json TEXT, verdict TEXT,
  ax_skeleton_ref TEXT, screenshot_ref TEXT, attempt INTEGER,
  started_at TEXT, duration_ms INTEGER, error_class TEXT
);
CREATE TABLE results (         -- сначала сюда, только потом в приёмник
  id TEXT PRIMARY KEY, job_id TEXT NOT NULL, record_type TEXT NOT NULL,
  idempotency_key TEXT NOT NULL UNIQUE, payload_json TEXT NOT NULL,
  provenance_json TEXT, created_at TEXT
);
CREATE TABLE sink_deliveries ( -- transactional outbox
  id TEXT PRIMARY KEY, result_id TEXT NOT NULL REFERENCES results(id),
  sink_id TEXT NOT NULL, status TEXT NOT NULL, attempts INTEGER DEFAULT 0,
  last_error TEXT, delivered_at TEXT
);
CREATE TABLE scenarios (
  id TEXT PRIMARY KEY, site TEXT NOT NULL, name TEXT NOT NULL, revision INTEGER NOT NULL,
  yaml_path TEXT, ast_hash TEXT, status TEXT,   -- draft|canary|active|stale|needs_human
  success_count INTEGER, failure_count INTEGER, created_at TEXT
);
CREATE TABLE credentials (id TEXT PRIMARY KEY, profile_id TEXT, kind TEXT, blob BLOB);
CREATE TABLE events (id TEXT PRIMARY KEY, run_id TEXT, job_id TEXT, kind TEXT,
                     payload_json TEXT, created_at TEXT);
```

### 9.2. DSL сценариев

**Формат:** YAML 1.2 для авторства и ревью → строгая JSON Schema для валидации → канонический
JSON AST с хэшем для исполнения. Исполнять YAML напрямую нельзя (неоднозначные типы,
пользовательские теги, нестабильное хэширование). Никакого произвольного Python; выражения —
ограниченный CEL-подобный язык.

**DSL — машина состояний, а не линейный список кликов.**

**Локатор хранит семантику. `ref-ID` и координаты живут только в текущем снимке и никогда не
записываются как постоянный адрес.**

Лестница разрешения цели с деградацией:

```yaml
target:
  resolve: first_confident
  candidates:
    - concept: favorite_button_unselected      # L0 — понятие SiteAdapter
      min_score: 0.92
    - ax:                                      # L1 — роль + имя в области
        role: button
        name_regex: "(?i)like|favorite|save"
        state: {pressed: false}
        scope: {concept: listing_detail}
        min_score: 0.82
    - ax:                                      # L2 — роль + позиция
        role: button
        ordinal: 1
        scope: {concept: listing_form}
        min_score: 0.75
    - ai:                                      # L7 — модель, только на крайний случай
        intent: "кнопка добавления в избранное"
        mode: locate_only
        min_confidence: 0.93
        patch_policy: propose_and_use_once
```

**Правила лестницы:**
- AI-fallback только `locate_only` — модель находит элемент, но не решает, что с ним делать.
- Его результат становится **предложением патча**, проходит реплей и канарейку, и только потом
  входит в новую ревизию сценария.
- Лимит вызовов L7 на прогон — 5. При превышении сценарий помечается `fragile` и требует
  перекомпиляции, а не продолжает жечь токены.
- Если L7 срабатывает на каждом профиле — экономика уже сломана, это сигнал, а не режим работы.

**Семантика `optional`:** отсутствие элемента игнорируется, **ошибка действия — нет**.
`try/catch all` здесь привёл бы к ложному успеху.

```yaml
- id: dismiss_optional_consent
  optional:
    timeout: 1500ms
    ignore_only: target_not_found      # только это, не любую ошибку
    action: {click: {target: {concept: consent_accept}}}
  expect: {not: {concept_present: blocking_consent}}
  on_action_error: fail
```

**Классификация результата вместо «успех/провал»:**

```yaml
- id: classify_result
  switch:
    - when: {any: [{page_kind: auth_challenge}, {concept_present: mfa_prompt}]}
      halt: {status: requires_intervention, evidence: [url, screenshot, ax_excerpt]}
    - when: {concept_present: login_error}
      halt: {status: credential_rejected, evidence: [url, sanitized_error_text]}
    - when: {all: [{not: {page_kind: login}}, {concept_present: account_menu}]}
      goto: login_complete
    - otherwise:
        halt: {status: ambiguous_auth_result, evidence: [url, screenshot, ax_snapshot]}
```

**Полные примеры сценариев** (`grailed.login`, `grailed.activity_session`) со всеми полями —
в приложении A, файл `docs/dsl-examples.yaml`, генерируется на этапе 5.

### 9.3. Формат снимка страницы

Компактный текст, ~1200–2000 токенов на страницу. Целевой размер — жёсткий бюджет.

```
url: https://www.grailed.com/shop/xxx  page_kind: search_results  ready: ax_stable
[e12] searchbox "Search" value="stone island" focused
[e18] button "Filter"
[e24] listing_card "Stone Island Jacket · $340 · Size M"     @ (280,412)
[e25] listing_card "Stone Island Knit · $180 · Size L"       @ (520,412)
      ... ещё 22 карточки, свёрнуты (однотипные)
[e51] button "Next page"
```

Элементы приоритизируются по видимости и близости к центру экрана. Однотипные группируются.
Полный AX-скелет (≤400 узлов, zstd) сохраняется для **каждого** шага фазы разведки — это
десятки килобайт и это обязательное условие работы компилятора и самопочинки. Сырой
`getFullAXTree` — только при ошибке.

### 9.4. Динамическая статистика

Пользователь пишет текстом. Компилятор превращает в `HarvestSpec`:

```yaml
harvest_spec:
  records:
    listing_observed:
      key: [run_id, profile_id, listing_id]
      fields:
        listing_id:  {source: canonical_url, transform: grailed_listing_id}
        brand:       {source: ax_extract, concept: listing_brand, nullable: true}
        price_usd:   {source: ax_extract, concept: listing_price, normalize: currency_usd}
        liked:       {source: counter, counter_event: like}
        vibe_match:  {source: model_inference, runtime_inference_required: true}
  aggregates:
    session_summary: {listings_seen: counter, likes_added: counter, median_price_usd: agg}
```

**Правила:**
- Источник `ax_extract` и `counter` — детерминированны, модель не нужна.
- Источник `model_inference` помечает поле `runtime_inference_required` и попадает в оценку
  стоимости прогона, которая показывается пользователю **до** запуска.
- Каждый мутирующий/навигационный шаг DSL обязан объявить `records_metric` — enum
  `counter_event` замкнутый и генерируется из одного места.
- Валидация до запуска: если поле недостижимо, пользователь узнаёт об этом сразу, а не утром
  по пустой колонке.
- Каждое значение несёт provenance: url, время, locator, сырой текст.

---

## 10. Безопасность аккаунтов — обязательные правила

Этот раздел не про «хорошо бы». Каждое правило написано после конкретного сценария потери
аккаунтов. Реализуются как enforcement в коде, не как рекомендации в документации.

### 10.1. Состояние сессии: решение принято — чистый профиль

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

Что это означает практически: площадка видит валидные креды при пустом хранилище — то есть
незнакомое устройство. Ожидаемая реакция — запрос подтверждения (код на почту или SMS) на первом
входе каждого аккаунта. Обрабатывается через `CodeSource` (10.2).

Опция остаётся доступной на будущее: если состояние прежнего браузера где-то сохранится, его
можно накатить через `Storage.setCookies` и `DOMStorage.setDOMStorageItem` **до первой
навигации** — интерфейс под это в `Identity` закладывается, реализация по требованию.

**Инвариант, который остаётся в силе:** профиль никогда не пересоздаётся автоматически после
первого успешного входа. Потеря `user-data-dir` — инцидент, требующий человека. Иначе каждая
потеря профиля будет генерировать новый challenge.

### 10.2. CodeSource — получение кодов подтверждения

Единый интерфейс, три реализации. Сценарий логина не знает, откуда придёт код:

```python
class CodeSource(Protocol):
    async def get(self, account: AccountRef, issued_after: datetime,
                  timeout: timedelta) -> Code | None: ...

# ImapCodeSource   — почта по IMAP
# SmsApiCodeSource — собственный проект пользователя, SMS приходят на его домен
# ManualCodeSource — запрос человеку, всегда доступен как запасной путь
```

**Почта — только IMAP, не вход в веб-интерфейс через браузер.** Причины:

1. Вход в почтовый ящик из свежего антидетект-профиля — событие более рискованное, чем вход
   в целевую площадку, а цена ошибки выше: через почту восстанавливается всё остальное.
2. Рекурсия: агент заходит в почту с нового устройства → почтовый провайдер запрашивает
   подтверждение → код для этого подтверждения взять неоткуда.
3. Крупные провайдеры почти наверняка покажут капчу, обход которой вне границ проекта.

IMAP — штатный способ доступа, без браузера, без профиля, без капчи.

**Правила обращения с кодами** (не менее важны, чем сам механизм):

| Правило | Почему |
|---------|--------|
| Код берётся только из сообщений с `received_at > issued_after`, окно 5 минут | Иначе подхватит старый код из письма трёхмесячной давности и будет непонятно падать |
| Матчинг по получателю **и** отправителю | На общем ящике для нескольких аккаунтов иначе всё перепутается |
| Использованный код помечается, повторно не берётся | Одноразовость |
| **Не более одного запроса кода на аккаунт в сутки** | Два подряд запроса подтверждения — усиление подозрений, а не вторая попытка |
| Парсер детерминированный (regex по шаблонам площадки) | Модель — только на незнакомый формат, и её результат становится новым шаблоном, а не вызывается каждый раз |
| Пароль от почты — в том же vault, отдельным `kind`, в контекст модели не попадает | Тот же режим, что и пароль от площадки |
| IMAP ходит с **одного стабильного IP**, не через ротируемые прокси профилей | Почта — отдельная личность, ей нужна стабильность, а не разнообразие. Смена IP у IMAP-доступа сама триггерит защиту провайдера |
| Таймаут ожидания кода конечен; при истечении — `requires_intervention`, не повтор | Повторный запрос = второй сигнал площадке |

**`SmsApiCodeSource` — интеграция с SMS-каналом пользователя.** У пользователя есть собственный
проект, куда приходят SMS на его домен. Подключаемся к его API. Требования к интеграции
(выведены из разбора того, как устроены типовые SMS-шлюзы — детали подтверждены по официальной
документации):

- **Отправитель может быть буквенным** (`GRAILED`), а не номером в E.164. Логика матчинга не
  должна предполагать числовой `sender` — это классическая ошибка, которая всплывает в проде.
- **Длинные коды приходят склейкой из нескольких SMS** (concatenated multipart). Нужен
  собранный текст; если API отдаёт части — собирать самим по UDH/reference.
- **Вебхук ретраится** при неответе, то есть один и тот же код придёт несколько раз. Ключ
  идемпотентности обязателен, иначе получите дубли и путаницу с «уже использованным» кодом.
- **Подпись вебхука** проверять с constant-time сравнением; таймстемп — с окном, чтобы отсечь
  повтор старого запроса.
- **Опрос по курсору** (если API поллинговый): помнить позицию, но детектировать сброс на
  стороне шлюза (перезапуск устройства обычно меняет идентификатор сессии и обнуляет курсор) —
  иначе тихо пропустите сообщения.
- **Риск RCS.** Если площадка переведёт доставку кодов на RCS, SMS-слушатель перестанет их
  видеть — молча, без ошибки. На принимающем устройстве RCS держать выключенным, а в
  `CodeSource` иметь таймаут и внятную диагностику «код не пришёл» вместо зависания.

Что нужно от пользователя на этапе интеграции: адрес эндпоинта, формат ответа, способ доставки
(опрос или вебхук), схема подписи.

### 10.3. Один аккаунт в сутки при пробуждении

Три возврата после полугода тишины в одном временном окне из одной ASN — событие уровня
«кампания», а не «пользователь вернулся». Интервал между пробуждениями разных аккаунтов —
не менее 2–4 суток. Enforcement на входе в цикл планировщика.

### 10.4. Правила остановки

| Событие | Действие |
|---------|----------|
| `BAD_CREDENTIALS` | Терминально **для всей задачи**. Ноль повторов, ноль попыток на других аккаунтах этой ночью, уведомление человеку. Несколько неудачных входов с новых IP по разным аккаунтам — сигнатура перебора |
| Любой challenge / MFA / подтверждение | Терминальный останов, `requires_intervention`, аккаунт в cooldown на несколько суток. **Автоповтор логина запрещён** |
| `login_completed_at IS NOT NULL` | Ретрай job в тот же календарный день **запрещён**, job уходит в `partial`. Иначе: вход, обрыв прокси, повторный вход через минуту = классический паттерн перехвата сессии |
| Прерывание **внутри** шага логина | Ретрай запрещён, статус `manual_review` |
| Один `hard_ban` или challenge на любом профиле | Circuit breaker **немедленно** OPEN: отмена очереди + graceful abort всех running до совершения мутаций |

Circuit breaker подписывается на сигналы в **реальном времени**, а не на завершённые прогоны.
При равномерном старте 30 профилей за 40 минут первый прогон завершается позже, чем стартует
последний — брейкер по завершённым прогонам физически не успевает сработать.

### 10.5. Ступенчатый старт ночи

Волна 1 = один профиль, должен дойти до конца. Волна 2 = три. Волна 3 = остальные.
Первая боевая ночь — **один** аккаунт. Второй подключается через двое суток, третий — через
четверо.

### 10.6. Персона — из реальной истории аккаунта, не из seed

Первая сессия после простоя — **read-only разведка самого аккаунта**: прочитать через AX-дерево
свои favorites, подписки, размеры, историю поиска и зафитить персону под них. Seed оставить
только для моторики и ритма.

Аккаунт, который годами смотрел workwear, возвращается и начинает искать японский авангард в
другом размере — это смена поведенческого профиля, совпавшая по времени со сменой устройства и
IP. Три сигнала подряд.

Если история пуста — персона помечается `unknown` и профиль работает в минимальном режиме,
а не изобретает себе вкусы.

### 10.7. Прокси-слой

Всё в этом разделе проверено по официальной документации Chromium и исходникам движков.

**Топология: локальный SOCKS5 на профиль. Никакого TUN и никакого системного прокси.**

```
профиль #3 ─> Chromium --proxy-server=socks5://127.0.0.1:41003
                          │
                          ▼
              локальный inbound :41003 (движок)
                          │
                          ▼
              outbound: апстрим-прокси профиля #3 (любого типа)
```

TUN-режим и «системный прокси» (то, ради чего обычно смотрят на NekoRay/Throne) —
**машино-глобальные**: один wintun-адаптер = один default route на всю ОС, системный прокси =
одна настройка WinINET на пользователя. При такой схеме **все профили получают один выходной
IP** — ровно та связка аккаунтов, ради предотвращения которой строится весь продукт. Для одного
человека за одним VPN эти галочки правильные, для N личностей на одной машине — губительные.

`--proxy-server` действует на процесс, а у нас один процесс Chromium на профиль — изоляция
получается конструктивно.

**Выбор движка — определяется лицензией, а не функциональностью.**

| Движок | Лицензия | Вердикт |
|--------|----------|---------|
| **Xray-core** | **MPL-2.0** | **Основной выбор.** File-level copyleft — продукт остаётся проприетарным даже при линковке. Плюс gRPC `HandlerService` умеет добавлять/удалять outbound в рантайме без рестарта |
| 3proxy / gost | BSD-подобная / MIT | Проще и легче, если апстримы — обычные HTTP/SOCKS5 (это 95% коммерческих резидентских и мобильных прокси) |
| sing-box | GPL-3.0-or-later + оговорка про имя | Только если нужны Hysteria2 / TUIC / AnyTLS. Тогда — строго отдельным процессом, бинарь не линковать, в дистрибутиве соблюсти GPLv3 §4–6 |
| mihomo | GPL-3.0 | Та же проблема, без выигрыша. Пропускаем |
| NekoRay / Throne | GPL-3.0 | Не использовать. NekoRay архивирован; оба — GUI-обёртки с неподходящей топологией |

**Линковать `libbox` (c-shared .dll от sing-box) в продукт нельзя** — это делает GPL-3.0 весь
продукт целиком. Соблазн есть, путь тупиковый.

*Бейджам лицензий на GitHub не верить — читать файл LICENSE. У sing-box бейдж показывает
NOASSERTION из-за нестандартного файла.*

**Что проверено про Chromium и прокси:**

- **SOCKS5 резолвит DNS на стороне прокси** — утечки DNS нет. **SOCKS4 резолвит локально —
  использовать категорически нельзя.**
- **Chromium уже fail-closed по умолчанию:** он не добавляет `direct://` неявно. Умер локальный
  inbound — запрос падает с ошибкой, а не идёт напрямую. Дополнительно на движке: не заводить
  outbound типа `direct`, `route.final` направить в прокси или в `block`, DNS-серверы обязательно
  с detour на прокси.
- **Неотключаемые bypass-правила:** `localhost`, `*.localhost`, `[::1]`, `127.0.0.1/8`,
  `169.254/16`, `[FE80::]/10` всегда идут мимо прокси. Внешний IP не утекает, но учитывать надо.

**Утечка UDP — главный открытый вектор.** Проверено по документации Chromium дословно:
*«In Chrome SOCKSv5 is only used to proxy TCP-based URL requests. It cannot be used to relay UDP
traffic»*. То есть WebRTC-медиа и QUIC через SOCKS5 не пойдут — они пойдут напрямую, отдавая
реальный IP машины, одинаковый у всех профилей.

Закрывается тремя мерами, все обязательны:

1. `{"webrtc":{"ip_handling_policy":"disable_non_proxied_udp"}}` в `<UserDataDir>\Default\Preferences`
   — это **на профиль**, без реестра и enterprise-политик. Эквивалент флага
   `--webrtc-ip-handling-policy=disable_non_proxied_udp` (константа `kWebRtcIPHandlingPolicy`
   в `chrome_switches.cc`, работает и в Chrome for Testing).
2. `--disable-quic`, чтобы не ловить пограничные случаи с HTTP/3.
3. **Блокирующий приёмочный тест:** полный дамп трафика хоста при активном `RTCPeerConnection` —
   ни одного UDP-пакета мимо прокси. Не warning, а стоп-фактор для боевых прогонов.

**Фаза 2, если понадобится настоящее проксирование UDP:** попроцессный split-tunneling через WFP.
Важный нюанс — **WFP матчит по `ALE_APP_ID` (путь к exe), а не по PID**, а у всех профилей один
`chrome.exe`. Два выхода: уникальный путь бинаря на профиль, либо — изящнее —
**запускать каждый профиль под отдельной локальной учётной записью Windows** и матчить по
`ALE_USER_ID` (SID). Второе заодно даёт лучшую изоляцию вообще.

Писать свой драйвер режима ядра не нужно: месяцы работы, EV-сертификат организации и подпись
через Microsoft Partner Center. Есть готовый userspace-вариант — ProxiFyre на базе Windows Packet
Filter (драйвер уже подписан вендором), умеет попроцессно заворачивать TCP **и UDP** в SOCKS5.
Лицензия AGPL-3.0, права администратора — то есть годится как внутренний инструмент, но не для
поставки в составе продукта.

### 10.8. Управление фокусом окна

При нескольких headful-окнах фокус ОС всегда ровно у одного, а CDP доставляет ввод в обход
оконного менеджера. Страница видит доверенный `keydown` в документе, где
`document.hasFocus() === false`, и ни одного события `focus`/`blur` за всю сессию. Живой человек
не может печатать в неактивное окно — это логическое противоречие, один булев флаг.

1. `Emulation.setFocusEmulationEnabled(true)` на каждой page-сессии сразу после attach.
2. Этого мало: постоянный `hasFocus() === true` без единого `blur` за 40 минут — тоже аномалия.
   Моделировать «отвлечения»: 2–6 раз за сессию выключать эмуляцию на 20–180 секунд, при этом
   останавливая ввод и скролл.
3. Приёмочный критерий: проверка `hasFocus()` в момент каждого `keydown`/`click`.

### 10.9. Управляемая неидеальность

Сессия, безошибочная по построению, — сама по себе аномалия. 40 минут без единого лишнего клика,
без возврата назад из-за «не то открыл», без брошенного поиска — лучше, чем у любого живого
пользователя. Плюс все клики попадают строго в семантически значимые элементы (следствие
адресации по AX-дереву), тогда как человек регулярно кликает по картинке вместо ссылки и по
подписи вместо кнопки.

Отношение «наведений без клика» к «кликам» у бота ≈ 0, у человека — единицы или десятки. Это
дешёвая и устойчивая метрика, не зависящая от качества кривых Безье.

Обязательно **в v1**, не в v1.1:
- фоновый дрейф курсора и микродвижения во время пауз чтения;
- движение курсора вдоль читаемого контента при скролле;
- 3–10 «ложных» наведений на каждый клик;
- случайное выделение текста, клик в пустое место, нажатие Escape;
- с заданной вероятностью — открыть не тот элемент и вернуться через `history.back`;
- начать поиск и стереть, не отправив;
- иногда не доводить сессию до конца.

**Важно:** эти ветви должны быть в скомпилированном сценарии со своими весами. Иначе компилятор
вычистит их как «неуспешные шаги» — то есть систематически сотрёт именно человеческую часть
поведения.

### 10.10. Разнообразие точки входа

Каждая сессия с прямой навигации на главную = `document.referrer === ''` у 100% сессий флота,
`history.length === 1`, ни одной сторонней куки, пустой кеш всего кроме целевого домена.
«Однодоменный браузер» — когортный признак, не лечится микро-таймингами.

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

### 10.11. Структурное разнообразие сессий

Один скомпилированный сценарий, детерминированно проигрываемый на N профилях, даёт N сессий с
идентичной последовательностью типов действий. Кластеризация по n-граммам действий сводит их
в один кластер за один запрос к логам — независимо от того, насколько человекоподобны
микро-тайминги.

Из разведки извлекать не **один** путь, а **библиотеку рутин** с разными точками входа (лента /
категория / бренд / сохранённые / профиль продавца) и разными графами переходов. В персону
вывести поведенческий стиль: доля сессий с поиском (у части профилей — ноль), предпочитаемый
способ навигации, склонность вообще ничего не лайкать.

Приёмочный тест: сгенерировать 200 сессий, посчитать попарное расстояние по n-граммам действий.
Распределение не должно быть унимодальным.

### 10.12. Детект мягкого подавления

Современная практика площадок — не банить, а тихо снижать вес: лайки не учитываются, объявления
не показываются в поиске, сообщения не доставляются. Внешне всё зелёное: HTTP 200, элементы на
месте, ассерты проходят, таблица заполняется. Система месяцами рапортует об успехе, продолжая
жечь аккаунты.

Per-account health-метрики как временной ряд: сохраняется ли лайк после перезагрузки страницы,
виден ли собственный контент в поиске, меняется ли счётчик просмотров. Резкое изменение — событие
уровня circuit breaker, а не строка в CSV.

### 10.13. Долгосрочная регулярность

Ряд из 30 сессий «ровно один заход в сутки, ровно в вечернем окне, ровно 30–50 минут, никогда
днём, никогда дважды, никогда пропуска» — не человек. У живого пользователя число заходов в
сутки — переменная с большой дисперсией (0, 1, 5), длительность имеет тяжёлый хвост в сторону
30-секундных заходов, есть недельная и сезонная структура.

Обязательно: доля дней без сессии 30–50%, дни с 2–3 короткими заходами, редкие утренние сессии,
«отпуска» на 2–5 дней, медленный дрейф часа активности. Расписание считается **в таймзоне
профиля** (по гео прокси), а не пользователя.

---

## 11. Порядок работ

Оценки — человеко-дни для одного разработчика с ИИ-помощником. Итог по подсистемам занижен
примерно на 15–20% за счёт неучтённой интеграционной работы; закладывайте запас.

### Этап 0 — Нулевой спринт · 4 дня

Три спайка (8.1) + фундамент (8.2).

**Готово, когда:** есть репозиторий с единой раскладкой, общими типами, схемой БД и миграциями;
три спайка дали письменные ответы, зафиксированные регрессионными тестами.

### Этап 1 — Транспорт · 8 дней

`FlagBuilder` → `ChromeProcess` (+ Job Object на Windows) → `CDPConnection` → `DomainActivator`
→ `ReadinessDetector`.

**Готово, когда:** запускается профиль, `navigator.webdriver === false` (hard fail при ином),
в записи CDP-команд ноль вхождений `Runtime.enable`, `Browser.close` корректно флашит профиль,
падение процесса не оставляет зомби.

### Этап 2 — Восприятие · 10 дней

`PageObserver`: AX-дерево, фильтрация видимого, `GeometryResolver` с hit-тестом, `RefRegistry`,
компактный рендер снимка, бюджет токенов, сохранение AX-скелетов.

**Готово, когда:** снимок страницы каталога Grailed укладывается в 2000 токенов и содержит все
интерактивные элементы верхнего экрана; `resolve(ref)` даёт точку, по которой hit-test попадает
в нужный элемент в 99% случаев.

### Этап 3 — Ввод · 7 дней

`HumanInput` поверх готового `ResolvedTarget`. Траектории, тайминги, скролл — **упрощённые**,
несколько устойчивых параметризованных распределений вместо полной биомеханики. Плюс
обязательный idle-слой из 10.9 и управление фокусом из 10.8.

**Готово, когда:** на локальном detector-стенде отношение hover/click и доля времени с ненулевой
скоростью курсора сопоставимы с записью реальной человеческой сессии.

### Этап 4 — Site Adapter + первый детерминированный сценарий · 10 дней

`GrailedAdapter` (классификатор страниц, понятия, нормализация) + минимальный исполнитель DSL +
сценарий логина + три перехода feed → search → listing.

**Готово, когда** (это главный критерий проекта):

> Один узкий сценарий **100 раз подряд** корректно завершается, восстанавливается после
> принудительного убийства Chromium и **ни разу** не даёт ложный успех или повторный побочный
> эффект.

Пока это не выполнено, дальше идти нельзя. Всё остальное будет строиться на песке.

### Этап 5 — Durable Execution + минимальный TaskModel · 8 дней

Журнал, чекпойнты, leases, идемпотентность, outbox. CLI: создать профиль, создать задачу,
запустить, статус, лог. Приёмник — только CSV.

**Готово, когда:** `kill -9` посреди сессии оставляет валидный частичный результат, повторный
запуск не дублирует ни строк, ни действий на площадке.

### Этап 6 — Evaluation Lab · 6 дней

Корпус AX-снимков, реплей резолвера без браузера, golden tests, мутации, fault injection,
метрика ложного успеха.

**Готово, когда:** любое изменение в адаптере или DSL проверяется без единого обращения к живому
аккаунту.

Раньше агента — сознательно. Иначе каждое изменение в агенте проверяется аккаунтами.

### Этап 7 — Runner + Policy Engine + Intervention + CodeSource · 13 дней

Очередь, планировщик с персоной, ступенчатый старт, circuit breaker на сигналах реального
времени, watchdog-процесс, recovery, команда `abx digest <date>` для утреннего триажа.

**Готово, когда:** ночной прогон на 3 виртуальных профилях с искусственными отказами (смерть
прокси, зависший рендерер, challenge на втором профиле) заканчивается корректно, и утром одна
команда показывает «7 падений = 2 причины».

Оркестрация и предохранители **раньше** агента: иначе первая же ночь на живых аккаунтах станет
и первым тестом всех предохранителей одновременно.

### Этап 8 — Identity: профили, персоны, прокси · 12 дней

Профиль как долгоживущая сущность, персона из истории аккаунта, `LaunchPassport`, прокси-форвардер
с fail-closed, WebRTC-политика, `SecretStore`.

**Готово, когда:** дамп трафика при активном `RTCPeerConnection` не содержит ни одного пакета
мимо туннеля; профиль переживает перезапуск машины без потери сессии.

### Этап 9 — Agent Loop · 12 дней

Цель словами → наблюдение → действие → проверка. Бюджеты, таксономия ошибок, сжатие истории,
детект зацикливания, `LLMGate` как единственная точка вызова API.

**Готово, когда:** «зайди, найди куртку Stone Island, поставь лайк» отрабатывает на расходном
аккаунте и оставляет журнал в формате, который прямо потребляет компилятор.

### Этап 10 — Компилятор и самопочинка · 15 дней

Журнал агента → сценарий. Обобщение (конкретный товар → «любой товар»), несколько исходных
состояний, отрицательные примеры. Детект поломки, починка, реплей-гейт, канарейка, промоушен
ревизии.

**Готово, когда:** сценарий, скомпилированный из разведки, проходит на трёх разных состояниях
аккаунта; искусственно сломанный шаг чинится и новая ревизия проходит канарейку.

### Этап 11 — Data Contract, приёмники, HTTP API · 8 дней

Текст → JSON Schema, provenance, Google Sheets и вебхук через outbox. Локальный HTTP API.

### Этап 12 — Десктопный UI · 15+ дней

Tauri. Экраны: профили, задачи, запуски, живой лог, разбор падения. Раньше — не нужен.

---

**Итого до первого боевого результата (этапы 0–8): ~75 дней.**
**Полный v1 с агентом и компилятором: ~115 дней.**
**С UI: ~130 дней.**

---

## 12. Экономика

| Статья | Оценка |
|--------|--------|
| Компилирующая сессия (разведка) | $1.5–4 за сценарий, разово |
| Прогон по готовому сценарию, детерминированные поля | ~$0 |
| Прогон с `runtime_inference_required` полями | зависит от числа вызовов, показывать пользователю **до** запуска |
| Ремонт сломанного сценария | $0.3–0.5 за вызов, single-flight на весь флот |

**Обязательные предохранители:**
- Глобальный суточный бюджет на продукт с жёстким стопом и деградацией активных сессий
  в `graceful_exit`. Не на прогон и не на задачу — общий потолок.
- Лимит вызовов L7-локатора на прогон (5).
- Single-flight на ремонт: первый профиль чинит, остальные ждут **с таймаутом 60 с** и
  гарантированным `set_exception` в `finally`. Никогда не висеть бесконечно.
- Пока идёт ремонт — новые профили не стартуют.
- Два неуспешных ремонта подряд → задача в `paused_by_breaker`.
- Алерт на `cache_read_input_tokens == 0` — тихий инвалидатор префикса удорожает каждый шаг
  в разы и иначе незаметен.

**Измерить фактический `p` шага на первых 10 канареечных прогонах** (доля шагов с
`verdict=satisfied` без ретраев) и пересчитать всю экономику под реальное число. При `p = 0.995`
и 300 шагах до конца доходит 22% сессий — то есть 78% будят модель, и «$0 за реплей»
превращается в совсем другую цифру.

---

## 13. Окружение

**Машина:** Windows Server, пользователь Administrator. Разработка на macOS, боевые тесты на
Windows.

**Ставить:** Python 3.11+, Git, Chrome for Testing (версии из `chromium.pool.json` с sha256),
ffmpeg (для виртуальной камеры позже), Visual C++ Build Tools.

**Менеджер пакетов:** `uv`.

**Ключевые зависимости:** `websockets`, `anthropic`, `pydantic` + `pydantic-settings`,
`aiosqlite`, `cryptography` + `pywin32` (DPAPI), `structlog`, `typer`, `orjson`, `zstandard`.

**Раскладка:**

```
src/abx/
├── core/          типы, ошибки, ID, конфиг, логирование
├── cdp/           FlagBuilder, ChromeProcess, Connection, DomainActivator, Readiness
├── observe/       PageObserver, RefRegistry, GeometryResolver, SnapshotRenderer
├── input/         HumanInput, траектории, тайминги, idle-слой, фокус
├── sites/         SiteAdapter, grailed/
├── scenario/      DSL: схема, парсер, AST, исполнитель, компилятор, healer
├── agent/         AgentLoop, инструменты, промпты, LLMGate
├── identity/      Profile, Persona, Fingerprint, Proxy, SecretStore
├── durable/       журнал, чекпойнты, leases, outbox
├── runner/        очередь, планировщик, breaker, watchdog, supervisor
├── policy/        PolicyEngine, риск-бюджеты аккаунтов
├── intervention/  challenge, MFA, почта (позже)
├── storage/       БД, миграции, репозитории
├── sinks/         CSV, Sheets, webhook, SinkDispatcher
├── api/           локальный HTTP API
└── cli/           команды
tests/
├── unit/
├── replay/        корпус AX-снимков, golden tests
├── chaos/         fault injection
└── detector/      локальный стенд для проверки человекоподобности
docs/
data/              ABX_HOME: profiles/, runs/, scenarios/, abx.sqlite3
```

**Грабли Windows (учесть заранее):**
- `ProactorEventLoop` — поведение subprocess и сигналов отличается от Unix, нет `SIGTERM`.
- Кодировка консоли: русские логи в `cp1251` → мусор. Форсировать UTF-8.
- Длинные пути >260 символов: профили Chromium легко упираются, включить long paths.
- Антивирус на папке профилей — отдельная боль, добавить исключение.
- Job Object обязателен: без него при смерти оркестратора остаются десятки осиротевших процессов,
  держащих `user-data-dir` и порты.
- Headful-Chromium и отключённая RDP-сессия: при разрыве RDP рабочий стол блокируется, окна
  перестают рендериться. Нужна либо постоянная консольная сессия, либо `tscon`-переброс.

---

## 14. Открытые вопросы к пользователю

Не блокируют этапы 0–4, но нужны до этапа 8.

**Закрыто:**

- ~~Состояние прежнего браузера~~ → решено: заходим в чистом профиле, риск принят (10.1).
- ~~Расходный аккаунт для разведки~~ → есть, пользователь предоставит и залогинится сам.
- ~~Доступ к почте~~ → есть. Реализация — IMAP + собственный SMS-канал пользователя (10.2).

**Открыто:**

1. **Прокси.** Не куплены. Тип (резидентские / мобильные / датацентр), провайдер, гео. От гео
   зависит locale, timezone и расписание профилей.
2. **Какие почтовые провайдеры** у ящиков, привязанных к аккаунтам (Gmail / Outlook / Яндекс /
   свой домен). От этого зависит, что именно придётся включать в настройках почты — см. спайк D.
3. **API вашего SMS-проекта:** адрес эндпоинта, формат ответа, доставка опросом или вебхуком,
   схема подписи.
4. **Есть ли на боевой машине настоящий GPU.** Спайк из 8.1, но проверить стоит сразу.
5. **Не используют ли аккаунты вход через Google/Apple.** Тогда пары email:password не
   существует, и попытки входа паролем сами по себе создают шум.
6. **API-ключ Anthropic** — без него агентский луп не запустить.

---

## 15. Как начать

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

Не начинайте с транспорта, не начинайте с агента, не начинайте с UI.

---

## Приложение. Граница проекта

Строим универсальный инструмент автоматизации браузера и управления профилями.

**Не строим:** обход CAPTCHA, обход liveness/KYC-проверок, подмену видеопотока для верификации
личности, автоматическую массовую регистрацию аккаунтов.

Логин в собственные аккаунты пользователя, автоматизация рутины и управление отпечатками —
обычная часть задачи.
