diff --git a/docs/features/CLAUDE.md b/docs/features/CLAUDE.md
index 2e26b4e8..b777aab9 100644
--- a/docs/features/CLAUDE.md
+++ b/docs/features/CLAUDE.md
@@ -36,8 +36,10 @@ docs/features/
## Реестр фич
-| Фича | Путь | Краткое описание |
-|------|----------------------------------|-------------------------------------------------------------|
-| cart | [cart/CLAUDE.md](cart/CLAUDE.md) | Корзина: наполнение, просмотр, передача в оформление заказа |
+| Фича | Путь | Краткое описание |
+|----------------|--------------------------------------------------------|--------------------------------------------------------------------------|
+| cart | [cart/CLAUDE.md](cart/CLAUDE.md) | Корзина: наполнение, просмотр, передача в оформление заказа |
+| settings | [settings/CLAUDE.md](settings/CLAUDE.md) | Каркас настроек: форма в админке → хранение → чтение на backend |
+| telecart-pulse | [telecart-pulse/CLAUDE.md](telecart-pulse/CLAUDE.md) | Связка с SaaS: события кампаний и heartbeat (телеметрия окружения) |
diff --git a/docs/features/settings/CLAUDE.md b/docs/features/settings/CLAUDE.md
new file mode 100644
index 00000000..e8a1f470
--- /dev/null
+++ b/docs/features/settings/CLAUDE.md
@@ -0,0 +1,88 @@
+# Настройки модуля
+
+## Назначение
+
+Сквозная подсистема настроек модуля: форма в админ-панели, хранение значений и чтение их
+на backend. Это **каркас**, в который встраиваются остальные фичи — когда фиче нужен свой
+флаг/параметр, она добавляет поле в этот пайплайн, а не строит собственный механизм.
+
+## Точки входа
+
+| action | Метод | Где объявлен |
+|--------------------|------------------------------------|---------------------|
+| `getSettingsForm` | `SettingsHandler::getSettingsForm` | `bastion/routes.php` |
+| `saveSettingsForm` | `SettingsHandler::saveSettingsForm`| `bastion/routes.php` |
+| `resetCache` | `SettingsHandler::resetCache` | `bastion/routes.php` |
+| `getSystemInfo` | `SettingsHandler::getSystemInfo` | `bastion/routes.php` |
+
+## Ключевые файлы по слоям
+
+- **Дефолты:** `module/oc_telegram_shop/upload/oc_telegram_shop/configs/app.php` — структура
+ всех настроек с дефолтными значениями (секции `app`, `telegram`, `metrics`, `store`,
+ `orders`, `texts`, `sliders`, `mainpage_blocks`, `pulse`, `cron`).
+- **Handler (админка):** `module/.../bastion/Handlers/SettingsHandler.php` —
+ `getSettingsForm()` отдаёт срез конфига (`Arr::getWithKeys` по списку секций),
+ `saveSettingsForm()` сохраняет тот же срез.
+- **Сохранение:** `module/.../bastion/Services/SettingsService.php` — `update()` пишет в
+ OpenCart-таблицу `oc_setting` через `model_setting_setting->editSetting('module_telecart',
+ ['module_telecart_settings' => $data])`, синхронизирует статус модуля `module_tgshop` и
+ очищает кэш.
+- **Чтение (backend):** `Openguru\OpenCartFramework\Config\Settings` — `get('a.b.c')` /
+ `getAll()`. Расширение для витрины — `App\Services\SettingsService` (наследует `Settings`)
+ и DTO в `src/DTO/Settings/` с типизированными геттерами (`is*()`, `get*()`).
+- **Сборка конфига:** `module/.../bastion/ApplicationFactory.php` (и точка входа
+ `admin/controller/extension/module/tgshop.php`) — дефолты из `configs/app.php` мержатся с
+ сохранёнными `module_telecart_settings` через `Arr::mergeArraysRecursively`, результат
+ отдаётся в `Application`.
+- **Фронтенд (стор):** `frontend/admin/src/stores/settings.js` — Pinia setup-store: дефолтный
+ state, `fetchSettings()` (`getSettingsForm`), `saveSettings()` (`saveSettingsForm`).
+- **Фронтенд (поля):** `frontend/admin/src/components/Settings/` — `ItemBool` (toggle),
+ `ItemInput`, `ItemSelect`, `ItemImage`, `ItemToggleButton`. `ItemBool` →
+ `Switcher` → `ToggleSwitch` (PrimeVue). Используются во `views/*View.vue`.
+
+## Поток данных
+
+Чтение формы:
+`getSettingsForm` → `Settings::getAll()` (уже смерженный конфиг) → срез по секциям →
+JSON → стор `fetchSettings()` → поля во `*View.vue`.
+
+Сохранение:
+поле (`v-model`) → стор `saveSettings()` → `saveSettingsForm` → `SettingsService::update()`
+→ `oc_setting` (`module_telecart_settings`) + сброс кэша.
+
+Чтение на backend:
+дефолты `configs/app.php` ⊕ сохранённые значения → `Application` → `Config\Settings` / DTO
+→ сервисы (через DI-провайдеры, которые инжектят `getConfigValue('...')` в конструкторы).
+
+## Как добавить новый toggle (чек-лист)
+
+1. **Дефолт:** добавить ключ в нужную секцию `configs/app.php` (для булева — `true`/`false`).
+2. **Backend-потребитель:** прочитать значение там, где оно нужно. Идиома модуля — читать в
+ ServiceProvider (`$app->getConfigValue('секция.ключ')`) и **инжектить** в конструктор
+ сервиса (не дёргать конфиг из глубины класса). При наличии DTO — добавить геттер.
+3. **Стор:** добавить ключ с дефолтом в соответствующую секцию state в
+ `frontend/admin/src/stores/settings.js`.
+4. **View:** добавить компонент поля (для тумблера — `…текст-подсказка…`) в нужный
+ `*View.vue`.
+5. Убедиться, что секция уже перечислена в списках `Arr::getWithKeys` в `getSettingsForm`/
+ `saveSettingsForm` (если добавляешь поле в новую секцию — добавить и саму секцию в оба
+ списка).
+
+## Связанные фичи
+
+- [telecart-pulse](../telecart-pulse/CLAUDE.md) — heartbeat использует этот каркас:
+ флаг `pulse.heartbeat_enabled` проходит весь путь от формы до backend-гейта.
+
+## Нюансы и подводные камни
+
+- **PHP 7.4** в backend-коде (без `match`, промоушена свойств, union-типов и т.п.).
+- `getSettingsForm`/`saveSettingsForm` работают строго по **белому списку секций**
+ (`Arr::getWithKeys`). Поле вне перечисленных секций не отдастся и не сохранится.
+- Секция `cron` содержит read-only поля (`cli_path`, `last_run`), которые добавляются при
+ выдаче и вырезаются перед сохранением — не сохраняй вычисляемые значения.
+- `saveSettingsForm` сейчас вызывает пустой `validate()` — серверной валидации значений
+ настроек по сути нет; не полагайся на неё.
+- После сохранения кэш сбрасывается целиком (`SettingsService::update` → `cache->clear()`).
+- Существующие установки не имеют сохранённого значения нового ключа — оно берётся из
+ дефолта `configs/app.php` (мерж). Выбирай дефолт так, чтобы поведение «как было».
diff --git a/docs/features/telecart-pulse/CLAUDE.md b/docs/features/telecart-pulse/CLAUDE.md
new file mode 100644
index 00000000..40a6880b
--- /dev/null
+++ b/docs/features/telecart-pulse/CLAUDE.md
@@ -0,0 +1,78 @@
+# TeleCart Pulse
+
+## Назначение
+
+Связка модуля с внешним SaaS `pulse.telecart.pro`. Включает две концептуально разные части:
+
+- **События кампаний** (`WEBAPP_OPEN`, `ORDER_CREATED`, …) — аналитика маркетинговых
+ кампаний и Telegram-рассылок. Завязана на действия продавца: событие сохраняется и
+ отправляется только если в `start_param` пришли `campaign_id` и `tracking_id`, и только
+ при заданном `pulse.api_key`.
+- **Heartbeat** — раз в час отправляет технические сведения об установке (домен, имя бота,
+ версии PHP/модуля/OpenCart). Используется для статистики установок и техподдержки.
+ Управляется **отдельным** переключателем `pulse.heartbeat_enabled` (по умолчанию
+ **выключен**, opt-in), независимым от `pulse.api_key`.
+
+В коде обе части живут в namespace `Openguru\OpenCartFramework\TeleCartPulse`.
+
+## Точки входа
+
+| action | Метод | Где объявлен |
+|-------------|------------------------------|------------------|
+| `ingest` | `TelemetryHandler::ingest` | `src/routes.php` |
+| `heartbeat` | `TelemetryHandler::heartbeat`| `src/routes.php` |
+
+Триггерятся из SPA (`frontend/spa/src/stores/Pulse.js`, вызов при инициализации WebApp в
+`frontend/spa/src/main.js`). Heartbeat кэшируется на 1 час (`telecart_pulse_heartbeat`).
+
+## Ключевые файлы по слоям
+
+- **Handler:** `module/.../src/Handlers/TelemetryHandler.php` — `ingest()` (события) и
+ `heartbeat()` (телеметрия окружения); оба делегируют в `TeleCartPulseService`.
+- **Сервис:** `module/.../framework/TeleCartPulse/TeleCartPulseService.php` —
+ `handleIngest()` (валидация campaign/tracking, сохранение события в БД) и
+ `handleHeartbeat()` (сбор окружения, подпись, POST). В начале `handleHeartbeat()` —
+ гейт `if (! $this->heartbeatEnabled) return;`.
+- **Отправка событий:** `module/.../framework/TeleCartPulse/TeleCartPulseEventsSender.php` —
+ POST накопленных событий на `/events` с `Authorization: Bearer `.
+- **Регистрация в DI:** `module/.../framework/TeleCartPulse/TeleCartPulseServiceProvider.php`
+ — читает `pulse.api_key`, секрет подписи (`pulse.heartbeat_secret` / env
+ `PULSE_HEARTBEAT_SECRET`) и `pulse.heartbeat_enabled`, инжектит в сервисы.
+- **Модель/данные:** `module/.../framework/TeleCartPulse/TeleCartEvent.php`, таблица
+ `telecart_events` (миграция `database/migrations/20260101000010_create_telecart_events_table.php`).
+- **Расписание:** `configs/schedule.php` — `TeleCartPulseSendEventsTask` каждые 10 минут;
+ CLI-аналог — `console/Commands/PulseSendEventsCommand.php` (`pulse:send`).
+- **Конфиг/дефолты:** `configs/app.php`, секция `pulse` (`api_key`, `heartbeat_enabled`,
+ `batch_size`, `max_attempts`).
+- **Фронтенд (админка):** `frontend/admin/src/views/TeleCartPulseView.vue` — поля настроек,
+ включая переключатель heartbeat (`ItemBool`) с раскрытием состава отправляемых данных;
+ дефолты в `frontend/admin/src/stores/settings.js` (секция `pulse`).
+
+## Поток данных
+
+**События:** SPA `ingest` → `handleIngest()` → проверка `api_key` + наличия
+`campaign_id`/`tracking_id` → запись в `telecart_events` (status `pending`) → каждые 10 мин
+`TeleCartPulseSendEventsTask` → `TeleCartPulseEventsSender::sendEvent()` → POST `/events`.
+
+**Heartbeat:** SPA `heartbeat` → `handleHeartbeat()` → гейт `heartbeat_enabled` → кэш-чек
+(1 ч) → `getMe()` → payload окружения → подпись (если есть signer) → POST `/heartbeat`.
+
+## Связанные фичи
+
+- [settings](../settings/CLAUDE.md) — переключатель heartbeat и `api_key` проходят через
+ общий каркас настроек.
+
+## Нюансы и подводные камни
+
+- **PHP 7.4** в backend-коде.
+- **Heartbeat и Pulse — независимы.** `pulse.heartbeat_enabled` НЕ завязан на `api_key`.
+ Heartbeat реально уходит только при `heartbeat_enabled = true` **и** наличии signer
+ (секрет настроен). Выключенный тумблер → ранний `return`, даже кэш не ставится.
+- **События гейтятся `api_key`:** при пустом `pulse.api_key` `handleIngest()` выходит сразу,
+ ничего не сохраняется и не отправляется.
+- **Прозрачность heartbeat — обязательна.** Состав отправляемых данных раскрыт покупателю в
+ `TeleCartPulseView.vue`; при изменении полей payload в `handleHeartbeat()` синхронизируй
+ текст раскрытия.
+- Дефолт `heartbeat_enabled = false` (opt-in): без явного включения покупателем heartbeat не
+ отправляется; существующие установки без сохранённого значения тоже ничего не шлют.
+- Тест гейта: `tests/Unit/Framework/TeleCartPulse/TeleCartPulseServiceHeartbeatTest.php`.
diff --git a/docs/superpowers/specs/2026-06-30-heartbeat-honest-opt-out-design.md b/docs/superpowers/specs/2026-06-30-heartbeat-honest-opt-out-design.md
index e5f29036..48c4657d 100644
--- a/docs/superpowers/specs/2026-06-30-heartbeat-honest-opt-out-design.md
+++ b/docs/superpowers/specs/2026-06-30-heartbeat-honest-opt-out-design.md
@@ -16,9 +16,11 @@
- **Прозрачность:** в админ-панели покупатель видит, что именно собирается, куда
отправляется и зачем.
-- **Контроль:** отдельный переключатель, которым покупатель может выключить heartbeat.
-- **По умолчанию включено** (текущее поведение сохраняется, но становится видимым и
- управляемым).
+- **Контроль:** отдельный переключатель, которым покупатель управляет heartbeat.
+- **По умолчанию выключено (opt-in).** Без явного включения покупателем на сервер ничего не
+ уходит — в том числе у существующих установок. (Изначально планировался opt-out с дефолтом
+ «включено»; решение пересмотрено в пользу opt-in из-за юридических рисков сбора
+ идентифицирующих данных — домена и имени бота — без согласия.)
## Границы (scope)
@@ -62,12 +64,12 @@
### 1. Backend
**`module/.../configs/app.php`** — секция `pulse` (строки 86–90): добавить флаг с дефолтом
-`true`.
+`false` (opt-in).
```php
'pulse' => [
'api_key' => '',
- 'heartbeat_enabled' => true, // новый флаг
+ 'heartbeat_enabled' => false, // новый флаг, opt-in
'batch_size' => 50,
'max_attempts' => 3,
],
@@ -106,25 +108,29 @@ public function handleHeartbeat(): void
- перечислить, что отправляется: домен, имя бота, версия PHP, версия модуля, версии
OpenCart;
-- указать адрес назначения: `pulse.telecart.pro`;
+- получателя назвать обобщённо — «разработчику» (без адреса сервера и периодичности: они не
+ важны покупателю);
+- явно указать, что персональные данные покупателей и заказы **не** передаются;
- описать цель мягко: для улучшения продукта и техподдержки (без акцента на проверке
- лицензии).
+ лицензии);
+- отметить, что по умолчанию отправка отключена и её можно включить/отключить в любой момент.
-Точную формулировку владелец отредактирует; черновик в нейтральном тоне готовит реализация.
+Итоговая формулировка согласована с владельцем (нейтральный тон, вариант с оговоркой про
+отсутствие персональных данных).
### 3. Поведение для существующих установок
-Дефолт `true` в конфиге мержится с сохранёнными настройками
+Дефолт `false` в конфиге мержится с сохранёнными настройками
(`ApplicationFactory`: `Arr::mergeArraysRecursively`). У установок без сохранённого значения
-heartbeat остаётся включённым — поведение не меняется, но теперь переключатель виден.
-Когда покупатель выключает тумблер и сохраняет, значение пишется в `oc_setting`
+heartbeat **выключен** — на сервер ничего не уходит, пока покупатель явно не включит тумблер.
+Когда покупатель сохраняет настройку, значение пишется в `oc_setting`
(`module_telecart_settings`) и читается на backend.
### 4. Гейтинг — итоговая логика
heartbeat реально отправляется только если **одновременно**: `heartbeat_enabled = true`
-**и** есть signer (секрет настроен). Переключатель — пользовательский контроль поверх
-существующего серверного гейта; они независимы.
+(покупатель включил) **и** есть signer (секрет настроен). Переключатель — пользовательский
+контроль поверх существующего серверного гейта; они независимы.
## Тестирование
@@ -161,8 +167,7 @@ Unit-тест на `TeleCartPulseService::handleHeartbeat()`:
## Явно вне scope (YAGNI)
-- Не делаем opt-in (по умолчанию остаётся включено).
-- Не делаем отдельный consent-баннер на первой установке (достаточно видимого
- переключателя с раскрытием).
+- Делаем opt-in (по умолчанию выключено); отдельный consent-баннер на первой установке не
+ нужен — достаточно выключенного по умолчанию переключателя с раскрытием.
- Не трогаем события кампаний и рассылки Pulse.
- Не выносим heartbeat в отдельный namespace/фичу из `TeleCartPulse`.