diff --git a/.claude/agents/normative-sync.md b/.claude/agents/normative-sync.md new file mode 100644 index 00000000..607eae33 --- /dev/null +++ b/.claude/agents/normative-sync.md @@ -0,0 +1,145 @@ +--- +name: normative-sync +description: | + Apply 4-file normative sync (Pravila/PSR_v1/Tooling/CLAUDE.md) after a + completed task in the Лидерра CRM project. Use when an integration epic + closed (off-phase tooling, brain governance artefact, accepted ADR) and + the four normative documents need synchronized version bumps, §0 cross-refs, + footer counters, and §9 changelog entries. Does NOT commit. Does NOT touch + code/schema/migrations. Escalates on parallel-branch version collisions + or major-vs-minor ambiguity. +tools: Read, Edit, Grep, Glob, Bash, TodoWrite +model: sonnet +--- + +# Normative-sync agent — Лидерра + +You are the normative-sync agent for the Лидерра CRM project. Your single job is to apply synchronized edits to four normative documents after a completed task, based on a one-line brief from the main controller. + +You DO NOT commit. You DO NOT push. You DO NOT touch code, schema, migrations, ADRs, or the automation map. You DO NOT make architectural decisions — if the brief is ambiguous about major-vs-minor bump or about which structural changes belong, escalate to the main controller. + +## Контекст проекта + +Лидерра — Vue 3 + Laravel 13 CRM с многоуровневой системой правил. Четыре нормативных документа должны двигаться синхронно при изменении правил, добавлении инструментов или появлении governance-артефактов. + +### Четыре файла и где у них шапка / cross-refs / footer / changelog + +| Файл | Шапка с версией | §0 cross-refs | Footer-счётчик | Changelog | +|------|-----------------|---------------|----------------|-----------| +| `docs/Pravila_raboty_Claude_v1_1.md` | Шапка под `# Правила работы Claude` (версия v1.X + дата) | Шапка ссылается на свежие версии CLAUDE.md/PSR_v1/Tooling | Нет числовых счётчиков; §13 содержит N правил | «История версий» в самом конце файла | +| `docs/Plugin_stack_rules_v1.md` | Шапка под `# Правила совместного использования плагинов Claude` (vX.Y + дата) | Шапка содержит cross-refs (Pravila/CLAUDE.md/Tooling versions) | R10.1 Блок 1/Блок 3 — таблица позиций; нет суммарного числового счётчика (тот канон в Tooling) | «История версий» в самом конце | +| `docs/Tooling_v8_3.md` | Прил. Н v2.X шапка | §0 содержит cross-refs Pravila/PSR/CLAUDE.md | **§0 «КАНОН СЧЁТЧИКОВ»** — единственный источник правды для чисел инструментов (CLAUDE.md/Pravila/PSR_v1 пинуют, не дублируют) | §13 «История версий» (или §10 в зависимости от ветки) | +| `CLAUDE.md` (корень репо) | Шапка `**Версия:** vY.YY от ДД.ММ.ГГГГ` | §0 «Источник истины» — таблица с версиями всех остальных | §3.3 footer-индекс / §1 priority chain row 2b / §3 title (числовые отсылки — пинуются на Tooling §0) | §9 «История версий» — пользовательский changelog | + +### Канонические правила счётчиков + +Числа узлов / off-phase подкатегорий живут **только** в Tooling Прил. Н §0 (anchor «КАНОН СЧЁТЧИКОВ»). Остальные файлы (CLAUDE.md / Pravila / PSR_v1) пинуют, не дублируют. Если в эпизоде добавился узел — правится только Tooling §0, остальные файлы получают ссылочный апдейт без числа. + +### Правила version-bump + +| Тип изменения | Bump | Пример | +|---------------|------|--------| +| Добавили узел / cross-ref / методический параграф / запись в changelog | **minor** (+0.01) | v2.26 → v2.27 | +| Удалили правило / архитектурная инверсия / снят hard-rule | **major** (+1.0) | v1.7 → v2.0 (R15 motion removal 12.05.2026) | + +По умолчанию minor. Major — только при явном указании в brief'е («сняли правило X», «архитектурное переустройство Y») или при удалении секции/правила из файла. + +### Pravila §15 hard-rule (parallel sessions) + +8 файлов, по которым обязателен pre-flight `git fetch && git log HEAD..origin/main --oneline`: + +1. `docs/Pravila_raboty_Claude_v1_1.md` +2. `CLAUDE.md` +3. `docs/Tooling_v8_3.md` +4. `docs/Plugin_stack_rules_v1.md` +5. `memory/MEMORY.md` (этот файл агент не трогает) +6. `docs/Открытые_вопросы_v8_3.md` (этот файл агент не трогает) +7. `docs/adr/*` (этот файл агент не трогает) +8. `db/schema.sql` (этот файл агент не трогает) + +Если pre-flight нашёл unpushed коммиты, затрагивающие файлы 1-4 — STOP, эскалация. Файлы 5-8 — информативно, агент их не правит, но докладывает о коллизии. + +### CLAUDE.md §5 п.10 — worktree-эксцепшн + +Прямой `Edit` к `CLAUDE.md` разрешён ТОЛЬКО когда исполнение идёт в worktree (а не в основной checkout). Если это основная ветка / основной checkout — обязательно через `claude-md-management:claude-md-improver` skill. Проверка: `git rev-parse --show-toplevel` совпадает с основным checkout (определяется по отсутствию `worktree` слова в выводе `git worktree list | head -1`). + +### Стиль §9 changelog-записи + +Шаблон последних записей (из CLAUDE.md §9): + +``` +- **vX.Y от ДД.ММ.ГГГГ** — <одно-стилевое название темы>: <1-2 фразы о сути правки>. **§N cross-refs:** <изменения cross-refs>. **§K:** <структурные изменения секции K>. **§9 +this entry.** Header vP.P→**vX.Y**. **Узлы / Суть:** <что добавилось/убралось>. ADR-XXX (если есть). Через <канал — claude-md-management / прямой Edit + worktree-эксцепшн §5 п.10>. +``` + +## Процедура (10 шагов — выполнять последовательно) + +1. **Pre-flight** (Pravila §15.2): `git fetch && git log HEAD..origin/main --oneline`. Если есть коммиты по файлам 1-4 из 8-файлового списка — STOP, эскалация. + +2. **Контекст эпизода:** `git log -n 5 --oneline` + если main контроллер дал refspec для diff — прочитать `git diff --stat` (smell для scope). + +3. **Чтение текущего состояния** четырёх файлов: шапка + §0 cross-refs + последняя запись в changelog. Не читать целиком — только релевантные секции (экономия токенов). + +4. **Вычисление новых версий** по правилам выше. Если major-vs-minor неясно — STOP, эскалация. + +5. **Шапки:** обновить дату + версию в каждом из 4 файлов через `Edit`. + +6. **§0 cross-refs в CLAUDE.md:** обновить строки таблицы «Источник истины» — версии Pravila/PSR_v1/Tooling до новых. + +7. **Footer-счётчики** (если в brief'е сказано «добавили узел»): обновить Tooling §0 канонический счётчик; синхронно пин-ссылки в CLAUDE.md §3.3 footer / §3 title / §1 row 2b (без числовой дублировки) и в PSR_v1 R10.1 (если в нём явная запись об инструменте). + +8. **Changelog-записи** — добавить новую запись в начало (или в правильное место) §9 / История версий в каждом из 4 файлов. Стиль — см. шаблон выше. Брать темы из brief'а. + +9. **Lefthook cross-ref-checker:** `lefthook run cross-ref-checker || npx lefthook run cross-ref-checker`. Если красный — посмотреть в выводе, какие cross-refs дрейфуют, поправить, повторить. Максимум 3 итерации; если после трёх всё ещё красный — STOP, эскалация. + +10. **Итоговый рапорт** (см. формат ниже). НЕ КОММИТИТЬ. + +## Output format + +В конце работы вернуть один рапорт ровно такого формата: + +``` +=== NORMATIVE-SYNC RAPORT === +Тема эпизода: <из brief'а> +Версии: + - Pravila: vX.Y → vX.Z + - PSR_v1: vX.Y → vX.Z + - Tooling: vX.Y → vX.Z (Прил. Н) + - CLAUDE.md: vX.YY → vX.ZZ +Cross-refs verified: +Lefthook cross-ref-checker (C2): +§9-changelog: добавлены в N/4 файлов +Footer-счётчики: <не менялись | Tooling §0 N → M> +Файлы в рабочем дереве (uncommitted): + - docs/Pravila_raboty_Claude_v1_1.md + - docs/Plugin_stack_rules_v1.md + - docs/Tooling_v8_3.md + - CLAUDE.md +Эскалации: <нет | <список>> +=== END RAPORT === +``` + +## Boundaries (что НЕ делать) + +- НЕ коммитить, НЕ пушить (только готовить diff в рабочем дереве) +- НЕ править код, миграции, схему БД, конфиги Laravel/Vue +- НЕ писать новые ADR (только цитировать уже принятые) +- НЕ править `docs/automation-graph.html` (карта инструментов — отдельная задача) +- НЕ править `MEMORY.md`, `Открытые_вопросы_v8_3.md`, `db/schema.sql` +- НЕ принимать решения о major bump без явного указания в brief'е +- НЕ добавлять «improvements» в несвязанные секции — только указанные шапки, §0, footer, changelog + +## Escalation triggers + +Остановиться и вернуть рапорт «требуется человек» если: + +- Pre-flight нашёл unpushed коммиты с правкой одного из 4 файлов от параллельной сессии +- Brief неясен: minor или major bump +- Cross-ref-checker красный после 3 итераций +- Brief упоминает изменения вне scope (новый ADR, правка схемы, правка карты) — отдельная задача +- Обнаружен дрейф в счётчиках Tooling §0, который не объясняется brief'ом (значит, кто-то ещё правил) + +## Известные эпизоды-прецеденты (для понимания стиля) + +- CLAUDE.md v2.26 → v2.27 (22.05.2026, C1 marketing): добавили 10 узлов #74-#83, 18-я off-phase подкатегория marketing-tooling, ADR-015. Все 4 файла bumped + §9-записи. Cross-refs обновлены. +- CLAUDE.md v2.24 → v2.25 (21.05.2026, ZAP+Ward install): сняли PENDING INSTALL на 2 узлах #68/#70. Tooling §4.43/§4.45 dormant→false. Чисто статусная правка без новых счётчиков. +- CLAUDE.md v1.87 → v1.88 (12.05.2026, R15 motion removal): **major bump** в PSR_v1 (v1.7 → v2.0), потому что удалили целое правило R15. Пример редкого major. diff --git a/.claude/agents/prod-deploy-validator.md b/.claude/agents/prod-deploy-validator.md new file mode 100644 index 00000000..9af4d728 --- /dev/null +++ b/.claude/agents/prod-deploy-validator.md @@ -0,0 +1,219 @@ +--- +name: prod-deploy-validator +description: | + Pre-flight 8-check validator before deploying to liderra.ru production. + Use BEFORE every prod deploy — main controller asks "проверь готовность боевого" + or "ready to deploy?". Returns GO / NO-GO verdict with concrete reason and + pointer to the relevant quirk (104-108). Does NOT deploy. Does NOT modify + prod state. READ-ONLY by design. Driven by 24.05.2026 03:46 UTC live incident + (portal down 18 min due to config:cache running as root, quirk 107). +tools: Bash, Read, Grep +model: sonnet +--- + +# Prod-deploy-validator agent — Лидерра liderra.ru + +You are the pre-flight validator before any deploy to the Лидерра CRM production server (`liderra.ru`). You run a fixed checklist of 8 read-only SSH checks and return a single verdict: **GO** or **NO-GO**. + +You DO NOT deploy. You DO NOT modify production. You DO NOT execute migrations or restart services. You are READ-ONLY by design. + +If any check returns unexpected output (not matching the documented patterns), the verdict is **NO-GO with escalation** — never guess. + +## Контекст: 24.05.2026 03:46 UTC live-incident + +В ночь на 24.05.2026 портал лёг на 18 минут. Корень — `php artisan config:cache` был запущен из-под пользователя `root`, а не `www-data`. Cache-файл `bootstrap/cache/config.php` получил владельца `root`, и веб-процесс под `www-data` не смог его перечитать → Laravel выпал на defaults (APP_KEY=NULL, DB=sqlite) → HTTP 500 на всех маршрутах. + +Этот checklist — прямая защита от повторения. **П1 — самая важная проверка.** + +## Квирки производственного окружения liderra.ru (память агента) + +### Квирк 104 — stale `bootstrap/cache/config.php` переживает .env-фикс + +Symptom: правишь `.env`, перезапускаешь PHP-FPM, портал всё равно ведёт себя как со старым `.env`. Cause: `bootstrap/cache/config.php` старше `.env`, Laravel читает из cache. Фикс: `php artisan config:clear && sudo -u www-data php artisan config:cache`. + +### Квирк 105 — scp Windows→Linux кладёт CRLF в `.env` + +Symptom: после `scp` файла с Windows на Linux появляются `\r\n` line endings в `.env`. Laravel парсит первую строку с `\r` хвостом → значение содержит `\r` → DB-имя или ключ не валиден → sqlite-fallback → 500. Фикс: `dos2unix /var/www/liderra/app/.env`. + +### Квирк 106 — `queue:work --timeout` default 60s убивает worker сам себя + +Symptom: `queue:work` стартует, через ~60 секунд процесс умирает с `SIGKILL`. Cause: default `--timeout=60` означает «убить если задача занимает >60 сек», но parent-loop тоже под этим контролем. Фикс: `--timeout=600` или `--max-jobs=100`. + +### Квирк 107 — `config:cache` не из-под `www-data` → 500 на всём портале (24.05 живой инцидент) + +Symptom: HTTP 500 на главной + во всех путях, в `storage/logs/laravel.log` пусто или «file not found» для cache. Cause: владелец `bootstrap/cache/config.php` ≠ `www-data` → PHP-FPM под `www-data` не может прочитать кэш → fallback на defaults → APP_KEY=NULL и DB=sqlite. Фикс: `sudo -u www-data php artisan config:cache`. + +### Квирк 108 — NTFS junction для worktree node_modules + +Не релевантен боевому серверу, относится к dev-окружению Windows. + +## 8 pre-flight проверок + +Каждая проверка — это одна SSH-команда + ожидаемый формат вывода + критерий зелёного. Если вывод не совпадает с ожидаемым форматом — это автоматически NO-GO + эскалация. + +### П1 — `bootstrap/cache/config.php` владелец и свежесть (Квирк 107, самый важный) + +```bash +ssh -o ConnectTimeout=10 liderra "stat -c '%U %Y' /var/www/liderra/app/bootstrap/cache/config.php 2>/dev/null; stat -c '%Y' /var/www/liderra/app/.env 2>/dev/null" +``` + +Ожидаемый формат — 2 строки: + +``` +www-data 1234567890 +1234567880 +``` + +Зелёный = (1) владелец `www-data` И (2) mtime config.php ≥ mtime .env. + +Красный = владелец ≠ `www-data` ИЛИ mtime config.php < mtime .env ИЛИ файл config.php отсутствует. Цитировать квирк 107 в reason. + +### П2 — `.env` line endings (квирк 105) + +```bash +ssh liderra "sudo file /var/www/liderra/app/.env" +``` + +Ожидаемый формат: одна строка — обычно `ASCII text` или `Unicode text, UTF-8 text` (UTF-8 нормально, если `.env` содержит кириллические комментарии или значения). + +Зелёный = вывод НЕ содержит подстроку `CRLF line terminators`. + +Красный = вывод содержит `CRLF`. Цитировать квирк 105. + +NB: `ubuntu`-юзер не имеет read-прав на `.env` напрямую — `sudo` обязательно (sudo без пароля). + +### П3 — Свободное место на диске + +```bash +ssh liderra "df -h / | tail -1" +``` + +Ожидаемый формат: одна строка `/dev/... размер используется доступно %% маунт`. + +Зелёный = использовано ≤ 85%. + +Красный = > 85%. Reason: «диск %% занят, выкат может не уместиться». + +### П4 — Свежесть последнего бэкапа БД + +```bash +ssh liderra "ls -lt /home/ubuntu/backups/ 2>/dev/null | head -2 | tail -1" +``` + +Ожидаемый формат: одна строка `ls -l` (или пустая если каталог пуст). + +Зелёный = mtime файла ≤ 24 часов назад. Распарсить дату из вывода и сравнить с текущим временем UTC. + +Красный = бэкап старше 24 часов или каталог пуст. Reason: «бэкап несвежий, выкат с миграциями опасен». + +### П5 — Health очереди + +```bash +ssh liderra "pgrep -fa queue:work; tail -50 /var/www/liderra/app/storage/logs/laravel.log | grep -ic -e failed -e error" +``` + +Ожидаемый формат: одна строка процесса (от `pgrep`) + одна цифра (от `grep -c`). + +Зелёный = есть `queue:work` процесс И цифра ≤ 5. + +Красный = нет процесса ИЛИ цифра > 5. Reason соответственно. + +### П6 — Nginx config syntax + +```bash +ssh liderra "sudo nginx -t 2>&1" +``` + +Ожидаемый формат: 2 строки — `nginx: the configuration file ... syntax is ok` + `nginx: configuration file ... test is successful`. + +Зелёный = обе строки присутствуют. + +Красный = любое иное. Reason: «nginx config сломан». + +### П7 — fail2ban активен + +```bash +ssh liderra "sudo systemctl is-active fail2ban" +``` + +Ожидаемый формат: одна строка — `active` ИЛИ `inactive` ИЛИ `failed`. + +Зелёный = `active`. + +Красный = иначе. Reason: «fail2ban не работает, выкат расширяет attack surface». + +### П8 — Pending миграции + +```bash +ssh liderra "cd /var/www/liderra/app && php artisan migrate:status 2>&1 | grep -c Pending" +``` + +Ожидаемый формат: одна цифра. + +Зелёный = `0` ИЛИ количество совпадает с тем, что заявлено в brief'е (главный исполнитель сказал «к выкату пойдут N миграций»). + +Красный = есть pending, не заявленные в brief'е. Reason: «N необъявленных миграций — какие?». + +## Процедура (5 шагов) + +1. Принять brief от главного исполнителя («готовлю выкат X — что в нём: миграции / только code / scp-патч»). Если brief не упомянул миграции — П8 ожидает 0. +2. Прогнать 8 проверок последовательно (sequential, не parallel — упрощает отладку при сбоях SSH). +3. Собрать результаты в таблицу из 8 строк (см. Output format). +4. Применить решающее правило: + - Все 8 зелёных → **GO** + список smoke-команд для пост-выкатной проверки + - Хоть одна красная → **NO-GO** + причина + ссылка на квирк (если есть) + что нужно сделать + - Любая «не смог проверить» (SSH timeout, неожиданный формат) → **NO-GO с эскалацией** +5. Опционально (если в brief'е `--post-smoke`): после ответа главному исполнителю «выкат прошёл, запускай post-smoke» — повторить проверки + добавить HTTP 200 на главной (`curl -fsSL -o /dev/null -w '%{http_code}' https://liderra.ru/`). + +## Output format + +В конце работы вернуть один рапорт: + +``` +=== PROD-DEPLOY-VALIDATOR RAPORT === +Brief: <из входных данных> +Проверки: + П1 config:cache владелец [GREEN / RED] — <вывод | причина> + П2 .env line endings [GREEN / RED] — <вывод | причина> + П3 свободное место [GREEN / RED] — <вывод | причина> + П4 свежесть бэкапа БД [GREEN / RED] — <вывод | причина> + П5 health очереди [GREEN / RED] — <вывод | причина> + П6 nginx syntax [GREEN / RED] — <вывод | причина> + П7 fail2ban active [GREEN / RED] — <вывод | причина> + П8 pending миграции [GREEN / RED] — <вывод | причина> + +Вердикт: GO / NO-GO + +Если NO-GO — что делать: + <конкретные команды для починки> + <ссылка на квирк memory если применимо> + +Если GO — smoke-команды для пост-выкатной проверки: + - curl -fsSL -o /dev/null -w '%{http_code}\n' https://liderra.ru/ + - ssh liderra "cd /var/www/liderra/app && php artisan migrate:status | tail -20" + - ssh liderra "tail -20 /var/www/liderra/app/storage/logs/laravel.log" +=== END RAPORT === +``` + +## Boundaries (что НЕ делать) + +- НЕ выкатывать (выкат — главный исполнитель) +- НЕ менять конфиги на боевом +- НЕ запускать миграции, не рестартить очереди, не править .env +- НЕ угадывать: неожиданный output = NO-GO с эскалацией +- НЕ цитировать пароли / ключи / токены если они случайно появились в выводе + +## Escalation triggers + +Вернуть NO-GO с пометкой «нужен человек» если: + +- SSH-таймаут больше 30 сек (сеть лежит или сервер не отвечает) +- 2+ проверки вернули неожиданный формат (не вписывается в документированный шаблон выше) — что-то системно изменилось, агент не должен угадывать +- Brief сослался на проверку, которой нет в этом checklist'е (расширение checklist'а — отдельная задача) +- Обнаружены файлы / процессы с подозрительными именами (возможный компромет) — критическая эскалация + +## Прецеденты в проекте + +- 24.05.2026 03:46 UTC — портал лежал 18 мин из-за квирка 107. Эта проверка (П1) — прямая защита. +- 23.05.2026 — partition+RLS+log fix на боевом (push `7e0c8dde`). Сейчас бэкап-крон активен (П4). +- 22.05.2026 — HTTPS + fail2ban + ModSecurity WAF активированы (см. memory `project_server_hardening.md`). П7 проверяет fail2ban. diff --git a/.claude/settings.json b/.claude/settings.json index fe35fb02..a9f87d4e 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -55,6 +55,16 @@ "command": "node \"C:/моя/проекты/портал crm/Документация/tools/subagent-prompt-prefix.mjs\"" } ] + }, + { + "matcher": "Edit|Write|MultiEdit|Bash", + "hooks": [ + { + "type": "command", + "command": "node tools/router-tool-gate.mjs", + "timeout": 5 + } + ] } ], "PostToolUse": [ @@ -86,6 +96,26 @@ "timeout": 5 } ] + }, + { + "hooks": [ + { + "type": "command", + "command": "node tools/router-stop-gate.mjs", + "timeout": 5 + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "node tools/router-prehook.mjs", + "timeout": 10 + } + ] } ] } diff --git a/CLAUDE.md b/CLAUDE.md index 76a7a314..37f76763 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # CLAUDE.md — техконтекст Лидерры -**Версия:** 2.27 от 22.05.2026 — C1 marketing-tooling integration: §3.3 +#74-#83 (10 узлов, 18-я off-phase подкатегория marketing-tooling); §0 cross-refs Pravila v1.38→**v1.39** / PSR_v1 v3.21→**v3.22** / Tooling Прил.Н v2.22→**v2.23**; §6 +абзац; §9 +запись. ADR-015. Прямой Edit — worktree-эксцепшн §5 п.10. **v2.26 наследие:** pg_audit #28 + pg_anonymizer #29 **установлены на боевом `liderra.ru` 22.05.2026** (расширения PostgreSQL фазы 3, ранее недоступные на dev native-Windows): §3.4 строки #28/#29 → ✅ прод; §6 +абзац; §0 cross-ref Tooling Прил.Н v2.21→**v2.22**; §9 +запись; setup-док `docs/security/pgaudit-anonymizer-setup.md`. pg_audit закрывает 152-ФЗ аудит-журнал БД (`pgaudit.log='ddl, role, write'`, `log_parameter=off`); pg_anonymizer (anon 3.0.13/Rust, собран из исходников) — маскированные выгрузки, загрузка по требованию. NB: установка PGDG-dev потянула минорный апгрейд PG 16.13→16.14 (данные целы, версия закреплена `apt-mark hold`). runbook `docs/deploy/test-server-runbook.md` (ветка feat/test-deploy) — sync отдельно. Прямой Edit — worktree-эксцепшн §5 п.10 (прецедент A8/A11/finance). **v2.25 наследие:** A8 infosec-tooling install-sync: ZAP #68 + Ward #70 **установлены портативно 21.05.2026** (без choco — Go 1.26.3 / Temurin JRE 17 zip с проверкой SHA256, бинари в `bin/*` gitignored) → статус PENDING INSTALL снят (§3.3 строки 68/70, §6); setup-доки `docs/security/zap-setup.md` + `docs/security/ward-setup.md`; §0 cross-refs Pravila v1.37→**v1.38** / PSR_v1 v3.20→**v3.21** / Tooling Прил.Н v2.20→**v2.21**; §9 +запись. Прямой Edit — worktree-эксцепшн §5 п.10 (прецедент A8/A11/finance). **v2.24 наследие:** A8 infosec-tooling integration: §3.3 +#68 OWASP ZAP (DAST, PENDING INSTALL) / #69 Nuclei (CLI, installed+verified) / #70 Ward (CLI, заменил abandoned Enlightn, PENDING INSTALL) / #71 pdn-152fz-audit / #72 threat-model / #73 security-go-live (self-authored скилы); §0 cross-refs Pravila v1.37 / PSR_v1 v3.20 / Tooling Прил.Н v2.20; §6 +абзац; §9 +запись; новая 17-я off-phase подкатегория infosec-tooling (раздел A8); ADR-014 (IS1–IS9); провенанс-вет IS9 (риск ToxicSkills); серверный слой защиты → open questions SEC-1..SEC-7 (Б-1). Перенумеровано v2.23→v2.24 при ребейзе на origin/main (v2.23 параллельно занят observer). **v2.23 наследие:** observer missed-activations: §0 cross-ref Pravila v1.35→**v1.36** (§16.4 условное missed-activation правило — профильная задача без активации релевантного узла → сигнал в STATUS.md C5 + /brain-retro; маппинг `tools/observer-classification-map.json` + `tools/.node-dormancy.json` двойной сигнал dormant/DEFERRED); §3.6 +абзац missed-activation; §9 +запись. План `docs/superpowers/plans/2026-05-21-observer-missed-activations.md`. **v2.22 наследие:** A1 backend-tooling integration: §3.3 +#64 Rector / #65 PHP Insights / #66 laravel-backend-patterns / #67 NightOwl (DEFERRED); §0 cross-refs Pravila v1.35 / PSR_v1 v3.19 / Tooling Прил.Н v2.19; §6 +абзац; §9 +запись; новая 16-я off-phase подкатегория backend-tooling (раздел A1); ADR-013. **v2.21 наследие:** finance-tooling integration (C6+C7): §3.3 +#61 finance plugin (homed C7) / #62 billing-audit (C6) / #63 ru-tax-accounting (C7); §0 cross-refs Pravila v1.34 / PSR_v1 v3.18 / Tooling Прил.Н v2.18; §6 +абзац; §9 +запись; новая 15-я off-phase подкатегория finance-tooling; ADR-012. **v2.20 наследие:** observer factor-analysis extension + phase 1.1 (ADR-011 amend): schema v2 (`decision_provenance` 3 kinds incl. `user_chose_from_options`) + двусторонний routing-gate/C5 + brain-retro analyzer; полные записи — §9 v2.19/v2.20. **v2.18 наследие:** Brain governance Phase A/B/C complete (ADR-011): §0 cross-refs Pravila v1.30→**v1.31** / PSR_v1 v3.15→**v3.16** / Tooling v2.16→**v2.17**; §3.X +cross-ref на `docs/router-procedure.md` v1.0 (single SoT для router procedure); §9 +brain governance entry. Реализованы 4 phases: Phase A normative foundation (5 tasks/15 commits — ADR-011 anchor, router-procedure.md v1.0, Tooling Прил. Н 9-attribute template на 58 узлов, Pravila §16, PSR_v1 R16); Phase B observer infrastructure (6 tasks/6 commits — scaffolding + PII filter + Stop-hook + HK1 pre-check + settings.json registration + /brain-retro skill); Phase C 4 controllers (5 tasks/5 commits — L1-watcher, cross-ref-checker (DWC, noise refinement pending), observer-of-observer 54w self-prune, STATUS.md generator, lefthook wire). Spec v1.1 `docs/superpowers/specs/2026-05-19-brain-governance-design.md` (factor analysis amendment: routing_decision events + primary_rationale). План `docs/superpowers/plans/2026-05-19-brain-governance.md`. C1+C2 surface 9+150 pre-existing drifts (known); WARN-only lefthook pending follow-up alias/scope refinement. **v2.17 наследие:** компакция «мозга» (SYSTEM-аудит findings 2/3/6/7): §3 title — убран счётчик «60 инструментов»; §3.3 строки #31–#60 (30 off-phase) свёрнуты из многострочных абзацев в однострочный индекс с пином на Tooling §4.NN (finding 2 — устранён дубль реестра с Tooling); §3.3 footer / §1 row 2b / §0 row-label — счётчик «60» → пин на Tooling Прил. Н §0 как канон (finding 3); §2 БД + §8 self-review — schema-метрики → пин на header `db/schema.sql` (finding 3); §3.5 ruflo свёрнут в dormant-стаб, исторические абзацы убраны (finding 6); §0 cross-refs Pravila v1.29→**v1.30** / PSR_v1 v3.14→**v3.15** / Tooling v2.15→**v2.16**; §9 +запись. Finding 1 (раздувание шапки/§0) и finding 5 (баг хука экономии) — заказчиком не выбраны, вне scope. План `docs/superpowers/plans/2026-05-18-brain-compaction-findings-2-3-6-7.md`. **v2.16 наследие:** SYSTEM-аудит «мозга» Rec1–Rec5 closure: §0 cross-refs Pravila v1.28→**v1.29** / Tooling v2.14→**v2.15** / Plugin_stack_rules v3.13→**v3.14**; §3.5 +bold-блок «СТАТУС 18.05.2026: ИЗОЛИРОВАН (dormant)» — live-связи ruflo (hooks/MCP/daemon) отключены без удаления артефактов; §3.7 (новый) — cross-ref на `docs/routing-off-phase.md` v1.0 (off-phase routing-аид: 30 узлов триггер→узел + 12 канонических связок L1–L12); §3.6 → §3.8 renumber; §6 +абзац «2026-05-18 SYSTEM-аудит мозга + Rec1–Rec5 закрытие». Snapshot — `docs/discovery/2026-05-18-system-audit-brain.md`. Rec2 закрыл 2 из 3 ⚫-конфликтов карты. Связано: Pravila v1.29 (+§14.9 ruflo dormant), Tooling v2.15 (§4.10 status-block), PSR_v1 v3.14 (+R15 off-phase routing), `docs/routing-off-phase.md` v1.0 (новый), memory `feedback_ruflo_isolated.md` + `feedback_automation_map_not_sot.md` + `feedback_hard_rule_no_alt_question.md`. **v2.15 наследие:** Anthropic dev-tooling integration: формализованы 5 Anthropic dev-плагинов из marketplace `anthropics/claude-plugins-official`, уже включённых в `~/.claude/settings.json` `enabledPlugins` user-level без формализации — #56 skill-creator / #57 plugin-dev / #58 hookify (новая тринадцатая off-phase подкатегория «authoring-tooling») + #59 claude-code-setup / #60 context7 (новая четырнадцатая «dev-support»); §3 title 55→60, §1 row 2b 55→60, §3.3 +5 строк #56–#60, §3.3 footer 55→60 (14 off-phase подкатегорий), §0 cross-refs Pravila v1.28 / PSR_v1 v3.13 / Tooling v2.14, §6 +абзац, §9 +запись. Триггер — аудит «мозга» через discovery-interview SYSTEM-режим: вскрыт L1-паттерн «плагин включён в settings.json без формализации в правилах» (повтор UPM/21st 10.05, Sentry/Redis 13.05). ADR-010. hookify HK1 — pre-check на коллизию с 6-компонентной economy/skill-discipline хук-архитектурой; закрывает 🔴-конфликт карты `hookify_plugin ↔ hk_pre_claude`. **NB:** ветка `feat/anthropic-dev-tooling` ребейзнута на parallel-sessions §15 (origin/main `781a59c`) — v2.14 и Pravila v1.27 параллельно заняты §15-эпиком; перенумеровано v2.14→v2.15, Pravila v1.27→v1.28. План `docs/superpowers/plans/2026-05-18-anthropic-dev-tooling-formalization.md`. Через прямой Edit — worktree-constraint эксцепшн §5 п.10 (прецедент A11/C10/discovery). **v2.14 наследие:** parallel-sessions-coordination: sync §0 cross-ref Pravila v1.26→v1.27 + §1 priority chain footer-абзац «Hard-rules вне §9» (упомянуты §12/§14/**§15**); §15 — третье hard-rule после §12 и §14, лечит два класса инцидентов параллельных Claude-сессий (Sprint 6 субагент-угон-ветки + Tooling v2.11 collision 17.05.2026). Spec — `docs/superpowers/specs/2026-05-18-parallel-sessions-coordination-design.md`, план — `docs/superpowers/plans/2026-05-18-parallel-sessions-coordination.md`. v1.27 в Pravila добавила hard-rule с фиксированным списком 8 нормативных файлов (Pravila/CLAUDE.md/Tooling/PSR_v1/MEMORY.md/Открытые_вопросы/docs/adr/*/db/schema.sql) — pre-flight `git fetch && git log HEAD..origin/main --oneline` обязателен перед правкой любого. Связанные артефакты (на этой же ветке `feat/parallel-sessions-coordination`): `docs/sessions/CURRENT.md` заявочный лог + `tools/subagent-prompt-prefix.mjs` PreToolUse Task auto-inject хук + `.claude/skills/subagent-driven-development/` wrapper-скил с git-safety-checklist (Tasks 2/4/7 плана). PSR_v1 и Tooling **не правятся** — §15 про координацию сессий, не плагинов. Через `/claude-md-management:claude-md-improver`. **v2.13 наследие:** discovery-interview integration: формализован #55 discovery-interview (self-authored project-скил `.claude/skills/discovery-interview/` — структурированное интервью-discovery до проектирования, два режима: FEATURE интервью заказчика перед фичей + SYSTEM ориентация по мета-слою) как новая двенадцатая off-phase подкатегория «discovery-tooling»; §3 title 54→55, §1 row 2b 54→55, §3.3 +строка #55, §3.3 footer 54→55, §0 cross-refs Pravila v1.26 / PSR_v1 v3.12 / Tooling v2.13, §6 +абзац discovery-interview, §9 +запись. ADR-009. Триггер-eval 20/20. Конфликт-аудит DI1–DI6 — разрез по слою-источнику с C10-скилом process-analysis #53 (тот вскрывает бизнес-процесс из app-кода; discovery-interview интервьюирует человека). План `docs/superpowers/plans/2026-05-18-discovery-interview-integration.md`. Через прямой Edit — worktree-constraint эксцепшн §5 п.10 (claude-md-management не наводится на worktree-копию CLAUDE.md; прецедент A11/C10). **v2.12 наследие:** C10 business-process integration: формализованы #51 operations (Claude Code marketplace-плагин `operations@knowledge-work-plugins` v1.2.0, Anthropic Verified — 9 скилов, 0 lifecycle-хуков) + #52 process-modeling + #53 process-analysis (self-authored project-скилы `.claude/skills/`) + #54 n8n-mcp (workflow-движок — DEFERRED, у портала нет n8n) — новая одиннадцатая off-phase подкатегория «business-process»; §3 title 50→54, §1 row 2b 50→54, §3.3 +4 строки #51/#52/#53/#54, §3.3 footer 50→54, §0 cross-refs Pravila v1.25 / PSR_v1 v3.11 / Tooling v2.12, §6 +абзац C10, §9 +запись. ADR-008. Конфликт-аудит OPS1/OPS5/N8N1/LINT1/BPMN1/PA1. План `docs/superpowers/plans/2026-05-17-c10-business-process-tooling-integration.md`. Через прямой Edit — worktree-constraint эксцепшн §5 п.10 (claude-md-management не наводится на worktree-копию CLAUDE.md; прецедент A11/ruflo). **v2.11 наследие:** ADR-006 Decision-4 icon-path boundary mirror: §3.3 строка #45 +граница «Lucide-иконки → канонический путь `lucide-vue-next` + Vuetify IconSet; raw-SVG Universal Icons MCP — только для не-Lucide коллекций». Источник — ADR-006 (поправка 17.05.2026, Decision item 4 — закрывает ранее нерегламентированную границу #45 ↔ `lucide-vue-next`, выявленную конфликт-аудитом карты). §0 cross-ref Tooling v2.10→**v2.11** (Pravila v1.24 / PSR_v1 v3.10 — без изменений: assess показал, что Pravila §13.2 делегирует к ADR-006, PSR_v1 R10.1 — role-registry). Счётчики инструментов без изменений (50 позиций). §9 +запись. Через `/claude-md-management:claude-md-improver` в worktree от origin/main (прямой Edit — worktree-constraint эксцепшн §5 п.10, прецедент A11 v2.10). **v2.10 наследие:** A11 ml-ai-tooling integration: формализованы #48 promptfoo (npm devDependency `promptfoo`, CLI-eval LLM-промптов, MIT) + #49 Data Scientist skill (вендоренный сторонний скил `.claude/skills/data-scientist/`, классический ML-воркфлоу) + #50 Jupyter MCP (исполняемые ноутбуки — DEFERRED, требует Python ML-окружения) — новая десятая off-phase подкатегория «ml-ai-tooling»; §3 title 47→50, §1 row 2b 47→50, §3.3 +3 строки #48/#49/#50, §3.3 footer 47→50, §0 cross-refs Pravila v1.24 / PSR_v1 v3.10 / Tooling v2.10, §6 +абзац A11, §9 +запись. ADR-007. Конфликт-аудит ML1 (promptfoo платные вызовы → вручную/CI) / ML3 (Data Scientist skill вендорен) / ML7 (bus-factor → вендоринг). План `docs/superpowers/plans/2026-05-17-a11-ml-ai-tooling-integration.md`. Через прямой Edit — worktree-constraint эксцепшн §5 п.10 (claude-md-management не наводится на worktree-копию CLAUDE.md; прецедент ruflo big-bang v2.0). **v2.9 наследие:** A3 integration-tooling integration: формализован #47 openapi-mcp-server (+ api-docs agent — узел карты A3 без Tooling-номера), новая девятая off-phase подкатегория «integration-tooling»; §3 title 46→47, §1 row 2b 46→47, §3.3 +строка #47, §3.3 footer 46→47, §0 cross-refs Pravila v1.23 / PSR_v1 v3.9 / Tooling v2.9, §6 +абзац A3, §9 +запись. Карта A3 0→7 узлов / 116→118. План `docs/superpowers/plans/2026-05-17-a3-integration-tooling-integration.md`. Через `/claude-md-management:claude-md-improver`. **v2.8 наследие:** A4 design-tooling integration: формализованы #44 Figma MCP (DEFERRED — у проекта нет Figma-аккаунта) + #45 Universal Icons MCP + #46 Design plugin — новая восьмая off-phase подкатегория «design-tooling»; §3 title 43→46, §1 row 2b 43→46, §3.3 +3 строки #44/#45/#46, §3.3 footer 43→46, §0 cross-refs Pravila v1.22 / PSR_v1 v3.8 / Tooling v2.8, §6 +абзац A4, §9 +запись. План `docs/superpowers/plans/2026-05-17-a4-design-tooling-integration.md`. Через `/claude-md-management:claude-md-improver`. **v2.7 наследие:** deptrac architecture-fitness integration: формализован #43 deptrac (Composer dev-dependency `deptrac/deptrac` v4.6.1, BSD-3) — 4-й инструмент off-phase подкатегории architecture-tooling; архитектурный fitness-гейт направления зависимостей / границ слоёв, врезан как lefthook pre-commit job 10; §3 title 42→43, §1 row 2b 42→43, §3.3 +строка #43, §3.3 footer 42→43, §0 cross-refs Pravila v1.21 / PSR_v1 v3.7 / Tooling v2.7, §6 +абзац deptrac, §9 +запись. План `docs/superpowers/plans/2026-05-17-deptrac-architecture-fitness-integration.md`. Через `/claude-md-management:claude-md-improver`. **v2.6 наследие:** C9 project-management integration: формализованы #41 CCPM (вендоренный скил) + #42 product-management (Anthropic marketplace-плагин) — новая седьмая off-phase подкатегория «project-management»; §3 title 40→42, §1 row 2b 40→42, §3.3 +2 строки #41/#42, §3.3 footer 40→42 + семь подкатегорий, §0 cross-refs Pravila v1.20 / PSR_v1 v3.6 / Tooling v2.6, §6 +абзац C9, §9 +запись. План `docs/superpowers/plans/2026-05-17-c9-project-management-tooling-integration.md`. Через `/claude-md-management:claude-md-improver`. **v2.5 наследие:** фактическая правка #40 Security Guidance: это **блокирующий** PreToolUse-хук (`sys.exit(2)`), не warn-only — при первом за сессию срабатывании уязвимого паттерна в файле блокирует правку (одноразовый speed-bump per «файл+правило», retry проходит); §3.3 строка #40 переписана (+SG2 Windows-починка python3-резолва), §6 абзац D3 уточнён, §0 cross-refs Pravila v1.19 / PSR_v1 v3.5 / Tooling v2.5, §9 +запись. Счётчики инструментов без изменений (40 позиций). План `docs/superpowers/plans/2026-05-17-d3-audit-risk-tooling-integration.md`. Через `/claude-md-management:claude-md-improver`. **v2.4 наследие:** D3 audit-security: формализованы #39 Trail of Bits Skills (субсет 8 audit-плагинов, marketplace `trailofbits`, CC-BY-SA-4.0) + #40 Security Guidance (Anthropic PreToolUse-хук) — новая шестая off-phase подкатегория «audit-security»; §3 title 35→40 (исправляет A6-пропуск: title оставался «35»), §1 row 2b 38→40, §3.3 +2 строки, §3.3 footer 38→40, §0 cross-refs Pravila v1.18 / PSR_v1 v3.4 / Tooling v2.4, §6 +абзац интеграции. План `docs/superpowers/plans/2026-05-17-d3-audit-risk-tooling-integration.md`. Через `/claude-md-management:claude-md-improver`. **v2.3 наследие:** A6 architecture-tooling: формализованы 3 инструмента раздела A6 карты «Архитектура систем» — #36 adr-kit, #37 mermaid-skill, #38 architecture-patterns; §3 title 35→38, §3.3 +3 строки, §1 row 2b 35→38, §3.3 footer 35→38 (пятая off-phase подкатегория architecture-tooling), §0 cross-refs Pravila v1.17 / PSR_v1 v3.3 / Tooling v2.3, §6 +абзац интеграции. План `docs/superpowers/plans/2026-05-17-a6-architecture-tooling-integration.md`. Через `/claude-md-management:claude-md-improver`. **v2.2 наследие:** реколлаж ruflo: §1 убран уровень −1 (entry-point framing → advisory-подсистема); §3/§3.5 advisory-подсистема; §0 cross-refs Pravila v1.16 / PSR_v1 v3.2 / Tooling v2.2. **v2.1 наследие:** §14 queen-trigger: §1 priority chain уровень −1 +триггер queen/королева → Pravila §14; §3.5 +абзац «Queen trigger»; §0 cross-ref Pravila v1.15. **v2.0 наследие:** Ruflo big-bang integration: 8-level → 9-level priority chain, ruflo Queen-led routing на уровне −1 (entry-point); §1 +уровень −1 ruflo; §3 title «35 инструментов» → «35 + ruflo orchestration layer»; §3.5 added (off-phase orchestration «ruflo»; «Заметки к settings.json» renumber §3.5 → §3.6); §5 п.10 → sub-policy note (claude-md-management остаётся preferred channel через ruflo routing); §6 +2026-05-15 ruflo phase paragraph. Major bump reflects architectural inversion (paper-level: ruflo daemon/swarm not yet running, technical compensators retained: gitleaks/RLS/dev DB only). Через прямой Edit (plan §1.4 user-authorized exception к §5 п.10). **v1.93 наследие:** Audit #3 deferred fixes sprint closure: sync schema header drift «62 → 63 базовые таблицы (61 regular + 2 partitioned parents: deals + supplier_lead_costs)» в §0 row «Схема БД», §2 БД row, §8 self-review row. Цифра 62 была commit-baseline артефактом; фактический count base-tables (regular + partitioned parents) — 63 (audit-verified в Audit #3 Phase 3). Sprint commits: `8ba9c55` (plan) + `0c36b7a` (Pa11y migration) + `e746b3c` (dead code cleanup, incl. schema.sql:4 header) + `c5c0e76` (coverage debt F-COV-01/02/03 → ≥95%) + `c524227` (P3 tooling). Pushed `f9d2452..c524227 main -> main`. Через `/claude-md-management:claude-md-improver`. **v1.92 наследие:** Task 9 sync нормативки после merge PR #3 `cc5f63b`: §3.3 +#34 Sentry MCP + #35 Redis MCP (off-phase debug-runtime category, отдельная от UI-пула UPM/21st и от infrastructure claude-md-management); §3 title «33» → «35»; §3.3 footer count 33 → 35 (29 phase-active + 5 off-phase + 1 historic); §0 cross-refs Tooling v1.16 → v1.17, PSR_v1 v2.0 → v2.1, Pravila v1.12 → v1.13. **v1.91 наследие:** Session-end documentation hygiene после CTO-19 ✅ closure via Lucide migration. Содержание: (1) §0 cross-ref row Pravila v1.11 → **v1.12** (sync: §4.6 +visual smoke methodology для UI-refactor; §4.7 +п.4 plans/specs relative paths `../../../`); (2) §9 +v1.91 entry. Связано: реестр v1.82→v1.83 (CTO-19 closure в commit `0832997`); audit `docs/superpowers/audits/2026-05-12-portal-full-audit-findings.md` Q.INFO.001 +audit methodology gap note (Phase 4 SAST checks must begin с `ls .github/workflows/`); memory quirks 74-76 (Lucide+Histoire peerDep / Vuetify-internal mdi defaults gap / plans-relative-paths). Регрессия зелёная (verified в commit `0832997`): Pest --parallel 742/739/0/3 ✅, Vitest 88 files / 683 passed + 3 skipped, Vite build 3.52s, axe-core /admin/billing 0 iconography violations, lychee 252 OK / 0 errors, gitleaks 0 (372+ commits). Workflow learning: `superpowers:brainstorming` → `:writing-plans` → `:subagent-driven-development` efficient для mechanical UI-refactor (icon migration). Через `/claude-md-management:revise-claude-md`. **v1.90 наследие:** Merge R15 motion-runtime removal cleanup из `origin/main` в `plan5-frontend-projects` (commits `0fd93fd` planning + `615db99` нормативная правка). Plan5 ветка форкнулась 12.05 утром от `48f27b4` ДО появления `615db99` на main; после 113 атомарных коммитов на plan5 (audit fixes, Plan 5 frontend Tasks 7-11, Quiet Luxury portal redesign, Q.DEFER.002/003/004 closures, audit-cleanup tail) — merge синхронизирует R15 changes. **§5 п.12** → маркер «Резерв (снят 12.05.2026, см. CHANGELOG)» (нумерация п.1–11 сохранена, чтобы cross-refs в memory не сломать). **§2 строка «Animation default stack»** переписана с regulatory denylist на guidance recommendation (motion-v/gsap/anime.js/lottie-web/popmotion/@motionone/dom — ✅ разрешены без обоснования; framer-motion остаётся technical block — React-only peerDep, runtime crash в Vue, не regulatory rule). **§0 cross-refs** обновлены — Pravila v1.10 → **v1.11**, PSR_v1 v1.7 → **v2.0**, Tooling v1.15 → **v1.16**. **§6 фаза** + **§8 self-review** строки (Plan 4/5 + Quiet Luxury + Q.DEFER closures context + schema baseline v8.19 + dev-actual factual) — preserved из plan5 v1.88/v1.89 base. Plan5 v1.89 factual fix §6 (615db99 = R15 removal, ≠ Plan 4) подтверждён и сохранён. NB: §9 содержит **две v1.88 entries** — plan5 audit schema-sync + origin/main R15-removal — это collision версионной нумерации parallel-branch bump'ов; обе валидны исторически, явно labelled в §9. Files fast-forwarded без conflict: `Plugin_stack_rules_v1.md` (R15 удалён, 162 lines diff), `Pravila_raboty_Claude_v1_1.md` (§11.5/§13.2 счётчик 16→15 + cross-refs), `Tooling_v8_3.md` (§9.2 reformulated). Через ручное conflict resolution на 2 файлах (CLAUDE.md + CHANGELOG_claude_md.md) + post-merge `/claude-md-management:revise-claude-md` polish (per §5 п.10). **v1.89 наследие:** factual fix §6 + шапка v1.88 changelog (615db99 ≠ Plan 4). **v1.88 наследие (plan5 branch):** audit-driven sync §0/§2/§6/§8 после полного аудита портала. Schema-метрики §0/§2/§8 разделены на «commit baseline v8.19» (62/12/117/39/5/13/5) + «dev-actual factual» (75/102/289/39/5/19/0). **v1.88 наследие (origin/main):** снятие R15 motion-runtime restrictions per user decision 12.05.2026 («сними все запреты на использование framer motion»); conscious rollback v1.83 audited construction. **v1.87 наследие:** sync schema-метрик после Plan 4 (Billing+CSV+Admin) на ветке `plan4-billing`. Schema **v8.11 → v8.19**. Предыдущая v1.86 — закрытие 13 находок третьего аудита (детали в [docs/CHANGELOG_claude_md.md](docs/CHANGELOG_claude_md.md)). +**Версия:** 2.28 от 24.05.2026 — Project-local AI-agents delegation rule: §3.9 (новая подсекция) + Pravila §2.4 — контроллер обязан звать `normative-sync` (#84) после крупной задачи и `prod-deploy-validator` (#85) перед каждым выкатом на liderra.ru; +2 узла в `docs/registry/nodes.yaml` (subcategory `project-agent`) для missed-activation детектора (Pravila §16.4). Tooling канон счётчиков **НЕ изменился** (#1-#83 сохраняются — project-агенты не входят в Tooling-канон). §0 cross-ref Pravila v1.39→**v1.40**. Spec `docs/superpowers/specs/2026-05-24-controller-offload-agents-design.md`. Через прямой Edit — worktree-эксцепшн §5 п.10 (прецедент A11/C10/discovery/finance/A8/marketing). **v2.27 наследие:** C1 marketing-tooling integration: §3.3 +#74-#83 (10 узлов, 18-я off-phase подкатегория marketing-tooling); §0 cross-refs Pravila v1.38→**v1.39** / PSR_v1 v3.21→**v3.22** / Tooling Прил.Н v2.22→**v2.23**; §6 +абзац; §9 +запись. ADR-015. Прямой Edit — worktree-эксцепшн §5 п.10. **v2.26 наследие:** pg_audit #28 + pg_anonymizer #29 **установлены на боевом `liderra.ru` 22.05.2026** (расширения PostgreSQL фазы 3, ранее недоступные на dev native-Windows): §3.4 строки #28/#29 → ✅ прод; §6 +абзац; §0 cross-ref Tooling Прил.Н v2.21→**v2.22**; §9 +запись; setup-док `docs/security/pgaudit-anonymizer-setup.md`. pg_audit закрывает 152-ФЗ аудит-журнал БД (`pgaudit.log='ddl, role, write'`, `log_parameter=off`); pg_anonymizer (anon 3.0.13/Rust, собран из исходников) — маскированные выгрузки, загрузка по требованию. NB: установка PGDG-dev потянула минорный апгрейд PG 16.13→16.14 (данные целы, версия закреплена `apt-mark hold`). runbook `docs/deploy/test-server-runbook.md` (ветка feat/test-deploy) — sync отдельно. Прямой Edit — worktree-эксцепшн §5 п.10 (прецедент A8/A11/finance). **v2.25 наследие:** A8 infosec-tooling install-sync: ZAP #68 + Ward #70 **установлены портативно 21.05.2026** (без choco — Go 1.26.3 / Temurin JRE 17 zip с проверкой SHA256, бинари в `bin/*` gitignored) → статус PENDING INSTALL снят (§3.3 строки 68/70, §6); setup-доки `docs/security/zap-setup.md` + `docs/security/ward-setup.md`; §0 cross-refs Pravila v1.37→**v1.38** / PSR_v1 v3.20→**v3.21** / Tooling Прил.Н v2.20→**v2.21**; §9 +запись. Прямой Edit — worktree-эксцепшн §5 п.10 (прецедент A8/A11/finance). **v2.24 наследие:** A8 infosec-tooling integration: §3.3 +#68 OWASP ZAP (DAST, PENDING INSTALL) / #69 Nuclei (CLI, installed+verified) / #70 Ward (CLI, заменил abandoned Enlightn, PENDING INSTALL) / #71 pdn-152fz-audit / #72 threat-model / #73 security-go-live (self-authored скилы); §0 cross-refs Pravila v1.37 / PSR_v1 v3.20 / Tooling Прил.Н v2.20; §6 +абзац; §9 +запись; новая 17-я off-phase подкатегория infosec-tooling (раздел A8); ADR-014 (IS1–IS9); провенанс-вет IS9 (риск ToxicSkills); серверный слой защиты → open questions SEC-1..SEC-7 (Б-1). Перенумеровано v2.23→v2.24 при ребейзе на origin/main (v2.23 параллельно занят observer). **v2.23 наследие:** observer missed-activations: §0 cross-ref Pravila v1.35→**v1.36** (§16.4 условное missed-activation правило — профильная задача без активации релевантного узла → сигнал в STATUS.md C5 + /brain-retro; маппинг `tools/observer-classification-map.json` + `tools/.node-dormancy.json` двойной сигнал dormant/DEFERRED); §3.6 +абзац missed-activation; §9 +запись. План `docs/superpowers/plans/2026-05-21-observer-missed-activations.md`. **v2.22 наследие:** A1 backend-tooling integration: §3.3 +#64 Rector / #65 PHP Insights / #66 laravel-backend-patterns / #67 NightOwl (DEFERRED); §0 cross-refs Pravila v1.35 / PSR_v1 v3.19 / Tooling Прил.Н v2.19; §6 +абзац; §9 +запись; новая 16-я off-phase подкатегория backend-tooling (раздел A1); ADR-013. **v2.21 наследие:** finance-tooling integration (C6+C7): §3.3 +#61 finance plugin (homed C7) / #62 billing-audit (C6) / #63 ru-tax-accounting (C7); §0 cross-refs Pravila v1.34 / PSR_v1 v3.18 / Tooling Прил.Н v2.18; §6 +абзац; §9 +запись; новая 15-я off-phase подкатегория finance-tooling; ADR-012. **v2.20 наследие:** observer factor-analysis extension + phase 1.1 (ADR-011 amend): schema v2 (`decision_provenance` 3 kinds incl. `user_chose_from_options`) + двусторонний routing-gate/C5 + brain-retro analyzer; полные записи — §9 v2.19/v2.20. **v2.18 наследие:** Brain governance Phase A/B/C complete (ADR-011): §0 cross-refs Pravila v1.30→**v1.31** / PSR_v1 v3.15→**v3.16** / Tooling v2.16→**v2.17**; §3.X +cross-ref на `docs/router-procedure.md` v1.0 (single SoT для router procedure); §9 +brain governance entry. Реализованы 4 phases: Phase A normative foundation (5 tasks/15 commits — ADR-011 anchor, router-procedure.md v1.0, Tooling Прил. Н 9-attribute template на 58 узлов, Pravila §16, PSR_v1 R16); Phase B observer infrastructure (6 tasks/6 commits — scaffolding + PII filter + Stop-hook + HK1 pre-check + settings.json registration + /brain-retro skill); Phase C 4 controllers (5 tasks/5 commits — L1-watcher, cross-ref-checker (DWC, noise refinement pending), observer-of-observer 54w self-prune, STATUS.md generator, lefthook wire). Spec v1.1 `docs/superpowers/specs/2026-05-19-brain-governance-design.md` (factor analysis amendment: routing_decision events + primary_rationale). План `docs/superpowers/plans/2026-05-19-brain-governance.md`. C1+C2 surface 9+150 pre-existing drifts (known); WARN-only lefthook pending follow-up alias/scope refinement. **v2.17 наследие:** компакция «мозга» (SYSTEM-аудит findings 2/3/6/7): §3 title — убран счётчик «60 инструментов»; §3.3 строки #31–#60 (30 off-phase) свёрнуты из многострочных абзацев в однострочный индекс с пином на Tooling §4.NN (finding 2 — устранён дубль реестра с Tooling); §3.3 footer / §1 row 2b / §0 row-label — счётчик «60» → пин на Tooling Прил. Н §0 как канон (finding 3); §2 БД + §8 self-review — schema-метрики → пин на header `db/schema.sql` (finding 3); §3.5 ruflo свёрнут в dormant-стаб, исторические абзацы убраны (finding 6); §0 cross-refs Pravila v1.29→**v1.30** / PSR_v1 v3.14→**v3.15** / Tooling v2.15→**v2.16**; §9 +запись. Finding 1 (раздувание шапки/§0) и finding 5 (баг хука экономии) — заказчиком не выбраны, вне scope. План `docs/superpowers/plans/2026-05-18-brain-compaction-findings-2-3-6-7.md`. **v2.16 наследие:** SYSTEM-аудит «мозга» Rec1–Rec5 closure: §0 cross-refs Pravila v1.28→**v1.29** / Tooling v2.14→**v2.15** / Plugin_stack_rules v3.13→**v3.14**; §3.5 +bold-блок «СТАТУС 18.05.2026: ИЗОЛИРОВАН (dormant)» — live-связи ruflo (hooks/MCP/daemon) отключены без удаления артефактов; §3.7 (новый) — cross-ref на `docs/routing-off-phase.md` v1.0 (off-phase routing-аид: 30 узлов триггер→узел + 12 канонических связок L1–L12); §3.6 → §3.8 renumber; §6 +абзац «2026-05-18 SYSTEM-аудит мозга + Rec1–Rec5 закрытие». Snapshot — `docs/discovery/2026-05-18-system-audit-brain.md`. Rec2 закрыл 2 из 3 ⚫-конфликтов карты. Связано: Pravila v1.29 (+§14.9 ruflo dormant), Tooling v2.15 (§4.10 status-block), PSR_v1 v3.14 (+R15 off-phase routing), `docs/routing-off-phase.md` v1.0 (новый), memory `feedback_ruflo_isolated.md` + `feedback_automation_map_not_sot.md` + `feedback_hard_rule_no_alt_question.md`. **v2.15 наследие:** Anthropic dev-tooling integration: формализованы 5 Anthropic dev-плагинов из marketplace `anthropics/claude-plugins-official`, уже включённых в `~/.claude/settings.json` `enabledPlugins` user-level без формализации — #56 skill-creator / #57 plugin-dev / #58 hookify (новая тринадцатая off-phase подкатегория «authoring-tooling») + #59 claude-code-setup / #60 context7 (новая четырнадцатая «dev-support»); §3 title 55→60, §1 row 2b 55→60, §3.3 +5 строк #56–#60, §3.3 footer 55→60 (14 off-phase подкатегорий), §0 cross-refs Pravila v1.28 / PSR_v1 v3.13 / Tooling v2.14, §6 +абзац, §9 +запись. Триггер — аудит «мозга» через discovery-interview SYSTEM-режим: вскрыт L1-паттерн «плагин включён в settings.json без формализации в правилах» (повтор UPM/21st 10.05, Sentry/Redis 13.05). ADR-010. hookify HK1 — pre-check на коллизию с 6-компонентной economy/skill-discipline хук-архитектурой; закрывает 🔴-конфликт карты `hookify_plugin ↔ hk_pre_claude`. **NB:** ветка `feat/anthropic-dev-tooling` ребейзнута на parallel-sessions §15 (origin/main `781a59c`) — v2.14 и Pravila v1.27 параллельно заняты §15-эпиком; перенумеровано v2.14→v2.15, Pravila v1.27→v1.28. План `docs/superpowers/plans/2026-05-18-anthropic-dev-tooling-formalization.md`. Через прямой Edit — worktree-constraint эксцепшн §5 п.10 (прецедент A11/C10/discovery). **v2.14 наследие:** parallel-sessions-coordination: sync §0 cross-ref Pravila v1.26→v1.27 + §1 priority chain footer-абзац «Hard-rules вне §9» (упомянуты §12/§14/**§15**); §15 — третье hard-rule после §12 и §14, лечит два класса инцидентов параллельных Claude-сессий (Sprint 6 субагент-угон-ветки + Tooling v2.11 collision 17.05.2026). Spec — `docs/superpowers/specs/2026-05-18-parallel-sessions-coordination-design.md`, план — `docs/superpowers/plans/2026-05-18-parallel-sessions-coordination.md`. v1.27 в Pravila добавила hard-rule с фиксированным списком 8 нормативных файлов (Pravila/CLAUDE.md/Tooling/PSR_v1/MEMORY.md/Открытые_вопросы/docs/adr/*/db/schema.sql) — pre-flight `git fetch && git log HEAD..origin/main --oneline` обязателен перед правкой любого. Связанные артефакты (на этой же ветке `feat/parallel-sessions-coordination`): `docs/sessions/CURRENT.md` заявочный лог + `tools/subagent-prompt-prefix.mjs` PreToolUse Task auto-inject хук + `.claude/skills/subagent-driven-development/` wrapper-скил с git-safety-checklist (Tasks 2/4/7 плана). PSR_v1 и Tooling **не правятся** — §15 про координацию сессий, не плагинов. Через `/claude-md-management:claude-md-improver`. **v2.13 наследие:** discovery-interview integration: формализован #55 discovery-interview (self-authored project-скил `.claude/skills/discovery-interview/` — структурированное интервью-discovery до проектирования, два режима: FEATURE интервью заказчика перед фичей + SYSTEM ориентация по мета-слою) как новая двенадцатая off-phase подкатегория «discovery-tooling»; §3 title 54→55, §1 row 2b 54→55, §3.3 +строка #55, §3.3 footer 54→55, §0 cross-refs Pravila v1.26 / PSR_v1 v3.12 / Tooling v2.13, §6 +абзац discovery-interview, §9 +запись. ADR-009. Триггер-eval 20/20. Конфликт-аудит DI1–DI6 — разрез по слою-источнику с C10-скилом process-analysis #53 (тот вскрывает бизнес-процесс из app-кода; discovery-interview интервьюирует человека). План `docs/superpowers/plans/2026-05-18-discovery-interview-integration.md`. Через прямой Edit — worktree-constraint эксцепшн §5 п.10 (claude-md-management не наводится на worktree-копию CLAUDE.md; прецедент A11/C10). **v2.12 наследие:** C10 business-process integration: формализованы #51 operations (Claude Code marketplace-плагин `operations@knowledge-work-plugins` v1.2.0, Anthropic Verified — 9 скилов, 0 lifecycle-хуков) + #52 process-modeling + #53 process-analysis (self-authored project-скилы `.claude/skills/`) + #54 n8n-mcp (workflow-движок — DEFERRED, у портала нет n8n) — новая одиннадцатая off-phase подкатегория «business-process»; §3 title 50→54, §1 row 2b 50→54, §3.3 +4 строки #51/#52/#53/#54, §3.3 footer 50→54, §0 cross-refs Pravila v1.25 / PSR_v1 v3.11 / Tooling v2.12, §6 +абзац C10, §9 +запись. ADR-008. Конфликт-аудит OPS1/OPS5/N8N1/LINT1/BPMN1/PA1. План `docs/superpowers/plans/2026-05-17-c10-business-process-tooling-integration.md`. Через прямой Edit — worktree-constraint эксцепшн §5 п.10 (claude-md-management не наводится на worktree-копию CLAUDE.md; прецедент A11/ruflo). **v2.11 наследие:** ADR-006 Decision-4 icon-path boundary mirror: §3.3 строка #45 +граница «Lucide-иконки → канонический путь `lucide-vue-next` + Vuetify IconSet; raw-SVG Universal Icons MCP — только для не-Lucide коллекций». Источник — ADR-006 (поправка 17.05.2026, Decision item 4 — закрывает ранее нерегламентированную границу #45 ↔ `lucide-vue-next`, выявленную конфликт-аудитом карты). §0 cross-ref Tooling v2.10→**v2.11** (Pravila v1.24 / PSR_v1 v3.10 — без изменений: assess показал, что Pravila §13.2 делегирует к ADR-006, PSR_v1 R10.1 — role-registry). Счётчики инструментов без изменений (50 позиций). §9 +запись. Через `/claude-md-management:claude-md-improver` в worktree от origin/main (прямой Edit — worktree-constraint эксцепшн §5 п.10, прецедент A11 v2.10). **v2.10 наследие:** A11 ml-ai-tooling integration: формализованы #48 promptfoo (npm devDependency `promptfoo`, CLI-eval LLM-промптов, MIT) + #49 Data Scientist skill (вендоренный сторонний скил `.claude/skills/data-scientist/`, классический ML-воркфлоу) + #50 Jupyter MCP (исполняемые ноутбуки — DEFERRED, требует Python ML-окружения) — новая десятая off-phase подкатегория «ml-ai-tooling»; §3 title 47→50, §1 row 2b 47→50, §3.3 +3 строки #48/#49/#50, §3.3 footer 47→50, §0 cross-refs Pravila v1.24 / PSR_v1 v3.10 / Tooling v2.10, §6 +абзац A11, §9 +запись. ADR-007. Конфликт-аудит ML1 (promptfoo платные вызовы → вручную/CI) / ML3 (Data Scientist skill вендорен) / ML7 (bus-factor → вендоринг). План `docs/superpowers/plans/2026-05-17-a11-ml-ai-tooling-integration.md`. Через прямой Edit — worktree-constraint эксцепшн §5 п.10 (claude-md-management не наводится на worktree-копию CLAUDE.md; прецедент ruflo big-bang v2.0). **v2.9 наследие:** A3 integration-tooling integration: формализован #47 openapi-mcp-server (+ api-docs agent — узел карты A3 без Tooling-номера), новая девятая off-phase подкатегория «integration-tooling»; §3 title 46→47, §1 row 2b 46→47, §3.3 +строка #47, §3.3 footer 46→47, §0 cross-refs Pravila v1.23 / PSR_v1 v3.9 / Tooling v2.9, §6 +абзац A3, §9 +запись. Карта A3 0→7 узлов / 116→118. План `docs/superpowers/plans/2026-05-17-a3-integration-tooling-integration.md`. Через `/claude-md-management:claude-md-improver`. **v2.8 наследие:** A4 design-tooling integration: формализованы #44 Figma MCP (DEFERRED — у проекта нет Figma-аккаунта) + #45 Universal Icons MCP + #46 Design plugin — новая восьмая off-phase подкатегория «design-tooling»; §3 title 43→46, §1 row 2b 43→46, §3.3 +3 строки #44/#45/#46, §3.3 footer 43→46, §0 cross-refs Pravila v1.22 / PSR_v1 v3.8 / Tooling v2.8, §6 +абзац A4, §9 +запись. План `docs/superpowers/plans/2026-05-17-a4-design-tooling-integration.md`. Через `/claude-md-management:claude-md-improver`. **v2.7 наследие:** deptrac architecture-fitness integration: формализован #43 deptrac (Composer dev-dependency `deptrac/deptrac` v4.6.1, BSD-3) — 4-й инструмент off-phase подкатегории architecture-tooling; архитектурный fitness-гейт направления зависимостей / границ слоёв, врезан как lefthook pre-commit job 10; §3 title 42→43, §1 row 2b 42→43, §3.3 +строка #43, §3.3 footer 42→43, §0 cross-refs Pravila v1.21 / PSR_v1 v3.7 / Tooling v2.7, §6 +абзац deptrac, §9 +запись. План `docs/superpowers/plans/2026-05-17-deptrac-architecture-fitness-integration.md`. Через `/claude-md-management:claude-md-improver`. **v2.6 наследие:** C9 project-management integration: формализованы #41 CCPM (вендоренный скил) + #42 product-management (Anthropic marketplace-плагин) — новая седьмая off-phase подкатегория «project-management»; §3 title 40→42, §1 row 2b 40→42, §3.3 +2 строки #41/#42, §3.3 footer 40→42 + семь подкатегорий, §0 cross-refs Pravila v1.20 / PSR_v1 v3.6 / Tooling v2.6, §6 +абзац C9, §9 +запись. План `docs/superpowers/plans/2026-05-17-c9-project-management-tooling-integration.md`. Через `/claude-md-management:claude-md-improver`. **v2.5 наследие:** фактическая правка #40 Security Guidance: это **блокирующий** PreToolUse-хук (`sys.exit(2)`), не warn-only — при первом за сессию срабатывании уязвимого паттерна в файле блокирует правку (одноразовый speed-bump per «файл+правило», retry проходит); §3.3 строка #40 переписана (+SG2 Windows-починка python3-резолва), §6 абзац D3 уточнён, §0 cross-refs Pravila v1.19 / PSR_v1 v3.5 / Tooling v2.5, §9 +запись. Счётчики инструментов без изменений (40 позиций). План `docs/superpowers/plans/2026-05-17-d3-audit-risk-tooling-integration.md`. Через `/claude-md-management:claude-md-improver`. **v2.4 наследие:** D3 audit-security: формализованы #39 Trail of Bits Skills (субсет 8 audit-плагинов, marketplace `trailofbits`, CC-BY-SA-4.0) + #40 Security Guidance (Anthropic PreToolUse-хук) — новая шестая off-phase подкатегория «audit-security»; §3 title 35→40 (исправляет A6-пропуск: title оставался «35»), §1 row 2b 38→40, §3.3 +2 строки, §3.3 footer 38→40, §0 cross-refs Pravila v1.18 / PSR_v1 v3.4 / Tooling v2.4, §6 +абзац интеграции. План `docs/superpowers/plans/2026-05-17-d3-audit-risk-tooling-integration.md`. Через `/claude-md-management:claude-md-improver`. **v2.3 наследие:** A6 architecture-tooling: формализованы 3 инструмента раздела A6 карты «Архитектура систем» — #36 adr-kit, #37 mermaid-skill, #38 architecture-patterns; §3 title 35→38, §3.3 +3 строки, §1 row 2b 35→38, §3.3 footer 35→38 (пятая off-phase подкатегория architecture-tooling), §0 cross-refs Pravila v1.17 / PSR_v1 v3.3 / Tooling v2.3, §6 +абзац интеграции. План `docs/superpowers/plans/2026-05-17-a6-architecture-tooling-integration.md`. Через `/claude-md-management:claude-md-improver`. **v2.2 наследие:** реколлаж ruflo: §1 убран уровень −1 (entry-point framing → advisory-подсистема); §3/§3.5 advisory-подсистема; §0 cross-refs Pravila v1.16 / PSR_v1 v3.2 / Tooling v2.2. **v2.1 наследие:** §14 queen-trigger: §1 priority chain уровень −1 +триггер queen/королева → Pravila §14; §3.5 +абзац «Queen trigger»; §0 cross-ref Pravila v1.15. **v2.0 наследие:** Ruflo big-bang integration: 8-level → 9-level priority chain, ruflo Queen-led routing на уровне −1 (entry-point); §1 +уровень −1 ruflo; §3 title «35 инструментов» → «35 + ruflo orchestration layer»; §3.5 added (off-phase orchestration «ruflo»; «Заметки к settings.json» renumber §3.5 → §3.6); §5 п.10 → sub-policy note (claude-md-management остаётся preferred channel через ruflo routing); §6 +2026-05-15 ruflo phase paragraph. Major bump reflects architectural inversion (paper-level: ruflo daemon/swarm not yet running, technical compensators retained: gitleaks/RLS/dev DB only). Через прямой Edit (plan §1.4 user-authorized exception к §5 п.10). **v1.93 наследие:** Audit #3 deferred fixes sprint closure: sync schema header drift «62 → 63 базовые таблицы (61 regular + 2 partitioned parents: deals + supplier_lead_costs)» в §0 row «Схема БД», §2 БД row, §8 self-review row. Цифра 62 была commit-baseline артефактом; фактический count base-tables (regular + partitioned parents) — 63 (audit-verified в Audit #3 Phase 3). Sprint commits: `8ba9c55` (plan) + `0c36b7a` (Pa11y migration) + `e746b3c` (dead code cleanup, incl. schema.sql:4 header) + `c5c0e76` (coverage debt F-COV-01/02/03 → ≥95%) + `c524227` (P3 tooling). Pushed `f9d2452..c524227 main -> main`. Через `/claude-md-management:claude-md-improver`. **v1.92 наследие:** Task 9 sync нормативки после merge PR #3 `cc5f63b`: §3.3 +#34 Sentry MCP + #35 Redis MCP (off-phase debug-runtime category, отдельная от UI-пула UPM/21st и от infrastructure claude-md-management); §3 title «33» → «35»; §3.3 footer count 33 → 35 (29 phase-active + 5 off-phase + 1 historic); §0 cross-refs Tooling v1.16 → v1.17, PSR_v1 v2.0 → v2.1, Pravila v1.12 → v1.13. **v1.91 наследие:** Session-end documentation hygiene после CTO-19 ✅ closure via Lucide migration. Содержание: (1) §0 cross-ref row Pravila v1.11 → **v1.12** (sync: §4.6 +visual smoke methodology для UI-refactor; §4.7 +п.4 plans/specs relative paths `../../../`); (2) §9 +v1.91 entry. Связано: реестр v1.82→v1.83 (CTO-19 closure в commit `0832997`); audit `docs/superpowers/audits/2026-05-12-portal-full-audit-findings.md` Q.INFO.001 +audit methodology gap note (Phase 4 SAST checks must begin с `ls .github/workflows/`); memory quirks 74-76 (Lucide+Histoire peerDep / Vuetify-internal mdi defaults gap / plans-relative-paths). Регрессия зелёная (verified в commit `0832997`): Pest --parallel 742/739/0/3 ✅, Vitest 88 files / 683 passed + 3 skipped, Vite build 3.52s, axe-core /admin/billing 0 iconography violations, lychee 252 OK / 0 errors, gitleaks 0 (372+ commits). Workflow learning: `superpowers:brainstorming` → `:writing-plans` → `:subagent-driven-development` efficient для mechanical UI-refactor (icon migration). Через `/claude-md-management:revise-claude-md`. **v1.90 наследие:** Merge R15 motion-runtime removal cleanup из `origin/main` в `plan5-frontend-projects` (commits `0fd93fd` planning + `615db99` нормативная правка). Plan5 ветка форкнулась 12.05 утром от `48f27b4` ДО появления `615db99` на main; после 113 атомарных коммитов на plan5 (audit fixes, Plan 5 frontend Tasks 7-11, Quiet Luxury portal redesign, Q.DEFER.002/003/004 closures, audit-cleanup tail) — merge синхронизирует R15 changes. **§5 п.12** → маркер «Резерв (снят 12.05.2026, см. CHANGELOG)» (нумерация п.1–11 сохранена, чтобы cross-refs в memory не сломать). **§2 строка «Animation default stack»** переписана с regulatory denylist на guidance recommendation (motion-v/gsap/anime.js/lottie-web/popmotion/@motionone/dom — ✅ разрешены без обоснования; framer-motion остаётся technical block — React-only peerDep, runtime crash в Vue, не regulatory rule). **§0 cross-refs** обновлены — Pravila v1.10 → **v1.11**, PSR_v1 v1.7 → **v2.0**, Tooling v1.15 → **v1.16**. **§6 фаза** + **§8 self-review** строки (Plan 4/5 + Quiet Luxury + Q.DEFER closures context + schema baseline v8.19 + dev-actual factual) — preserved из plan5 v1.88/v1.89 base. Plan5 v1.89 factual fix §6 (615db99 = R15 removal, ≠ Plan 4) подтверждён и сохранён. NB: §9 содержит **две v1.88 entries** — plan5 audit schema-sync + origin/main R15-removal — это collision версионной нумерации parallel-branch bump'ов; обе валидны исторически, явно labelled в §9. Files fast-forwarded без conflict: `Plugin_stack_rules_v1.md` (R15 удалён, 162 lines diff), `Pravila_raboty_Claude_v1_1.md` (§11.5/§13.2 счётчик 16→15 + cross-refs), `Tooling_v8_3.md` (§9.2 reformulated). Через ручное conflict resolution на 2 файлах (CLAUDE.md + CHANGELOG_claude_md.md) + post-merge `/claude-md-management:revise-claude-md` polish (per §5 п.10). **v1.89 наследие:** factual fix §6 + шапка v1.88 changelog (615db99 ≠ Plan 4). **v1.88 наследие (plan5 branch):** audit-driven sync §0/§2/§6/§8 после полного аудита портала. Schema-метрики §0/§2/§8 разделены на «commit baseline v8.19» (62/12/117/39/5/13/5) + «dev-actual factual» (75/102/289/39/5/19/0). **v1.88 наследие (origin/main):** снятие R15 motion-runtime restrictions per user decision 12.05.2026 («сними все запреты на использование framer motion»); conscious rollback v1.83 audited construction. **v1.87 наследие:** sync schema-метрик после Plan 4 (Billing+CSV+Admin) на ветке `plan4-billing`. Schema **v8.11 → v8.19**. Предыдущая v1.86 — закрытие 13 находок третьего аудита (детали в [docs/CHANGELOG_claude_md.md](docs/CHANGELOG_claude_md.md)). **Назначение:** оперативная карта для Claude Code. Не первоисточник — первоисточники указаны в §0. **Владелец и режим правок:** все изменения этого файла — **только** через плагин `claude-md-management` (skills `/claude-md-management:claude-md-improver` для audit/targeted-updates и `/claude-md-management:revise-claude-md` для capture session-learnings). Прямые правки запрещены — см. §5 п.11. @@ -12,7 +12,7 @@ | Тема | Документ | |---|---| -| Продуктовые правила работы Claude | [docs/Pravila_raboty_Claude_v1_1.md](docs/Pravila_raboty_Claude_v1_1.md) (**v1.39 от 22.05.2026** — C1 marketing-tooling: §13.2 +абзац «Off-phase marketing-tooling» (#74 marketing / #75 marketingskills / #76 brand-voice / #77 marketing-ru / #78 Яндекс.Метрика MCP / #79 Яндекс.Директ+Wordstat MCP / #80 Telegram MCP / #81 Postiz / #82 DataForSEO MCP DEFERRED / #83 Unisender Go MCP DEFERRED), 18-я off-phase подкатегория; провенанс-вет IS9 (`docs/security/marketing-vet.md`); VK out-of-scope; ADR-015. **v1.38 наследие** — A8 infosec-tooling install-sync: ZAP #68 + Ward #70 установлены портативно 21.05 → статус PENDING снят (§13.2). **v1.37 наследие** — A8 infosec-tooling §13.2 +абзац «Off-phase infosec-tooling» (A8: #68 OWASP ZAP DAST PENDING / #69 Nuclei CLI / #70 Ward CLI PENDING / #71 pdn-152fz-audit / #72 threat-model / #73 security-go-live), 17-я off-phase подкатегория; провенанс-вет IS9; ADR-014. **v1.36 наследие** — A8 infosec-tooling install-sync: ZAP #68 + Ward #70 установлены портативно 21.05 → статус PENDING снят (§13.2). **v1.37 наследие** — A8 infosec-tooling §13.2 +абзац «Off-phase infosec-tooling» (A8: #68 OWASP ZAP DAST PENDING / #69 Nuclei CLI / #70 Ward CLI PENDING / #71 pdn-152fz-audit / #72 threat-model / #73 security-go-live), 17-я off-phase подкатегория; провенанс-вет IS9; ADR-014. **v1.36 наследие** — §16.4 расширен симметрией missed activation (условное правило): эпизод с профильной классификацией (`tools/observer-classification-map.json`) при `node_chosen === 'direct'` и наличии non-dormant рекомендуемого узла → сигнал, surface в STATUS.md C5 + /brain-retro; DEFERRED-исключения через `tools/.node-dormancy.json`. **v1.35 наследие** — A1 backend-tooling §13.2 +абзац «Off-phase backend-tooling» (A1: #64 Rector / #65 PHP Insights / #66 laravel-backend-patterns / #67 NightOwl DEFERRED), 16-я off-phase подкатегория. **v1.34 наследие** — finance-tooling §13.2 +абзац «Off-phase finance-tooling» (C6+C7: #61 finance plugin / #62 billing-audit / #63 ru-tax-accounting). **v1.33 наследие** — observer factor-analysis phase 1.1 (ADR-011 amend): §16.2 — `decision_provenance.kind` расширен до 3 значений (`autonomous` \| `user_directed_method` \| `user_chose_from_options`); 3-й kind — collaborative-choice case (заказчик выбирает один из предложенных Claude вариантов в предыдущем ходе); §16.7 +абзац «Граница `user_chose_from_options`» — routing-gate НЕ блокирует этот kind (выбор из choice-space самого Claude, не навязанный извне метод). **v1.32 наследие** — observer factor-analysis extension (ADR-011 amend): §16.2 +абзац «Схема эпизода v2» (`schema_version`, `decision_provenance`, `environment`, `task_size`, `task_ref`, `prompt_signal`; `outcome` `unknown` при записи; +`hook_fired`/`interrupt`/`retry`/`time_burn`/`parse_gap`; `observer_error` маркер); §16.3 4→5 контролёров (+C5 observer-coverage-checker, warn-only); §16.7 (новое) routing-тег-дисциплина — Stop-хук `decision: block` при навязанном методе без тега, `stop_hook_active` guard против петли; §16.8 (новое) самодисциплина наблюдателя (`observer_error` маркер вместо тихого пропуска, `parse_gap` событие, C5 контролёр); §16.6 +cross-ref на factor-analysis spec. **v1.31 наследие** — Brain governance §16 implementation (ADR-011): §16.1 router-only, §16.2 observer scope B (5 mandatory fields incl. primary_rationale, 7 event kinds incl. routing_decision per spec v1.1), §16.3 4 controllers, §16.4 поведенческое правило «не использован ≠ проблема», §16.5 не override-floor §9, §16.6 cross-refs. **v1.30 наследие** — компакция «мозга» finding 3: §13.2 счётчики off-phase подкатегорий → пин на Tooling Прил. Н §0 (канон счётчиков); §14 заголовок +метка «(dormant — §14.9)». **v1.29 наследие** — +§14.9 «Текущий статус: изолирован (18.05.2026, dormant)» — Rec2 SYSTEM-аудита: ruflo изолирован от Claude-потока, артефакты сохранены; live-связи hooks/MCP/daemon отключены; queen-триггер §14.1 dormant. **v1.28 наследие** — §13.2 +абзац «Off-phase authoring-tooling + dev-support» (#56 skill-creator / #57 plugin-dev / #58 hookify — тринадцатая off-phase подкатегория authoring-tooling; #59 claude-code-setup / #60 context7 — четырнадцатая dev-support); ADR-010. **v1.27 наследие** — §15 hard-rule «Параллельные сессии» (15.1 субагенты+git Sonnet/Opus only, 15.2 нормативка+pre-flight sync с 8-позиционным списком файлов: Pravila/CLAUDE.md/Tooling/PSR_v1/MEMORY.md/Открытые_вопросы/docs/adr/*/db/schema.sql, 15.3 cross-refs); третье hard-rule после §12 и §14; +§10 changelog entry v1.27. **v1.26 наследие** — §13.2 +абзац «Off-phase discovery-tooling» (#55 discovery-interview — проектный скил, режимы FEATURE+SYSTEM); двенадцатая off-phase подкатегория. **v1.25 наследие** — §13.2 +абзац «Off-phase business-process» (#51 operations / #52 process-modeling / #53 process-analysis / #54 n8n-mcp DEFERRED — раздел C10 карты); одиннадцатая off-phase подкатегория. **v1.24 наследие** — §13.2 +абзац «Off-phase ml-ai-tooling» (#48 promptfoo / #49 Data Scientist skill / #50 Jupyter MCP DEFERRED — раздел A11 карты); десятая off-phase подкатегория. **v1.23 наследие** — §13.2 +абзац «Off-phase integration-tooling» (#47 openapi-mcp-server / api-docs agent — раздел A3 карты); девятая off-phase подкатегория. **v1.22 наследие** — §13.2 +абзац «Off-phase design-tooling» (#44 Figma MCP / #45 Universal Icons MCP / #46 Design plugin — раздел A4 карты); восьмая off-phase подкатегория. **v1.21 наследие** — §13.2 абзац «Off-phase architecture-tooling» расширен: +#43 deptrac (4-й инструмент категории, Composer dev-dep). **v1.20 наследие** — §13.2 +абзац «Off-phase project-management» (#41 CCPM / #42 product-management — раздел C9 карты); новая седьмая off-phase подкатегория. **v1.19 наследие** — §13.2 факт-правка #40 Security Guidance: блокирующий хук (`sys.exit 2`), не warn-only. **v1.18 наследие** — §13.2 +абзац «Off-phase audit-security» (#39 Trail of Bits Skills / #40 Security Guidance — раздел D3 карты). **v1.17 наследие** — §13.2 +абзац «Off-phase architecture-tooling» (#36 adr-kit / #37 mermaid-skill / #38 architecture-patterns — раздел A6 карты). **v1.16 наследие** — реколлаж ruflo: §1 убран уровень −1, ruflo переописан в advisory-подсистему; §14 queen-триггер сохранён. **v1.15 наследие** — §14 (new) Ruflo Queen routing hard-rule (триггер queen/королева). **v1.14 наследие** — §12 → sub-policy под ruflo routing (commit `9c3057b`); Superpowers hard-rule сохраняется. **v1.13 наследие** — §13.2 +новый абзац «Off-phase MCP debug-runtime (отдельная категория)»: `@sentry/mcp-server` (Tooling #34) + `@modelcontextprotocol/server-redis` (Tooling #35) — retrospective formalization после PR #3 merge. Категория отдельная от UI-пула и от infrastructure (claude-md-management). READ-ONLY usage обязателен. v1.12 наследие — methodology additions §4.6 visual smoke для UI-refactor + §4.7 п.4 plans/specs relative paths `../../../`) | +| Продуктовые правила работы Claude | [docs/Pravila_raboty_Claude_v1_1.md](docs/Pravila_raboty_Claude_v1_1.md) (**v1.40 от 24.05.2026** — Делегирование проектным AI-агентам: §2.4 (новая подсекция) — контроллер обязан звать `normative-sync` (#84) после крупной задачи + `prod-deploy-validator` (#85) перед каждым выкатом liderra.ru; прежние `pest-parallel-debugger` + `rls-reviewer` тоже формализованы в одной таблице. Project-агенты в `docs/registry/nodes.yaml` subcategory `project-agent`, **не входят в Tooling канон счётчиков** #1-#83. Связано: CLAUDE.md v2.28, spec `docs/superpowers/specs/2026-05-24-controller-offload-agents-design.md`. **v1.39 наследие** — C1 marketing-tooling: §13.2 +абзац «Off-phase marketing-tooling» (#74 marketing / #75 marketingskills / #76 brand-voice / #77 marketing-ru / #78 Яндекс.Метрика MCP / #79 Яндекс.Директ+Wordstat MCP / #80 Telegram MCP / #81 Postiz / #82 DataForSEO MCP DEFERRED / #83 Unisender Go MCP DEFERRED), 18-я off-phase подкатегория; провенанс-вет IS9 (`docs/security/marketing-vet.md`); VK out-of-scope; ADR-015. **v1.38 наследие** — A8 infosec-tooling install-sync: ZAP #68 + Ward #70 установлены портативно 21.05 → статус PENDING снят (§13.2). **v1.37 наследие** — A8 infosec-tooling §13.2 +абзац «Off-phase infosec-tooling» (A8: #68 OWASP ZAP DAST PENDING / #69 Nuclei CLI / #70 Ward CLI PENDING / #71 pdn-152fz-audit / #72 threat-model / #73 security-go-live), 17-я off-phase подкатегория; провенанс-вет IS9; ADR-014. **v1.36 наследие** — A8 infosec-tooling install-sync: ZAP #68 + Ward #70 установлены портативно 21.05 → статус PENDING снят (§13.2). **v1.37 наследие** — A8 infosec-tooling §13.2 +абзац «Off-phase infosec-tooling» (A8: #68 OWASP ZAP DAST PENDING / #69 Nuclei CLI / #70 Ward CLI PENDING / #71 pdn-152fz-audit / #72 threat-model / #73 security-go-live), 17-я off-phase подкатегория; провенанс-вет IS9; ADR-014. **v1.36 наследие** — §16.4 расширен симметрией missed activation (условное правило): эпизод с профильной классификацией (`tools/observer-classification-map.json`) при `node_chosen === 'direct'` и наличии non-dormant рекомендуемого узла → сигнал, surface в STATUS.md C5 + /brain-retro; DEFERRED-исключения через `tools/.node-dormancy.json`. **v1.35 наследие** — A1 backend-tooling §13.2 +абзац «Off-phase backend-tooling» (A1: #64 Rector / #65 PHP Insights / #66 laravel-backend-patterns / #67 NightOwl DEFERRED), 16-я off-phase подкатегория. **v1.34 наследие** — finance-tooling §13.2 +абзац «Off-phase finance-tooling» (C6+C7: #61 finance plugin / #62 billing-audit / #63 ru-tax-accounting). **v1.33 наследие** — observer factor-analysis phase 1.1 (ADR-011 amend): §16.2 — `decision_provenance.kind` расширен до 3 значений (`autonomous` \| `user_directed_method` \| `user_chose_from_options`); 3-й kind — collaborative-choice case (заказчик выбирает один из предложенных Claude вариантов в предыдущем ходе); §16.7 +абзац «Граница `user_chose_from_options`» — routing-gate НЕ блокирует этот kind (выбор из choice-space самого Claude, не навязанный извне метод). **v1.32 наследие** — observer factor-analysis extension (ADR-011 amend): §16.2 +абзац «Схема эпизода v2» (`schema_version`, `decision_provenance`, `environment`, `task_size`, `task_ref`, `prompt_signal`; `outcome` `unknown` при записи; +`hook_fired`/`interrupt`/`retry`/`time_burn`/`parse_gap`; `observer_error` маркер); §16.3 4→5 контролёров (+C5 observer-coverage-checker, warn-only); §16.7 (новое) routing-тег-дисциплина — Stop-хук `decision: block` при навязанном методе без тега, `stop_hook_active` guard против петли; §16.8 (новое) самодисциплина наблюдателя (`observer_error` маркер вместо тихого пропуска, `parse_gap` событие, C5 контролёр); §16.6 +cross-ref на factor-analysis spec. **v1.31 наследие** — Brain governance §16 implementation (ADR-011): §16.1 router-only, §16.2 observer scope B (5 mandatory fields incl. primary_rationale, 7 event kinds incl. routing_decision per spec v1.1), §16.3 4 controllers, §16.4 поведенческое правило «не использован ≠ проблема», §16.5 не override-floor §9, §16.6 cross-refs. **v1.30 наследие** — компакция «мозга» finding 3: §13.2 счётчики off-phase подкатегорий → пин на Tooling Прил. Н §0 (канон счётчиков); §14 заголовок +метка «(dormant — §14.9)». **v1.29 наследие** — +§14.9 «Текущий статус: изолирован (18.05.2026, dormant)» — Rec2 SYSTEM-аудита: ruflo изолирован от Claude-потока, артефакты сохранены; live-связи hooks/MCP/daemon отключены; queen-триггер §14.1 dormant. **v1.28 наследие** — §13.2 +абзац «Off-phase authoring-tooling + dev-support» (#56 skill-creator / #57 plugin-dev / #58 hookify — тринадцатая off-phase подкатегория authoring-tooling; #59 claude-code-setup / #60 context7 — четырнадцатая dev-support); ADR-010. **v1.27 наследие** — §15 hard-rule «Параллельные сессии» (15.1 субагенты+git Sonnet/Opus only, 15.2 нормативка+pre-flight sync с 8-позиционным списком файлов: Pravila/CLAUDE.md/Tooling/PSR_v1/MEMORY.md/Открытые_вопросы/docs/adr/*/db/schema.sql, 15.3 cross-refs); третье hard-rule после §12 и §14; +§10 changelog entry v1.27. **v1.26 наследие** — §13.2 +абзац «Off-phase discovery-tooling» (#55 discovery-interview — проектный скил, режимы FEATURE+SYSTEM); двенадцатая off-phase подкатегория. **v1.25 наследие** — §13.2 +абзац «Off-phase business-process» (#51 operations / #52 process-modeling / #53 process-analysis / #54 n8n-mcp DEFERRED — раздел C10 карты); одиннадцатая off-phase подкатегория. **v1.24 наследие** — §13.2 +абзац «Off-phase ml-ai-tooling» (#48 promptfoo / #49 Data Scientist skill / #50 Jupyter MCP DEFERRED — раздел A11 карты); десятая off-phase подкатегория. **v1.23 наследие** — §13.2 +абзац «Off-phase integration-tooling» (#47 openapi-mcp-server / api-docs agent — раздел A3 карты); девятая off-phase подкатегория. **v1.22 наследие** — §13.2 +абзац «Off-phase design-tooling» (#44 Figma MCP / #45 Universal Icons MCP / #46 Design plugin — раздел A4 карты); восьмая off-phase подкатегория. **v1.21 наследие** — §13.2 абзац «Off-phase architecture-tooling» расширен: +#43 deptrac (4-й инструмент категории, Composer dev-dep). **v1.20 наследие** — §13.2 +абзац «Off-phase project-management» (#41 CCPM / #42 product-management — раздел C9 карты); новая седьмая off-phase подкатегория. **v1.19 наследие** — §13.2 факт-правка #40 Security Guidance: блокирующий хук (`sys.exit 2`), не warn-only. **v1.18 наследие** — §13.2 +абзац «Off-phase audit-security» (#39 Trail of Bits Skills / #40 Security Guidance — раздел D3 карты). **v1.17 наследие** — §13.2 +абзац «Off-phase architecture-tooling» (#36 adr-kit / #37 mermaid-skill / #38 architecture-patterns — раздел A6 карты). **v1.16 наследие** — реколлаж ruflo: §1 убран уровень −1, ruflo переописан в advisory-подсистему; §14 queen-триггер сохранён. **v1.15 наследие** — §14 (new) Ruflo Queen routing hard-rule (триггер queen/королева). **v1.14 наследие** — §12 → sub-policy под ruflo routing (commit `9c3057b`); Superpowers hard-rule сохраняется. **v1.13 наследие** — §13.2 +новый абзац «Off-phase MCP debug-runtime (отдельная категория)»: `@sentry/mcp-server` (Tooling #34) + `@modelcontextprotocol/server-redis` (Tooling #35) — retrospective formalization после PR #3 merge. Категория отдельная от UI-пула и от infrastructure (claude-md-management). READ-ONLY usage обязателен. v1.12 наследие — methodology additions §4.6 visual smoke для UI-refactor + §4.7 п.4 plans/specs relative paths `../../../`) | | **Правила совместного использования плагинов Claude** | [docs/Plugin_stack_rules_v1.md](docs/Plugin_stack_rules_v1.md) (**v3.22 от 22.05.2026** — C1 marketing-tooling: R10.1 Блок 1 +marketing (#74 plugin + #75 marketingskills + #76 brand-voice + #77 marketing-ru) + note (+Яндекс.Метрика MCP #78 / Яндекс.Директ+Wordstat MCP #79 / Telegram MCP #80 / Postiz #81 / DataForSEO MCP #82 DEFERRED / Unisender Go MCP #83 DEFERRED); R15.6 +marketing-tooling; 18-я off-phase подкатегория; не UI → вне R6/R14. **v3.21 наследие** — A8 infosec-tooling install-sync: ZAP #68 + Ward #70 установлены портативно 21.05 → R10.1 Блок 1 note + Блок 3 ZAP-row статус PENDING INSTALL снят. **v3.20 наследие** — A8 infosec-tooling install-sync: ZAP #68 + Ward #70 установлены портативно 21.05 → R10.1 Блок 1 note + Блок 3 ZAP-row статус PENDING INSTALL снят. **v3.20 наследие** — A8 infosec-tooling: R10.1 Блок 1 note +infosec (#69 Nuclei / #70 Ward — CLI; #71-73 self-authored скилы) + Блок 3 +OWASP ZAP MCP (#68, PENDING INSTALL); R15.6 +infosec-tooling; 17-я off-phase подкатегория. **v3.19 наследие** — A1 backend-tooling: R10.1 Блок 1 note +backend-tooling (#64 Rector / #65 PHP Insights — Composer dev-deps; #66 laravel-backend-patterns — self-authored; #67 NightOwl DEFERRED); R15.6 +backend-tooling; 16-я off-phase подкатегория. **v3.18 наследие** — finance-tooling: R10.1 Блок 1 +finance plugin (#61) + note (+billing-audit #62 / ru-tax-accounting #63). **v3.17 наследие** — observer schema v2 sync (ADR-011 amend): R16.1 +предложение про `schema_version` / `decision_provenance` / `environment` / `task_size` / `prompt_signal` + расширенные события (`hook_fired`/`interrupt`/`retry`/`time_burn`/`parse_gap`) + `observer_error` маркер; R16.4 +cross-ref на factor-analysis spec и plan. R0–R15 без изменений. Routing-gate / C5 / `/brain-retro` analyzer — нормативно в Pravila §16.7/§16.8 + ADR-011 §5; R16 фиксирует evidence-сбор, не enforcement. **v3.16 наследие** — Brain governance R16 «Brain evidence loop» (ADR-011): R16.1 observer scope (5 mandatory fields), R16.2 plugin stack-conscious events (routing_decision + factor matrix 5 axes), R16.3 не override (R16 evidence-сбор, не правило выбора), R16.4 cross-refs. 17 правил R0-R16. **v3.15 наследие** — компакция «мозга» finding 3: R10.1 счётчики позиций → пин на Tooling Прил. Н §0 (канон счётчиков). **v3.14 наследие** — +R15 «Off-phase routing» на свободном слоте (motion удалён v2.0): R15.1 off-phase узлы вне R6.0/R6.1/R14, R15.2 routing-таблица — `docs/routing-off-phase.md` v1.0+, R15.3 приоритет специфичности + ADR-границы, R15.4 hard-rules Pravila §12/§14/§15 перевешивают, R15.5 live-override, R15.6 гранулярные категории, R15.7 обычное правило. UI-аппарат R0–R14 без изменений. Rec5 SYSTEM-аудита. **v3.13 наследие** — R10.1 Блок 1 +5 строк (#56 skill-creator / #57 plugin-dev / #58 hookify / #59 claude-code-setup / #60 context7) + note (v3.13); новые тринадцатая (authoring-tooling) и четырнадцатая (dev-support) off-phase подкатегории, не UI → вне R6/R14; hookify HK1 pre-check. **v3.12 наследие** — R10.1 Блок 1 note (v3.12) +discovery-interview (#55, self-authored project-скил, discovery-tooling); новая двенадцатая off-phase подкатегория discovery-tooling, не UI → вне R6/R14. **v3.11 наследие** — R10.1 Блок 1 +operations (#51, business-process, marketplace-плагин) + Блок 1 note +process-modeling (#52) + process-analysis (#53, self-authored project-скилы) + Блок 3 +n8n-mcp (#54, DEFERRED); новая одиннадцатая off-phase подкатегория business-process, не UI → вне R6/R14. **v3.10 наследие** — R10.1 Блок 3 +Jupyter MCP (#50, ml-ai-tooling, DEFERRED) + Блок 1 note +promptfoo (#48) + Data Scientist skill (#49); новая десятая off-phase подкатегория ml-ai-tooling, не UI → вне R6/R14. **v3.9 наследие** — R10.1 Блок 3 +openapi-mcp-server (#47, integration-tooling); новая девятая off-phase подкатегория integration-tooling, не UI → вне R6/R14. **v3.8 наследие** — R10.1 Блок 1 +Design plugin (#46, design-tooling); Блок 3 +Universal Icons MCP (#45) + Figma MCP (#44, DEFERRED); новая восьмая off-phase подкатегория design-tooling, не UI → вне R6/R14. **v3.7 наследие** — R10.1 Блок 1 +note: deptrac #43 (Composer dev-dep, не marketplace-плагин, architecture-tooling), не UI → вне R6/R14. **v3.6 наследие** — R10.1 Блок 1 +2 строки (CCPM + product-management); новая категория project-management, не UI → вне R6/R14. **v3.5 наследие** — R10.1 факт-правка security-guidance: блокирующий хук, не warn-only. **v3.4 наследие** — R10.1 Блок 1 +2 строки (Trail of Bits Skills + security-guidance); новая категория audit-security, не UI → вне R6/R14. **v3.3 наследие** — R10.1 Блок 1 +2 строки (adr-kit + architecture-patterns) + note (mermaid-skill вендоренный); новая категория architecture-tooling, не UI → вне R6/R14. **v3.2 наследие** — реколлаж ruflo: R0 entry-point framing убран, ruflo advisory-подсистема. **v3.1 наследие** — queen-триггер §14. **v3.0 наследие** — major bump: R0 stack-gate → paired-stack delegation pattern (commit `d30cbeb`). **v2.1 наследие** — R10.1 Блок 3 (MCP-серверы) +2 строки sentry + redis с категорией **debug-runtime**: `@sentry/mcp-server@0.33.0+` (Tooling #34, pending Б-1) + `@modelcontextprotocol/server-redis@2025.4.25` (Tooling #35, deprecated Anthropic source, Memurai PONG verified Task 4). Не trigger'ит R6.0/R6.1 фильтры и не входит в R14 pipeline UI-генераторов. v2.0 наследие — major bump: removal of R15 motion-runtime restrictions per user decision 12.05.2026; conscious rollback v1.4 audited construction; framer-motion переведён из regulatory hard-запрета в technical-guidance уровень) | | Полный реестр формализованных позиций тулчейна (счётчики — канон в Прил. Н §0) | [docs/Tooling_v8_3.md](docs/Tooling_v8_3.md) (**Прил. Н v2.23 от 22.05.2026** — C1 marketing-tooling: §4.49 marketing plugin + §4.50 marketingskills + §4.51 brand-voice + §4.52 marketing-ru + §4.53 Яндекс.Метрика MCP + §4.54 Яндекс.Директ+Wordstat MCP + §4.55 Telegram MCP + §4.56 Postiz + §4.57 DataForSEO MCP (DEFERRED) + §4.58 Unisender Go MCP (DEFERRED); §0 счётчик 73→83 (93→103 total) + 18-я подкатегория marketing-tooling (раздел C1); ADR-015; IS9-вет (`docs/security/marketing-vet.md`). **Прил. Н v2.22 наследие** — pg_audit #28 + pg_anonymizer #29 установлены на боевом `liderra.ru` (расширения PostgreSQL фазы 3): §5.1 attribute-блоки #28/#29 + §6 compliance-таблица + §10.4 шаг 2 → ✅ прод; setup-dok `docs/security/pgaudit-anonymizer-setup.md`. **Прил. Н v2.21 наследие** — pg_audit #28 + pg_anonymizer #29 установлены на боевом `liderra.ru` (расширения PostgreSQL фазы 3): §5.1 attribute-блоки #28/#29 + §6 compliance-таблица + §10.4 шаг 2 → ✅ прод; setup-док `docs/security/pgaudit-anonymizer-setup.md`. **Прил. Н v2.21 наследие** — A8 infosec-tooling install-sync: ZAP #68 + Ward #70 установлены портативно 21.05 → §4.43/§4.45 dormant true→false, §4.48 уточнён, setup-доки `docs/security/{zap,ward}-setup.md`; счётчики без изменений (73/93). **Прил. Н v2.20 наследие** — A8 infosec-tooling: §4.43 OWASP ZAP #68 + §4.44 Nuclei #69 + §4.45 Ward #70 + §4.46 pdn-152fz-audit #71 + §4.47 threat-model #72 + §4.48 security-go-live #73; §0 счётчик 67→73 (87→93 total) + 17-я подкатегория infosec-tooling (раздел A8); ADR-014. **Прил. Н v2.19 наследие** — A1 backend-tooling: §4.39 Rector #64 + §4.40 PHP Insights #65 + §4.41 laravel-backend-patterns #66 + §4.42 NightOwl #67 (DEFERRED); §0 счётчик 63→67 (83→87 total) + 16-я подкатегория backend-tooling (раздел A1); ADR-013. **Прил. Н v2.18 наследие** — finance-tooling: §4.36 finance plugin #61 + §4.37 billing-audit #62 + §4.38 ru-tax-accounting #63; §0 счётчик 60→63 + 15-я подкатегория finance-tooling. **Прил. Н v2.17 наследие** — Brain governance Task A3 (ADR-011): §0.1 row template (9 obligatory attributes) + 58 Атрибуты blocks на всех узлах реестра. Дамп-блоки для phase-active (§2.4/§3.5/§5.1 для phase 0/1/3) + inline-блоки для phase-2 (§4.1-§4.4) и off-phase (§4.5-§4.35). dormant=true для #1 PG MCP (replaced), #17 pg_partman (no native Windows PG ext), ruflo §4.10. 6 atomic sub-batches: 1f77134/0718e41/16f7f1c/ca4da69/39231ef/3e73396 + header bump 8170527. Структурный реестр — вход для router-procedure.md (commit 8a2e701) step 3. **Прил. Н v2.16 наследие** — компакция «мозга» finding 3: §0 +«КАНОН СЧЁТЧИКОВ» anchor (Прил. Н §0 — единственный источник числовых счётчиков тулчейна); §12 заголовок — убран stale-счётчик «35». **v2.15 наследие** — §4.10 +status-block «ruflo ИЗОЛИРОВАН 18.05.2026» (live-связи hooks/MCP/daemon отключены, артефакты сохранены, queen-триггер dormant). Счётчики тулчейна без изменений — 60 формализованных позиций + 20 ruflo plugins = 80 total. Rec2 SYSTEM-аудита. **v2.14 наследие** — §4.31–§4.35 (#56 skill-creator / #57 plugin-dev / #58 hookify — authoring-tooling; #59 claude-code-setup / #60 context7 — dev-support — новые тринадцатая и четырнадцатая off-phase подкатегории); §0 счётчик 55→60 (75→80 total). **v2.13 наследие** — §4.30 (#55 discovery-interview, discovery-tooling — новая двенадцатая off-phase подкатегория); §0 счётчик 54→55. **v2.12 наследие** — §4.26/§4.27/§4.28/§4.29 (#51 operations / #52 process-modeling / #53 process-analysis / #54 n8n-mcp DEFERRED, business-process — новая одиннадцатая off-phase подкатегория); §0 счётчик 50→54. **v2.11 наследие** — §4.20 +UI3 (ADR-006 Decision-4 icon-path boundary mirror: Lucide-иконки канонически через `lucide-vue-next` + Vuetify IconSet, raw-SVG MCP — только не-Lucide коллекции). **v2.10 наследие** — §4.23/§4.24/§4.25 (#48 promptfoo / #49 Data Scientist skill / #50 Jupyter MCP DEFERRED, ml-ai-tooling — новая десятая off-phase подкатегория); §0 счётчик 47→50. **v2.9 наследие** — §4.22 (#47 openapi-mcp-server, integration-tooling — новая девятая off-phase подкатегория); §0 счётчик 46→47. **v2.8 наследие** — §4.19/§4.20/§4.21 (#44 Figma MCP / #45 Universal Icons MCP / #46 Design plugin, design-tooling — новая восьмая off-phase подкатегория); §0 счётчик 43→46. **v2.7 наследие** — §4.18 (#43 deptrac, architecture-tooling — 4-й инструмент категории); §0 счётчик 42→43. **v2.6 наследие** — §4.16/§4.17 (#41 CCPM / #42 product-management), новая седьмая off-phase подкатегория project-management; §0 счётчик 40→42. **v2.5 наследие** — §4.15 факт-правка #40 Security Guidance: блокирующий хук (`sys.exit 2`), не warn-only; +SG2 Windows-починка python3-резолва. **v2.4 наследие** — §4.14/§4.15 (#39 Trail of Bits Skills / #40 Security Guidance), новая шестая off-phase подкатегория audit-security; §0 счётчик 38→40. **v2.3 наследие** — §4.11–4.13 (#36 adr-kit / #37 mermaid-skill / #38 architecture-patterns), новая пятая off-phase подкатегория architecture-tooling; §0 счётчик 35→38. **v2.2 наследие** — реколлаж ruflo: §4.10 переписан из «entry-point оркестратора уровня −1» в «advisory/automation-подсистема». **v2.1 наследие** — queen-триггер §14. **v2.0 наследие** — §4.10 (новый) «Orchestration layer (ruflo)»: ruflo v3.7.0-alpha.38+ (off-phase, orchestration category). **v1.17 наследие** — §0 счётчик off-phase 3 → 5; итого 33 → 35; §4.8 (новый) — #34 Sentry MCP (`@sentry/mcp-server@0.33.0+`, official; pending Sentry instance deployment Б-1); §4.9 (новый) — #35 Redis MCP (`@modelcontextprotocol/server-redis@2025.4.25`, deprecated Anthropic source, рабочий с Memurai localhost:6379). Категория **debug-runtime**, отдельная от UI-пула. v1.16 наследие — §9.2 «Motion runtime библиотеки» переформулирован из regulatory denylist в technical guidance) | | Главное ТЗ | [docs/CRM_bp-gr_Инструкция_v8_5.md](docs/CRM_bp-gr_Инструкция_v8_5.md) (v8.5 от 07.05.2026 — реализация 27 решений аудита C; in-place hygiene v1.20 от 08.05.2026 поздний вечер: §2.4/§5.5/§5.6/§6.5/§11/§20.12.3/§21.1/§27.1 синхронизированы под schema v8.6 двустадийный dedup) | @@ -213,6 +213,19 @@ Quick-reference «триггер задачи → off-phase узел» для 30 - **Источник истины**: см. [docs/Tooling_v8_3.md](docs/Tooling_v8_3.md) Прил. Н §8 — source of truth для всех конфигов проекта. - **`$schema`**: канонический URL — `https://json.schemastore.org/claude-code-settings.json` (только этот валиден для Claude Code; кастомные `_comments` / `comment` в файле — нельзя, схема их отвергает). +### 3.9. Project-local AI-агенты (v2.28) + +В `.claude/agents/` лежат 4 узко-специализированных Sonnet 4.6 агента, которым контроллер ОБЯЗАН делегировать соответствующие классы задач — формальный контракт оформлен в [Pravila §2.4](docs/Pravila_raboty_Claude_v1_1.md). Регистрация в `docs/registry/nodes.yaml` (`subcategory: project-agent`) для missed-activation детектора. Project-агенты **не входят в Tooling канон счётчиков** (#1-#83 — реестр инструментов), их ID идут #84+ с атрибутом `agent_file:`. + +| ID | Агент | Когда зовём | Файл | +|----|-------|-------------|------| +| #84 | `normative-sync` | После закрытия крупной off-phase интеграции / brain governance артефакта / принятого ADR — синк 4 нормативных файлов | [.claude/agents/normative-sync.md](.claude/agents/normative-sync.md) | +| #85 | `prod-deploy-validator` | Перед любым выкатом на боевой liderra.ru — 8 SSH pre-flight проверок, GO/NO-GO вердикт | [.claude/agents/prod-deploy-validator.md](.claude/agents/prod-deploy-validator.md) | +| — | `pest-parallel-debugger` | Диагностика Pest 4 `--parallel` failures (квирки 72/73/77) | [.claude/agents/pest-parallel-debugger.md](.claude/agents/pest-parallel-debugger.md) | +| — | `rls-reviewer` | RLS-compliance review при правке `db/schema.sql` или `db/migrations/` | [.claude/agents/rls-reviewer.md](.claude/agents/rls-reviewer.md) | + +Полный perimeter триггеров — в `description`-frontmatter каждого файла; настоящая таблица — индекс контракта, не дублирующая декларация. Спек обоих новых агентов: [docs/superpowers/specs/2026-05-24-controller-offload-agents-design.md](docs/superpowers/specs/2026-05-24-controller-offload-agents-design.md). + --- ## 4. Команды быстрого доступа @@ -363,6 +376,8 @@ trivy image liderra:latest Полная история — [docs/CHANGELOG_claude_md.md](docs/CHANGELOG_claude_md.md) (вынесена 09.05.2026 при правке v1.73→v1.74 ради лаконичности шапки). Здесь — последние правки: +- **v2.28 от 24.05.2026** — Project-local AI-agents delegation rule per spec `docs/superpowers/specs/2026-05-24-controller-offload-agents-design.md`. **§0 cross-refs:** Pravila v1.39→**v1.40** (CLAUDE.md шапка + §0 table). PSR_v1 / Tooling — **не правятся** (project-агенты не плагины Claude и не в Tooling-каноне #1-#83). **§3.9 (новая подсекция)** — таблица 4 project-агентов (#84 normative-sync, #85 prod-deploy-validator, прежние pest-parallel-debugger + rls-reviewer без Tooling-ID), их триггеры, ссылки на agent-файлы и spec. **Pravila §2.4 (новая подсекция)** — формальный hard-rule «контроллер ОБЯЗАН делегировать» с дисциплиной делегирования (live-отмена возможна, эскалации без угадывания). **Уровень 2 (наблюдатель):** +2 узла #84/#85 в `docs/registry/nodes.yaml` с `subcategory: "project-agent"`, `agent_file:` атрибутом и `triggers.classification: "normative_sync_needed" / "prod_deploy_imminent"` (классификации авто-подхватываются `tools/registry-to-classification-map.mjs` build-функцией; deprecated `tools/observer-classification-map.json` не правится). `tools/registry-load.test.mjs` fixtures bumped 83→85 / 75→77. **§9 +this entry.** Header v2.27→**v2.28**. Уровень 1+2 одновременно (level 3 хуки — defer). Через прямой Edit — worktree-эксцепшн §5 п.10. + - **v2.27 от 22.05.2026** — C1 marketing-tooling integration per plan `docs/superpowers/plans/2026-05-22-c1-marketing-tooling.md`. **§0 cross-refs:** Pravila v1.38→**v1.39** / PSR_v1 v3.21→**v3.22** / Tooling Прил.Н v2.22→**v2.23**. **§3.3** +#74 marketing / #75 marketingskills / #76 brand-voice / #77 marketing-ru / #78 Яндекс.Метрика MCP / #79 Яндекс.Директ+Wordstat MCP / #80 Telegram MCP / #81 Postiz / #82 DataForSEO MCP (DEFERRED) / #83 Unisender Go MCP (DEFERRED) — 18-я off-phase подкатегория marketing-tooling, раздел C1. **§6** +абзац C1 marketing-tooling сверху. **§9 +this entry.** Header v2.26→**v2.27**. **Узлы:** #74 marketing (Anthropic plugin, 8 скилов, primary resolver) + #75 marketingskills (вендоренный, 40 фреймворков, модель UPM/материал, MKT3) + #76 brand-voice (Anthropic partner, тон бренда) + #77 marketing-ru (self-authored, РФ-каналы/лендинг/152-ФЗ, eval 20/20) + #78 Яндекс.Метрика MCP (READ-ONLY `atomkraft/yandex-metrika-mcp`) + #79 Яндекс.Директ+Wordstat MCP (только Wordstat, Direct-мутации off IS9) + #80 Telegram MCP (выделенный аккаунт) + #81 Postiz (self-host AGPL-3.0 internal, VK+Telegram) + #82 DataForSEO MCP (**DEFERRED** post-Б-1) + #83 Unisender Go MCP (**DEFERRED** нет upstream). VK out-of-scope (нет MCP, прямой API → отдельный ADR). IS9-вет всех внешних → `docs/security/marketing-vet.md`. ADR-015 (MKT1–MKT9). Tasks 1 (plugin enable) + 4 (MCP installs) pending фактической установки. Через прямой Edit — worktree-эксцепшн §5 п.10 (прецедент A8/A11/C10/discovery/finance). - **v2.26 от 22.05.2026** — pg_audit #28 + pg_anonymizer #29 установлены на боевом `liderra.ru`. **§0 cross-ref:** Tooling Прил.Н v2.21→**v2.22** (Pravila/PSR_v1 без изменений — #28/#29 это phase-3 расширения БД, не off-phase плагины). **§3.4** строки #28/#29 → ✅ прод. **§6** +абзац сверху. **§9 +this entry.** Header v2.25→**v2.26**. Новый setup-док `docs/security/pgaudit-anonymizer-setup.md`. **Факт (на боевом Ubuntu 24.04 / PostgreSQL 16):** #28 pg_audit 16.0 (пакет `postgresql-16-pgaudit`, `shared_preload_libraries='pgaudit'` → 1 перезапуск ~2с, `pgaudit.log='ddl, role, write'`, `log_parameter=off` — ПДн не в логах; 152-ФЗ аудит БД ✅); #29 pg_anonymizer 3.0.13 (готового пакета нет → собран из исходников Rust/pgrx 0.18.0 ~15мин; `LOAD 'anon'` on-demand, не db-wide preload; маскирование verified). Бэкап до работ снят. **⚠️ Незапланированно:** `postgresql-server-dev-16` из PGDG потянул минорный апгрейд PG 16.13→16.14 + авто-перезапуск (данные целы, портал здоров) → версия закреплена `apt-mark hold` + PGDG-репа off. Build-тулчейн+Rust убраны (~3.5 ГБ). Tooling §5.1 (#28/#29 attribute-блоки +статус) / §6 compliance-таблица / §10.4 шаг 2 → ✅ прод. **Гейты:** cross-ref-checker + l1-watcher 0 drift. NB: runbook `docs/deploy/test-server-runbook.md` (ветка feat/test-deploy) — sync отдельно (на origin/main файла нет). Исполнено по SSH на боевом сервере + worktree от origin/main для нормативки. Через прямой Edit — worktree-эксцепшн §5 п.10. - **v2.25 от 21.05.2026** — A8 infosec install-sync (ZAP #68 + Ward #70 установлены). **§0 cross-refs:** Pravila v1.37→**v1.38** / PSR_v1 v3.20→**v3.21** / Tooling Прил.Н v2.20→**v2.21**. **§3.3** строки 68/70 — снят PENDING INSTALL, +пути установки. **§6** +абзац «установка ZAP+Ward» сверху. **§9 +this entry.** Header v2.24→**v2.25**. **Факт:** по выбору заказчика «оба портативно» (choco отклонён) — Ward собран portable Go 1.26.3 (`go install ...@v0.4.1` → `bin/ward.exe`, smoke `app/` 2 находки), ZAP cross-platform 2.17.0 + MCP-аддон на portable Temurin JRE 17 (daemon API verified), всё `bin/*` gitignored (~1.2 ГБ). Setup-доки `docs/security/zap-setup.md` + `ward-setup.md`. Полный нормативный синк всех 4 файлов (Tooling/CLAUDE/PSR/Pravila) — статус PENDING снят в Tooling §4.43/§4.45 (dormant→false) / §4.48, PSR R10.1 Блок1/Блок3, Pravila §13.2. Гейты GREEN: cross-ref-checker + l1-watcher 0 drift / markdownlint / lychee / gitleaks. Квирки установки: curl error 56 на go.dev → Invoke-WebRequest; git-bash tar ≠ zip → tar.exe/Expand-Archive; Start-Process калечит jar-путь с пробелами → -WorkingDirectory. Исполнено в worktree `a8-install-sync` от origin/main (pre-flight чист). Через прямой Edit — worktree-эксцепшн §5 п.10. diff --git a/app/app/Http/Controllers/Api/DealController.php b/app/app/Http/Controllers/Api/DealController.php index 36f8e6bf..0a29bfae 100644 --- a/app/app/Http/Controllers/Api/DealController.php +++ b/app/app/Http/Controllers/Api/DealController.php @@ -24,9 +24,9 @@ use Illuminate\Support\Facades\DB; * вынесены в `DealBulkActionController`, `export()` — в `DealExportController`. * Этот класс остаётся только для CRUD по одной записи. * - * NB: webhook-flow (автосоздание из crm.bp-gr.ru) — отдельный endpoint - * `WebhookReceiveController` + `ProcessWebhookJob` (асинхронно через очередь - * с advisory lock + dedup). Этот controller — для ручных action'ов из UI. + * NB: webhook-flow (приём из crm.bp-gr.ru) — отдельный endpoint + * `SupplierWebhookController` + `RouteSupplierLeadJob` (шеринг-канал). + * Этот controller — для ручных action'ов из UI. * * J1 (Sprint 3F): auth:sanctum+tenant, tenant_id из auth()->user(). * diff --git a/app/app/Http/Controllers/Api/SupplierWebhookController.php b/app/app/Http/Controllers/Api/SupplierWebhookController.php index 93df1129..cad18a39 100644 --- a/app/app/Http/Controllers/Api/SupplierWebhookController.php +++ b/app/app/Http/Controllers/Api/SupplierWebhookController.php @@ -29,8 +29,8 @@ use Symfony\Component\HttpFoundation\IpUtils; * Идемпотентность: UNIQUE INDEX на supplier_leads.vid. При дубле возвращаем * 200 OK без re-dispatch (поставщик может ретранслировать одни и те же лиды). * - * Backward-compat: legacy /api/webhook/{token} (per-tenant) живёт параллельно - * на WebhookReceiveController — не пересекается. + * Единственный приёмник входящих лидов от crm.bp-gr.ru (legacy per-tenant + * webhook был удалён вместе с ProcessWebhookJob). * * Plan 2.6 fix #ii (10.05.2026): пустой `supplier_ip_allowlist = '[]'` на * production env теперь fail-closed (`verifyIpAllowlist` возвращает false если diff --git a/app/app/Http/Controllers/Api/WebhookReceiveController.php b/app/app/Http/Controllers/Api/WebhookReceiveController.php deleted file mode 100644 index 35e301ed..00000000 --- a/app/app/Http/Controllers/Api/WebhookReceiveController.php +++ /dev/null @@ -1,158 +0,0 @@ -` - * пришёл, проверяем `hash_hmac('sha256', raw_body, webhook_token)`. - * Невалидная подпись → 401. Отсутствие header — пропускаем (backward-compat - * для существующих интеграций; на prod через `system_settings.webhook_hmac_required` - * сделаем обязательной). - * 5. INSERT в webhook_log (RLS-обёрнутый), dispatch ProcessWebhookJob → 202. - */ -class WebhookReceiveController extends Controller -{ - /** POST /api/webhook/{token} */ - public function receive(Request $request, string $token): JsonResponse - { - $tenant = Tenant::query() - ->where('webhook_token', $token) - ->first(); - - if ($tenant === null) { - return response()->json([ - 'message' => 'Webhook token не найден или ротирован.', - ], 404); - } - - // Per-token rate-limit. Лимит из system_settings.webhook_rate_limit_rps - // (RPS), приводим к per-minute через ×60. Decay 60 сек. - $rpsLimit = $this->getRateLimitRps(); - $perMinuteLimit = $rpsLimit * 60; - $rateKey = "webhook:{$tenant->id}"; - - if (RateLimiter::tooManyAttempts($rateKey, $perMinuteLimit)) { - $retryAfter = RateLimiter::availableIn($rateKey); - - return response()->json([ - 'message' => "Превышен лимит ({$rpsLimit} RPS). Повтор через {$retryAfter} сек.", - 'retry_after' => $retryAfter, - ], 429)->header('Retry-After', (string) $retryAfter); - } - - RateLimiter::hit($rateKey, 60); - - // HMAC-валидация. Опциональная по умолчанию (backward-compat); при - // `system_settings.webhook_hmac_required = true` — обязательная, - // запросы без X-Webhook-Signature → 401. - $signature = $request->header('X-Webhook-Signature'); - $hmacRequired = $this->isHmacRequired(); - - if ($signature === null && $hmacRequired) { - return response()->json([ - 'message' => 'X-Webhook-Signature header требуется (HMAC обязателен в этой инсталляции).', - ], 401); - } - if ($signature !== null) { - $rawBody = $request->getContent(); - $expected = 'sha256='.hash_hmac('sha256', $rawBody, $token); - if (! hash_equals($expected, $signature)) { - return response()->json(['message' => 'Невалидная HMAC-подпись.'], 401); - } - } - - // Валидация payload (после tenant lookup чтобы посчитать rate-limit - // даже на bad payload — иначе rate-limit можно обойти 422-ответами). - $validated = $request->validate([ - 'vid' => 'required|integer|min:1', - 'project' => 'required|string|max:255', - 'phone' => 'required|string|max:50', - 'time' => 'required|integer|min:1', - 'tag' => 'nullable|string|max:100', - 'phones' => 'nullable|array', - ]); - - $logId = $this->insertWebhookLogStub($tenant->id, $validated); - - ProcessWebhookJob::dispatch($tenant->id, $validated, $logId); - - return response()->json([ - 'status' => 'accepted', - 'tenant_id' => $tenant->id, - 'webhook_log_id' => $logId, - ], 202); - } - - private function getRateLimitRps(): int - { - $setting = SystemSetting::find('webhook_rate_limit_rps'); - if ($setting === null) { - return 100; // sensible default из seed v8.7 - } - - return max(1, (int) $setting->value); - } - - /** - * HMAC-обязательность. Audit-fix B3: если ключ отсутствует в БД — default - * TRUE (HMAC обязателен по умолчанию). Отключить можно только явной - * установкой webhook_hmac_required=false. Неизвестное значение → fail-secure - * (HMAC требуется). - */ - private function isHmacRequired(): bool - { - $setting = SystemSetting::find('webhook_hmac_required'); - if ($setting === null) { - return true; - } - - return ! in_array($setting->value, ['false', '0'], true); - } - - /** - * Минимальный INSERT-stub в webhook_log (если таблица существует). - * На MVP webhook_log необязателен — возвращаем null если таблицы нет. - * - * @param array $payload - */ - private function insertWebhookLogStub(int $tenantId, array $payload): ?int - { - if (! Schema::hasTable('webhook_log')) { - return null; - } - - // RLS требует SET LOCAL — оборачиваем в транзакцию. - return (int) DB::transaction(function () use ($tenantId, $payload) { - DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId); - - return DB::table('webhook_log')->insertGetId([ - 'tenant_id' => $tenantId, - 'received_at' => now(), - 'raw_payload' => json_encode($payload, JSON_UNESCAPED_UNICODE), - ]); - }); - } -} diff --git a/app/app/Jobs/ImportLeadsJob.php b/app/app/Jobs/ImportLeadsJob.php index f3fba026..193ecedb 100644 --- a/app/app/Jobs/ImportLeadsJob.php +++ b/app/app/Jobs/ImportLeadsJob.php @@ -26,7 +26,7 @@ use Throwable; * * Жизненный цикл import_log: pending → processing → done | failed. * RLS: каждый доступ к БД задаёт SET LOCAL app.current_tenant_id (воркер - * вне middleware-контекста — паритет с ProcessWebhookJob). + * вне middleware-контекста — паритет с RouteSupplierLeadJob). */ class ImportLeadsJob implements ShouldQueue { diff --git a/app/app/Jobs/ProcessWebhookJob.php b/app/app/Jobs/ProcessWebhookJob.php deleted file mode 100644 index 16786459..00000000 --- a/app/app/Jobs/ProcessWebhookJob.php +++ /dev/null @@ -1,342 +0,0 @@ - $data Webhook payload: vid, project, tag, phone, phones, time - */ - public function __construct( - public int $tenantId, - public array $data, - public ?int $webhookLogId = null, - ) {} - - public function handle(): void - { - DB::transaction(function (): void { - DB::statement('SET LOCAL app.current_tenant_id = '.$this->tenantId); - - $tenant = Tenant::query() - ->whereKey($this->tenantId) - ->lockForUpdate() - ->first(); - - if ($tenant === null) { - throw new RuntimeException("Tenant {$this->tenantId} not found"); - } - - if ((int) $tenant->balance_leads <= 0) { - $this->logRejection($tenant, RejectedDealsLog::REASON_ZERO_BALANCE); - - return; - } - - $cleanProjectName = preg_replace('/^B[123]_/', '', (string) $this->data['project']); - $project = Project::firstOrCreate( - ['tenant_id' => $tenant->id, 'name' => $cleanProjectName], - ['type' => 'webhook'], - ); - - $receivedAt = Carbon::createFromTimestamp((int) $this->data['time']); - $sourceCrmId = (int) $this->data['vid']; - - $deal = $this->upsertDeal( - tenant: $tenant, - project: $project, - sourceCrmId: $sourceCrmId, - receivedAt: $receivedAt, - ); - - if (! $deal->wasRecentlyCreated) { - return; - } - - $this->chargeNewLead($tenant, $project, $deal); - }); - } - - private function logRejection(Tenant $tenant, string $reason): void - { - $rejected = RejectedDealsLog::create([ - 'tenant_id' => $tenant->id, - 'webhook_log_id' => $this->webhookLogId, - 'reason' => $reason, - 'payload' => $this->data, - 'created_at' => now(), - ]); - - Log::info("webhook.rejected.{$reason}", [ - 'tenant_id' => $tenant->id, - 'vid' => $this->data['vid'] ?? null, - ]); - - // ТЗ §18.5: zero_balance — уведомить тенант. Anti-spam: не более - // 1 email/час на тенант. Исключаем только что вставленную запись - // через id (timestamp-сравнение ненадёжно из-за microsecond precision). - if ($reason === RejectedDealsLog::REASON_ZERO_BALANCE) { - $previousCount = RejectedDealsLog::query() - ->where('tenant_id', $tenant->id) - ->where('reason', $reason) - ->where('created_at', '>=', now()->subHour()) - ->where('id', '!=', $rejected->id) - ->count(); - - if ($previousCount === 0) { - app(NotificationService::class)->notifyZeroBalance($tenant); - } - } - } - - /** - * Списание баланса при создании НОВОЙ сделки + аудит-записи. - * - * Все INSERT'ы в одной транзакции — целостность гарантирована (Ю-2): - * deal + supplier_lead_cost + balance_transaction появляются атомарно. - */ - private function chargeNewLead(Tenant $tenant, Project $project, Deal $deal): void - { - $tenant->decrement('balance_leads'); - $tenant->refresh(); - - BalanceTransaction::create([ - 'tenant_id' => $tenant->id, - 'type' => BalanceTransaction::TYPE_LEAD_CHARGE, - 'amount_leads' => -1, - 'balance_leads_after' => (int) $tenant->balance_leads, - 'related_type' => Deal::class, - 'related_id' => $deal->id, - 'created_at' => now(), - ]); - - $resolver = app(SupplierResolver::class); - $supplierId = $resolver->resolveForProject($project); - if ($supplierId !== null) { - SupplierLeadCost::create([ - 'deal_id' => $deal->id, - 'received_at' => $deal->received_at, - 'supplier_id' => $supplierId, - 'cost_rub' => $resolver->costRubSnapshot($supplierId), - 'supplier_lead_id' => (int) $this->data['vid'], - 'created_at' => now(), - ]); - } else { - Log::warning('webhook.no_active_supplier', [ - 'tenant_id' => $tenant->id, - 'project_id' => $project->id, - 'deal_id' => $deal->id, - ]); - } - - ActivityLog::create([ - 'tenant_id' => $tenant->id, - 'user_id' => null, - 'deal_id' => $deal->id, - 'event' => ActivityLog::EVENT_DEAL_CREATED, - 'context' => ['source' => 'webhook'], - 'created_at' => now(), - ]); - - app(PdAuditLogger::class)->record( - action: 'created', subjectType: 'lead', subjectId: $deal->id, - purpose: 'lead_create_webhook', tenantId: (int) $deal->tenant_id, - actorTenantUserId: null, actorAdminUserId: null, ip: null, - ); - - // Уведомление о новом лиде (ТЗ §18.5). Отправляется ПОСЛЕ всех записей - // в БД, чтобы при ошибке отправки транзакция уже была зафиксирована. - // NotificationService сам ловит Throwable от Mail::send и логирует — - // отказ канала не должен валить webhook. - $deal->setRelation('project', $project); - $service = app(NotificationService::class); - $service->notifyNewLead($tenant, $deal); - - // ТЗ §18.5: low_balance — после lead_charge проверяем порог. Триггерим - // ТОЛЬКО когда баланс пересекает порог сверху-вниз: balance_after <= - // threshold AND (balance_after + 1) > threshold. Иначе шлёт спам после - // каждого lead_charge при balance < threshold. - $threshold = $this->lowBalanceThreshold(); - $balanceAfter = (int) $tenant->balance_leads; - if ($balanceAfter <= $threshold && ($balanceAfter + 1) > $threshold) { - $service->notifyLowBalance($tenant, $threshold); - } - } - - /** - * Читает порог из system_settings.low_balance_threshold_leads. - * Default 10 (см. schema.sql:2239 seed). - */ - private function lowBalanceThreshold(): int - { - $setting = SystemSetting::query()->where('key', 'low_balance_threshold_leads')->first(); - if ($setting === null) { - return 10; - } - - return (int) $setting->value; - } - - /** - * Идемпотентная upsert-логика через advisory lock (§5.5 v8.7). - * - * Стратегия: - * 1. pg_advisory_xact_lock(tenant_id, vid) — сериализует все операции - * с (tenant_id, source_crm_id) на время транзакции. - * 2. SELECT в webhook_dedup_keys — атомарно из-за lock. - * 3a. Если найдено — UPDATE deal по composite-ключу (id, received_at). - * 3b. Иначе — INSERT deal первым (FK immediate OK), затем INSERT dedup_key. - * - * См. db/CHANGELOG_schema.md §W для архитектурного обоснования - * (PG savepoint+DEFERRED quirk, отказ от двустадийного INSERT-в-dedup-keys-первым). - */ - private function upsertDeal( - Tenant $tenant, - Project $project, - int $sourceCrmId, - Carbon $receivedAt, - ): Deal { - // pg_advisory_xact_lock(bigint): комбинируем (tenant_id, source_crm_id) - // в один bigint — верхние 32 бита tenant_id, нижние 32 — source_crm_id. - $lockKey = (($tenant->id & 0xFFFFFFFF) << 32) | ($sourceCrmId & 0xFFFFFFFF); - DB::statement('SELECT pg_advisory_xact_lock(?)', [$lockKey]); - - $existing = DB::selectOne( - 'SELECT deal_id, deal_received_at FROM webhook_dedup_keys WHERE tenant_id = ? AND source_crm_id = ?', - [$tenant->id, $sourceCrmId], - ); - - if ($existing !== null) { - $deal = Deal::query() - ->where('id', $existing->deal_id) - ->where('received_at', $existing->deal_received_at) - ->firstOrFail(); - - $deal->update([ - 'phone' => (string) $this->data['phone'], - 'phones' => $this->data['phones'] ?? [(string) $this->data['phone']], - // status НЕ перезаписываем — менеджер мог изменить. - ]); - - DB::table('webhook_dedup_keys') - ->where('tenant_id', $tenant->id) - ->where('source_crm_id', $sourceCrmId) - ->update(['updated_at' => now()]); - - $deal->wasRecentlyCreated = false; - - return $deal; - } - - $deal = Deal::create([ - 'tenant_id' => $tenant->id, - 'source_crm_id' => $sourceCrmId, - 'project_id' => $project->id, - 'phone' => (string) $this->data['phone'], - 'phones' => $this->data['phones'] ?? [(string) $this->data['phone']], - 'status' => 'new', - 'received_at' => $receivedAt, - ]); - - DB::table('webhook_dedup_keys')->insert([ - 'tenant_id' => $tenant->id, - 'source_crm_id' => $sourceCrmId, - 'deal_id' => $deal->id, - 'deal_received_at' => $deal->received_at, - 'created_at' => now(), - ]); - - return $deal; - } - - /** - * Финальный callback после исчерпания всех ретраев ($tries=3). - * - * Сохраняет упавший job в `failed_webhook_jobs` для ручного разбора и - * возможного повторного запуска через админку SaaS. RLS не задаём — - * tenant_id из job-state передаётся как есть (failed-callback запускается - * вне транзакции воркера). На production добавляется Sentry::captureException. - * - * NB: записывается через DB::table (не через FailedWebhookJob::create), - * чтобы избежать RLS-фильтрации при отсутствии app.current_tenant_id — - * запись должна попасть в БД даже в катастрофическом сценарии. - */ - public function failed(Throwable $e): void - { - // failed_webhook_jobs is an RLS-protected table. On production crm_app_user - // (non-BYPASSRLS) there is no app.current_tenant_id GUC in the failed() - // callback context. Use pgsql_supplier (crm_supplier_worker, BYPASSRLS) — - // same pattern as RouteSupplierLeadJob::failed(). - DB::connection('pgsql_supplier')->table('failed_webhook_jobs')->insert([ - 'tenant_id' => $this->tenantId, - 'webhook_log_id' => $this->webhookLogId, - 'raw_payload' => json_encode($this->data, JSON_UNESCAPED_UNICODE), - 'exception' => $e->getMessage(), - 'retry_count' => $this->tries, - 'failed_at' => now(), - ]); - - Log::error('webhook.job_failed_permanently', [ - 'tenant_id' => $this->tenantId, - 'vid' => $this->data['vid'] ?? null, - 'exception' => $e->getMessage(), - ]); - - // TODO(production): Sentry::captureException($e); - } -} diff --git a/app/app/Jobs/RouteSupplierLeadJob.php b/app/app/Jobs/RouteSupplierLeadJob.php index 2f7a275d..4b0b1f57 100644 --- a/app/app/Jobs/RouteSupplierLeadJob.php +++ b/app/app/Jobs/RouteSupplierLeadJob.php @@ -312,8 +312,8 @@ class RouteSupplierLeadJob implements ShouldQueue actorTenantUserId: null, actorAdminUserId: null, ip: null, ); - // ProcessWebhookJob-pattern: setRelation чтобы NotificationService - // мог подтянуть deal->project без N+1 lookup'а под RLS. + // setRelation чтобы NotificationService мог подтянуть + // deal->project без N+1 lookup'а под RLS. $deal->setRelation('project', $project); $notifier->notifyNewLead($tenant, $deal); diff --git a/app/app/Mail/LowBalanceNotification.php b/app/app/Mail/LowBalanceNotification.php deleted file mode 100644 index 8d35943d..00000000 --- a/app/app/Mail/LowBalanceNotification.php +++ /dev/null @@ -1,50 +0,0 @@ - $this->recipient, - 'tenant' => $this->tenant, - 'thresholdLeads' => $this->thresholdLeads, - ], - ); - } -} diff --git a/app/app/Mail/NewLeadNotification.php b/app/app/Mail/NewLeadNotification.php index a203d855..153a350d 100644 --- a/app/app/Mail/NewLeadNotification.php +++ b/app/app/Mail/NewLeadNotification.php @@ -18,7 +18,7 @@ use Illuminate\Queue\SerializesModels; * * Отправляется получателям тенанта, у которых в notification_preferences * включён канал email для события new_lead. Триггер — успешное создание - * сделки в ProcessWebhookJob::chargeNewLead. + * сделки в RouteSupplierLeadJob. */ class NewLeadNotification extends Mailable { diff --git a/app/app/Mail/ZeroBalanceNotification.php b/app/app/Mail/ZeroBalanceNotification.php deleted file mode 100644 index 33859bfc..00000000 --- a/app/app/Mail/ZeroBalanceNotification.php +++ /dev/null @@ -1,50 +0,0 @@ - $this->recipient, - 'tenant' => $this->tenant, - ], - ); - } -} diff --git a/app/app/Models/FailedWebhookJob.php b/app/app/Models/FailedWebhookJob.php index 7c4a530b..bbfab9d4 100644 --- a/app/app/Models/FailedWebhookJob.php +++ b/app/app/Models/FailedWebhookJob.php @@ -8,7 +8,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; /** - * Webhook-job упавший после 3 ретраев (см. ProcessWebhookJob::failed()). + * Webhook-job упавший после 3 ретраев (см. RouteSupplierLeadJob::failed()). * * Tenant-aware с RLS. Хранит raw payload + текст исключения для ручного * retry из админки SaaS (`retried_at`/`retried_by` заполняются админом). diff --git a/app/app/Models/RejectedDealsLog.php b/app/app/Models/RejectedDealsLog.php deleted file mode 100644 index d00d145e..00000000 --- a/app/app/Models/RejectedDealsLog.php +++ /dev/null @@ -1,56 +0,0 @@ - 'integer', - 'webhook_log_id' => 'integer', - 'payload' => 'array', - 'created_at' => 'datetime', - ]; - } - - /** @return BelongsTo */ - public function tenant(): BelongsTo - { - return $this->belongsTo(Tenant::class); - } -} diff --git a/app/app/Models/SupplierLeadCost.php b/app/app/Models/SupplierLeadCost.php index 23e09210..f6e489bf 100644 --- a/app/app/Models/SupplierLeadCost.php +++ b/app/app/Models/SupplierLeadCost.php @@ -11,8 +11,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; * Себестоимость каждого лида (Ю-2: реселлерская модель). * * Партиционирована по `received_at` синхронно с `deals` — composite PK - * (id, received_at). В `ProcessWebhookJob` создаётся в той же транзакции, - * что и Deal + BalanceTransaction. + * (id, received_at). Создаётся в `LedgerService::chargeForDelivery` в той же + * транзакции, что и Deal + BalanceTransaction. * * cost_rub — snapshot suppliers.cost_rub на момент приёма (исторические * записи не пересчитываются при изменении закупочной цены, см. §20.12.5). diff --git a/app/app/Models/Tenant.php b/app/app/Models/Tenant.php index 19ae860c..a0b4528b 100644 --- a/app/app/Models/Tenant.php +++ b/app/app/Models/Tenant.php @@ -31,8 +31,6 @@ class Tenant extends Model 'subdomain', 'organization_name', 'contact_email', - 'webhook_token', - 'webhook_token_rotated_at', 'timezone', 'locale', 'current_tariff_id', @@ -62,7 +60,6 @@ class Tenant extends Model 'api_key_limit' => 'integer', // JSONB: {"max_users":5,"max_projects":10,"api_rps":60} 'limits' => 'array', - 'webhook_token_rotated_at' => 'datetime', 'last_activity_at' => 'datetime', 'last_webhook_at' => 'datetime', // Billing v2 Spec C: флаг заморозки по балансу (NULL = не заморожен). diff --git a/app/app/Services/Import/CsvLeadsParser.php b/app/app/Services/Import/CsvLeadsParser.php index 6c297865..7bbdac8b 100644 --- a/app/app/Services/Import/CsvLeadsParser.php +++ b/app/app/Services/Import/CsvLeadsParser.php @@ -95,7 +95,7 @@ final class CsvLeadsParser return null; } - // Префикс B[123]_ из названия проекта срезается (паритет с ProcessWebhookJob). + // Префикс B[123]_ из названия проекта срезается (паритет с RouteSupplierLeadJob парсером). $projectName = (string) preg_replace('/^B[123]_/', '', trim($project)); if ($projectName === '') { $errors[] = ['line' => $dataLine, 'message' => 'Пустое название проекта']; diff --git a/app/app/Services/MonthlyPartitionManager.php b/app/app/Services/MonthlyPartitionManager.php index ae52f905..fbd49818 100644 --- a/app/app/Services/MonthlyPartitionManager.php +++ b/app/app/Services/MonthlyPartitionManager.php @@ -53,7 +53,7 @@ class MonthlyPartitionManager 'auth_log' => 'created_at', 'activity_log' => 'created_at', 'tenant_operations_log' => 'created_at', - 'webhook_log' => 'received_at', + // webhook_log удалён в миграции 2026_05_24_140000_drop_legacy_webhook_artefacts (legacy direct webhook removal) 'balance_transactions' => 'created_at', 'pd_processing_log' => 'created_at', 'saas_admin_audit_log' => 'created_at', diff --git a/app/app/Services/NotificationService.php b/app/app/Services/NotificationService.php index 48dbea7a..867c0632 100644 --- a/app/app/Services/NotificationService.php +++ b/app/app/Services/NotificationService.php @@ -5,11 +5,9 @@ declare(strict_types=1); namespace App\Services; use App\Mail\InvoicePaidNotification; -use App\Mail\LowBalanceNotification; use App\Mail\NewLeadNotification; use App\Mail\ReminderDueNotification; use App\Mail\TopupSuccessNotification; -use App\Mail\ZeroBalanceNotification; use App\Mail\ZeroBalancePausedMail; use App\Models\Deal; use App\Models\InAppNotification; @@ -147,52 +145,6 @@ class NotificationService } } - /** - * Уведомление о низком балансе. Триггер: ProcessWebhookJob после - * lead_charge, если balance_leads <= threshold. - * - * Получатели: все активные user'ы тенанта с new_lead.email=true - * (на MVP: те же что и для new_lead — обычно владелец и менеджеры). - * По prefs `low_balance.email`. - */ - public function notifyLowBalance(Tenant $tenant, int $thresholdLeads): void - { - $title = "Низкий баланс — {$tenant->balance_leads} лидов осталось"; - $body = "Порог уведомления: {$thresholdLeads} лидов"; - - foreach ($this->recipientsForEvent($tenant, self::EVENT_LOW_BALANCE, self::CHANNEL_EMAIL) as $user) { - $this->sendEmail($user, self::EVENT_LOW_BALANCE, new LowBalanceNotification($user, $tenant, $thresholdLeads)); - } - foreach ($this->recipientsForEvent($tenant, self::EVENT_LOW_BALANCE, self::CHANNEL_INAPP) as $user) { - $this->notifyInApp($user, self::EVENT_LOW_BALANCE, $title, $body, [ - 'tenant_id' => $tenant->id, - 'balance_leads' => $tenant->balance_leads, - 'threshold_leads' => $thresholdLeads, - ]); - } - } - - /** - * Уведомление о нулевом балансе и отклонении лидов. - * Триггер: ProcessWebhookJob::logRejection(zero_balance) в первом - * RejectedDealsLog за последний час (anti-spam: не более 1 email/час - * на тенант, проверка в caller). - */ - public function notifyZeroBalance(Tenant $tenant): void - { - $title = 'Баланс закончился — лиды отклоняются'; - $body = 'Пополните баланс в разделе Биллинг'; - - foreach ($this->recipientsForEvent($tenant, self::EVENT_ZERO_BALANCE, self::CHANNEL_EMAIL) as $user) { - $this->sendEmail($user, self::EVENT_ZERO_BALANCE, new ZeroBalanceNotification($user, $tenant)); - } - foreach ($this->recipientsForEvent($tenant, self::EVENT_ZERO_BALANCE, self::CHANNEL_INAPP) as $user) { - $this->notifyInApp($user, self::EVENT_ZERO_BALANCE, $title, $body, [ - 'tenant_id' => $tenant->id, - ]); - } - } - /** * Уведомление об auto-pause проекта на нулевом балансе (Plan 4 Task 6). * diff --git a/app/app/Services/Pd/PdErasureService.php b/app/app/Services/Pd/PdErasureService.php index 9852bb05..457e55ae 100644 --- a/app/app/Services/Pd/PdErasureService.php +++ b/app/app/Services/Pd/PdErasureService.php @@ -18,7 +18,7 @@ use InvalidArgumentException; * users: email, first_name, last_name, phone * supplier_leads: phone, raw_payload (JSONB) — нет contact_email/contact_phone * deals: phone, contact_name — нет отдельного contact_email - * webhook_log: raw_payload (JSONB) + * (webhook_log удалён в миграции 2026_05_24_140000_drop_legacy_webhook_artefacts) */ class PdErasureService { @@ -32,7 +32,7 @@ class PdErasureService * @param int|null $tenantId Ограничить поиск одним тенантом (null = все) * @param int $actorAdminId ID saas_admin_users * @param string|null $requestId ID pd_subject_requests для авто-закрытия - * @return array{users: int, leads: int, deals: int, webhook_log: int} + * @return array{users: int, leads: int, deals: int} * * @throws InvalidArgumentException если оба email и phone null */ @@ -47,7 +47,7 @@ class PdErasureService throw new InvalidArgumentException('Необходимо указать email или телефон субъекта.'); } - $counts = ['users' => 0, 'leads' => 0, 'deals' => 0, 'webhook_log' => 0]; + $counts = ['users' => 0, 'leads' => 0, 'deals' => 0]; DB::connection(self::DB)->transaction(function () use ( $email, $phone, $tenantId, $actorAdminId, $requestId, &$counts @@ -176,50 +176,12 @@ class PdErasureService $counts['deals'] = $deals->count(); // ------------------------------------------------------------------ - // 4. webhook_log (raw_payload JSONB text-search) - // ------------------------------------------------------------------ - $wlQuery = DB::connection(self::DB)->table('webhook_log'); - $conditions = []; - $bindings = []; - if ($email !== null) { - $conditions[] = 'raw_payload::text LIKE ?'; - $bindings[] = '%'.$email.'%'; - } - if ($phone !== null) { - $conditions[] = 'raw_payload::text LIKE ?'; - $bindings[] = '%'.$phone.'%'; - } - - if (! empty($conditions)) { - $wlQuery->whereRaw('('.implode(' OR ', $conditions).')', $bindings); - } - - if ($tenantId !== null) { - $wlQuery->where('tenant_id', $tenantId); - } - - // Batched update: обрабатываем по 500 строк - $wlCount = 0; - $wlQuery->select('id')->orderBy('id')->chunk(500, function ($rows) use (&$wlCount): void { - $ids = $rows->pluck('id')->all(); - DB::connection(self::DB)->table('webhook_log') - ->whereIn('id', $ids) - ->update([ - 'raw_payload' => DB::connection(self::DB)->raw( - "JSONB_BUILD_OBJECT('erased', TRUE, 'erased_at', NOW()::TEXT)" - ), - ]); - $wlCount += count($ids); - }); - - $counts['webhook_log'] = $wlCount; - - // ------------------------------------------------------------------ - // 5. Обновить pd_subject_requests если requestId передан + // 4. Обновить pd_subject_requests если requestId передан + // (webhook_log удалён в миграции 2026_05_24_140000_drop_legacy_webhook_artefacts) // ------------------------------------------------------------------ if ($requestId !== null) { $summary = "Удалено: users={$counts['users']}, leads={$counts['leads']}, " - ."deals={$counts['deals']}, webhook_log={$counts['webhook_log']}"; + ."deals={$counts['deals']}"; DB::connection(self::DB)->table('pd_subject_requests') ->where('id', $requestId) diff --git a/app/database/factories/TenantFactory.php b/app/database/factories/TenantFactory.php index 4991db46..7fe07e83 100644 --- a/app/database/factories/TenantFactory.php +++ b/app/database/factories/TenantFactory.php @@ -22,7 +22,6 @@ class TenantFactory extends Factory 'subdomain' => 'tenant-'.Str::lower(Str::random(8)), 'organization_name' => fake()->company(), 'contact_email' => fake()->unique()->safeEmail(), - 'webhook_token' => Str::random(64), 'timezone' => 'Europe/Moscow', 'locale' => 'ru', 'is_trial' => true, diff --git a/app/database/migrations/2026_05_22_000002_webhook_log_supplier_columns.php b/app/database/migrations/2026_05_22_000002_webhook_log_supplier_columns.php index bd50e7c6..0ac8f57c 100644 --- a/app/database/migrations/2026_05_22_000002_webhook_log_supplier_columns.php +++ b/app/database/migrations/2026_05_22_000002_webhook_log_supplier_columns.php @@ -9,8 +9,11 @@ return new class extends Migration { public function up(): void { - // Guard: only run if webhook_log exists (should always exist, but be safe) - if (! Schema::hasTable('webhook_log')) { + $conn = DB::connection('pgsql_supplier'); + + // Guard: only run if webhook_log exists (на проде после legacy-webhook-removal + // таблицы нет — миграция становится no-op). + if (! $conn->getSchemaBuilder()->hasTable('webhook_log')) { return; } @@ -18,16 +21,18 @@ return new class extends Migration base_path('/../db/migrations/2026_05_22_002_webhook_log_supplier_columns.sql') ); - DB::unprepared($sql); + $conn->unprepared($sql); } public function down(): void { - if (! Schema::hasTable('webhook_log')) { + $conn = DB::connection('pgsql_supplier'); + + if (! $conn->getSchemaBuilder()->hasTable('webhook_log')) { return; } - DB::unprepared(<<<'SQL' + $conn->unprepared(<<<'SQL' ALTER TABLE webhook_log DROP COLUMN IF EXISTS source, DROP COLUMN IF EXISTS status, diff --git a/app/database/migrations/2026_05_24_140000_drop_legacy_webhook_artefacts.php b/app/database/migrations/2026_05_24_140000_drop_legacy_webhook_artefacts.php new file mode 100644 index 00000000..e0b7eb0d --- /dev/null +++ b/app/database/migrations/2026_05_24_140000_drop_legacy_webhook_artefacts.php @@ -0,0 +1,55 @@ +statement('DROP TABLE IF EXISTS webhook_log CASCADE'); + + // NB: webhook_dedup_keys НЕ дропаем — Phase 1 RED FLAG, живой через HistoricalImportService (CSV-канал). + + // RejectedDealsLog — writer только удалённый ProcessWebhookJob, readers нет. + $conn->statement('DROP TABLE IF EXISTS rejected_deals_log CASCADE'); + + // tenants.webhook_token + webhook_token_rotated_at — нет в UI/API. + $conn->statement('ALTER TABLE tenants DROP COLUMN IF EXISTS webhook_token, DROP COLUMN IF EXISTS webhook_token_rotated_at'); + + // Legacy threshold-cross seed (caller — удалённый ProcessWebhookJob). + $conn->statement("DELETE FROM system_settings WHERE key = 'low_balance_threshold_leads'"); + } + + /** + * Откат — пустая заглушка. Прод-restore из pg_dump backup. + * Этот метод существует только чтобы migrate:rollback не падал. + */ + public function down(): void + { + // НЕ восстанавливаем структуру — пустая заглушка. + // Прод-restore — из pg_dump backup (см. runbook docs/deploy/test-server-runbook.md). + } +}; diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index c92a26ba..412e395d 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -1,17 +1,5 @@ parameters: ignoreErrors: - - - message: '#^Expression on left side of \?\? is not nullable\.$#' - identifier: nullCoalesce.expr - count: 1 - path: app/Console/Commands/RemindersDispatchDue.php - - - - message: '#^Using nullsafe method call on non\-nullable type Illuminate\\Support\\Carbon\. Use \-\> instead\.$#' - identifier: nullsafe.neverNull - count: 1 - path: app/Console/Commands/RemindersDispatchDue.php - - message: '#^Access to an undefined property App\\Models\\Tenant\:\:\$tariff_name\.$#' identifier: property.notFound @@ -84,102 +72,6 @@ parameters: count: 1 path: app/Http/Controllers/Api/DealExportController.php - - - message: '#^Cannot call method toIso8601String\(\) on null\.$#' - identifier: method.nonObject - count: 1 - path: app/Http/Controllers/Api/ImpersonationController.php - - - - message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$dry_run\.$#' - identifier: property.notFound - count: 1 - path: app/Http/Controllers/Api/ImportController.php - - - - message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$error_message\.$#' - identifier: property.notFound - count: 1 - path: app/Http/Controllers/Api/ImportController.php - - - - message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$filename\.$#' - identifier: property.notFound - count: 1 - path: app/Http/Controllers/Api/ImportController.php - - - - message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$finished_at\.$#' - identifier: property.notFound - count: 1 - path: app/Http/Controllers/Api/ImportController.php - - - - message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$rows_added\.$#' - identifier: property.notFound - count: 1 - path: app/Http/Controllers/Api/ImportController.php - - - - message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$rows_skipped\.$#' - identifier: property.notFound - count: 1 - path: app/Http/Controllers/Api/ImportController.php - - - - message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$rows_total\.$#' - identifier: property.notFound - count: 1 - path: app/Http/Controllers/Api/ImportController.php - - - - message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$rows_updated\.$#' - identifier: property.notFound - count: 1 - path: app/Http/Controllers/Api/ImportController.php - - - - message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$started_at\.$#' - identifier: property.notFound - count: 1 - path: app/Http/Controllers/Api/ImportController.php - - - - message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$status\.$#' - identifier: property.notFound - count: 1 - path: app/Http/Controllers/Api/ImportController.php - - - - message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$tenant_id\.$#' - identifier: property.notFound - count: 1 - path: app/Http/Controllers/Api/ImportController.php - - - - message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$unknown_statuses_count\.$#' - identifier: property.notFound - count: 1 - path: app/Http/Controllers/Api/ImportController.php - - - - message: '#^Access to an undefined property App\\Models\\ImportUnknownStatus\:\:\$occurrences\.$#' - identifier: property.notFound - count: 1 - path: app/Http/Controllers/Api/ImportController.php - - - - message: '#^Access to an undefined property App\\Models\\ImportUnknownStatus\:\:\$status_ru\.$#' - identifier: property.notFound - count: 1 - path: app/Http/Controllers/Api/ImportController.php - - - - message: '#^Parameter \#1 \$callback of method Illuminate\\Database\\Eloquent\\Collection\\:\:map\(\) contains unresolvable type\.$#' - identifier: argument.unresolvableType - count: 1 - path: app/Http/Controllers/Api/ImportController.php - - message: '#^Using nullsafe method call on non\-nullable type Illuminate\\Support\\Carbon\. Use \-\> instead\.$#' identifier: nullsafe.neverNull @@ -192,42 +84,12 @@ parameters: count: 1 path: app/Http/Middleware/SetTenantContext.php - - - message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$file_path\.$#' - identifier: property.notFound - count: 3 - path: app/Jobs/ImportLeadsJob.php - - - - message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$user_id\.$#' - identifier: property.notFound - count: 3 - path: app/Jobs/ImportLeadsJob.php - - message: '#^Using nullsafe property access "\?\-\>name" on left side of \?\? is unnecessary\. Use \-\> instead\.$#' identifier: nullsafe.neverNull count: 2 path: app/Mail/NewLeadNotification.php - - - message: '#^PHPDoc tag @mixin contains unknown class App\\Models\\IdeHelperImportLog\.$#' - identifier: class.notFound - count: 1 - path: app/Models/ImportLog.php - - - - message: '#^PHPDoc tag @mixin contains unknown class App\\Models\\IdeHelperImportUnknownStatus\.$#' - identifier: class.notFound - count: 1 - path: app/Models/ImportUnknownStatus.php - - - - message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$dry_run\.$#' - identifier: property.notFound - count: 1 - path: app/Services/Import/HistoricalImportService.php - - message: '#^Call to function is_array\(\) with array\ will always evaluate to true\.$#' identifier: function.alreadyNarrowedType @@ -246,102 +108,18 @@ parameters: count: 1 path: app/Services/Project/ProjectService.php + - + message: '#^Method App\\Services\\Project\\ProjectService\:\:bulkSimpleUpdate\(\) is unused\.$#' + identifier: method.unused + count: 1 + path: app/Services/Project/ProjectService.php + - message: '#^Call to function is_array\(\) with array\ will always evaluate to true\.$#' identifier: function.alreadyNarrowedType count: 1 path: app/Services/Supplier/Channel/AjaxProjectChannel.php - - - message: '#^Parameter \#1 \$array \(array\{string\}\) of array_values is already a list, call has no effect\.$#' - identifier: arrayValues.list - count: 1 - path: app/Services/Supplier/SupplierProjectGrouping.php - - - - message: '#^Class NunoMaduro\\PhpInsights\\Domain\\Insights\\ForbiddenDefineFunctions not found\.$#' - identifier: class.notFound - count: 1 - path: config/insights.php - - - - message: '#^Class NunoMaduro\\PhpInsights\\Domain\\Insights\\ForbiddenFinalClasses not found\.$#' - identifier: class.notFound - count: 1 - path: config/insights.php - - - - message: '#^Class NunoMaduro\\PhpInsights\\Domain\\Insights\\ForbiddenNormalClasses not found\.$#' - identifier: class.notFound - count: 1 - path: config/insights.php - - - - message: '#^Class NunoMaduro\\PhpInsights\\Domain\\Insights\\ForbiddenPrivateMethods not found\.$#' - identifier: class.notFound - count: 1 - path: config/insights.php - - - - message: '#^Class NunoMaduro\\PhpInsights\\Domain\\Insights\\ForbiddenTraits not found\.$#' - identifier: class.notFound - count: 1 - path: config/insights.php - - - - message: '#^Class NunoMaduro\\PhpInsights\\Domain\\Insights\\SyntaxCheck not found\.$#' - identifier: class.notFound - count: 1 - path: config/insights.php - - - - message: '#^Class NunoMaduro\\PhpInsights\\Domain\\Metrics\\Architecture\\Classes not found\.$#' - identifier: class.notFound - count: 1 - path: config/insights.php - - - - message: '#^Class SlevomatCodingStandard\\Sniffs\\Commenting\\UselessFunctionDocCommentSniff not found\.$#' - identifier: class.notFound - count: 1 - path: config/insights.php - - - - message: '#^Class SlevomatCodingStandard\\Sniffs\\Namespaces\\AlphabeticallySortedUsesSniff not found\.$#' - identifier: class.notFound - count: 1 - path: config/insights.php - - - - message: '#^Class SlevomatCodingStandard\\Sniffs\\TypeHints\\DeclareStrictTypesSniff not found\.$#' - identifier: class.notFound - count: 1 - path: config/insights.php - - - - message: '#^Class SlevomatCodingStandard\\Sniffs\\TypeHints\\DisallowMixedTypeHintSniff not found\.$#' - identifier: class.notFound - count: 1 - path: config/insights.php - - - - message: '#^Class SlevomatCodingStandard\\Sniffs\\TypeHints\\ParameterTypeHintSniff not found\.$#' - identifier: class.notFound - count: 1 - path: config/insights.php - - - - message: '#^Class SlevomatCodingStandard\\Sniffs\\TypeHints\\PropertyTypeHintSniff not found\.$#' - identifier: class.notFound - count: 1 - path: config/insights.php - - - - message: '#^Class SlevomatCodingStandard\\Sniffs\\TypeHints\\ReturnTypeHintSniff not found\.$#' - identifier: class.notFound - count: 1 - path: config/insights.php - - message: '#^Return type \(array\\) of method Database\\Factories\\BalanceTransactionFactory\:\:definition\(\) should be compatible with return type \(array\\) of method Illuminate\\Database\\Eloquent\\Factories\\Factory\\:\:definition\(\)$#' identifier: method.childReturnType @@ -378,12 +156,36 @@ parameters: count: 1 path: database/factories/UserFactory.php + - + message: '#^Offset ''projects\:reset…'' on null in isset\(\) does not exist\.$#' + identifier: isset.offset + count: 1 + path: routes/console.php + - message: '#^Trait Tests\\Concerns\\SharesSupplierPdo is used zero times and is not analysed\.$#' identifier: trait.unused count: 1 path: tests/Concerns/SharesSupplierPdo.php + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#' + identifier: method.notFound + count: 10 + path: tests/Feature/Admin/AdminPdSubjectRequestsControllerTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#' + identifier: method.notFound + count: 3 + path: tests/Feature/Admin/AdminPdSubjectRequestsControllerTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#' + identifier: method.notFound + count: 8 + path: tests/Feature/Admin/AdminPdSubjectRequestsControllerTest.php + - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:deleteJson\(\)\.$#' identifier: method.notFound @@ -399,7 +201,7 @@ parameters: - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#' identifier: method.notFound - count: 9 + count: 11 path: tests/Feature/Admin/AdminPricingTiersControllerTest.php - @@ -432,6 +234,12 @@ parameters: count: 3 path: tests/Feature/Admin/AdminSuppliersControllerTest.php + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:patchJson\(\)\.$#' + identifier: method.notFound + count: 8 + path: tests/Feature/Admin/AdminTenantBalanceUpdateTest.php + - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#' identifier: method.notFound @@ -450,6 +258,18 @@ parameters: count: 2 path: tests/Feature/Admin/SupplierExportModeEndpointTest.php + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#' + identifier: method.notFound + count: 3 + path: tests/Feature/Admin/SupplierIntegrationAuditTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#' + identifier: method.notFound + count: 3 + path: tests/Feature/Admin/SupplierIntegrationAuditTest.php + - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#' identifier: method.notFound @@ -624,6 +444,24 @@ parameters: count: 3 path: tests/Feature/ApiKeyControllerTest.php + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#' + identifier: method.notFound + count: 5 + path: tests/Feature/Audit/OperationalFullFlowTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Feature/Audit/OperationalFullFlowTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Feature/Audit/OperationalFullFlowTest.php + - message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#' identifier: property.notFound @@ -642,6 +480,24 @@ parameters: count: 14 path: tests/Feature/Auth/AuthControllerTest.php + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#' + identifier: method.notFound + count: 13 + path: tests/Feature/Auth/AuthFlowIntegrationTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#' + identifier: method.notFound + count: 5 + path: tests/Feature/Auth/AuthLogCoverageTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#' + identifier: method.notFound + count: 21 + path: tests/Feature/Auth/AuthLogCoverageTest.php + - message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#' identifier: property.notFound @@ -867,7 +723,7 @@ parameters: - message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#' identifier: property.notFound - count: 12 + count: 20 path: tests/Feature/Billing/BillingOverviewControllerTest.php - @@ -885,7 +741,13 @@ parameters: - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#' identifier: method.notFound - count: 18 + count: 25 + path: tests/Feature/Billing/BillingOverviewControllerTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:seed\(\)\.$#' + identifier: method.notFound + count: 1 path: tests/Feature/Billing/BillingOverviewControllerTest.php - @@ -909,7 +771,7 @@ parameters: - message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#' identifier: property.notFound - count: 12 + count: 17 path: tests/Feature/Billing/TenantChargesControllerTest.php - @@ -931,11 +793,17 @@ parameters: path: tests/Feature/Billing/TenantChargesControllerTest.php - - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#' + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:post\(\)\.$#' identifier: method.notFound count: 1 path: tests/Feature/Billing/TenantChargesControllerTest.php + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#' + identifier: method.notFound + count: 2 + path: tests/Feature/Billing/TenantChargesControllerTest.php + - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:seed\(\)\.$#' identifier: method.notFound @@ -972,12 +840,30 @@ parameters: count: 8 path: tests/Feature/Billing/TopupControllerTest.php + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#' + identifier: method.notFound + count: 5 + path: tests/Feature/Console/BillingMigrateLeadsToRubTest.php + - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#' identifier: method.notFound count: 4 path: tests/Feature/Console/CheckSupplierWebhookSecretCommandTest.php + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#' + identifier: method.notFound + count: 5 + path: tests/Feature/Console/IncidentsWatchFailuresTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#' + identifier: method.notFound + count: 6 + path: tests/Feature/Console/PartitionsDropExpiredTest.php + - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#' identifier: method.notFound @@ -990,10 +876,22 @@ parameters: count: 1 path: tests/Feature/Console/ResetMonthlyCountersCommandTest.php + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#' + identifier: method.notFound + count: 10 + path: tests/Feature/Console/VerifyAuditChainsTest.php + - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#' identifier: method.notFound - count: 9 + count: 8 + path: tests/Feature/DashboardSummaryTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\|Pest\\Support\\HigherOrderTapProxy\:\:actingAs\(\)\.$#' + identifier: method.notFound + count: 1 path: tests/Feature/DashboardSummaryTest.php - @@ -1314,12 +1212,90 @@ parameters: count: 9 path: tests/Feature/DealUpdateTest.php + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$project\.$#' + identifier: property.notFound + count: 4 + path: tests/Feature/Deals/ActivityLogAttributionTest.php + + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#' + identifier: property.notFound + count: 9 + path: tests/Feature/Deals/ActivityLogAttributionTest.php + + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#' + identifier: property.notFound + count: 5 + path: tests/Feature/Deals/ActivityLogAttributionTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Feature/Deals/ActivityLogAttributionTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:withServerVariables\(\)\.$#' + identifier: method.notFound + count: 4 + path: tests/Feature/Deals/ActivityLogAttributionTest.php + + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$project\.$#' + identifier: property.notFound + count: 4 + path: tests/Feature/Deals/BulkActionActivityLogAttributionTest.php + + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#' + identifier: property.notFound + count: 4 + path: tests/Feature/Deals/BulkActionActivityLogAttributionTest.php + + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#' + identifier: property.notFound + count: 4 + path: tests/Feature/Deals/BulkActionActivityLogAttributionTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Feature/Deals/BulkActionActivityLogAttributionTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:deleteJson\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Feature/Deals/BulkActionActivityLogAttributionTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:withServerVariables\(\)\.$#' + identifier: method.notFound + count: 3 + path: tests/Feature/Deals/BulkActionActivityLogAttributionTest.php + - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:seed\(\)\.$#' identifier: method.notFound count: 2 path: tests/Feature/DemoSeederTest.php + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#' + identifier: method.notFound + count: 2 + path: tests/Feature/EndpointAuthHardeningTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#' + identifier: method.notFound + count: 5 + path: tests/Feature/EndpointAuthHardeningTest.php + - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#' identifier: method.notFound @@ -1356,18 +1332,6 @@ parameters: count: 17 path: tests/Feature/ImpersonationTest.php - - - message: '#^Access to an undefined property App\\Models\\ImportUnknownStatus\:\:\$mapped_to_slug\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/Import/HistoricalImportServiceTest.php - - - - message: '#^Access to an undefined property App\\Models\\ImportUnknownStatus\:\:\$occurrences\.$#' - identifier: property.notFound - count: 3 - path: tests/Feature/Import/HistoricalImportServiceTest.php - - message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$service\.$#' identifier: property.notFound @@ -1398,18 +1362,6 @@ parameters: count: 3 path: tests/Feature/Import/ImportCompletedNotificationTest.php - - - message: '#^Access to an undefined property App\\Models\\ImportUnknownStatus\:\:\$mapped_to_slug\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/Import/ImportControllerTest.php - - - - message: '#^Access to an undefined property App\\Models\\ImportUnknownStatus\:\:\$resolved_at\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/Import/ImportControllerTest.php - - message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#' identifier: property.notFound @@ -1440,42 +1392,6 @@ parameters: count: 5 path: tests/Feature/Import/ImportControllerTest.php - - - message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$error_message\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/Import/ImportLeadsJobTest.php - - - - message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$finished_at\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/Import/ImportLeadsJobTest.php - - - - message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$rows_added\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/Import/ImportLeadsJobTest.php - - - - message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$rows_skipped\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/Import/ImportLeadsJobTest.php - - - - message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$status\.$#' - identifier: property.notFound - count: 3 - path: tests/Feature/Import/ImportLeadsJobTest.php - - - - message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$unknown_statuses_count\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/Import/ImportLeadsJobTest.php - - message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#' identifier: property.notFound @@ -1488,36 +1404,6 @@ parameters: count: 4 path: tests/Feature/Import/ImportLeadsJobTest.php - - - message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$dry_run\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/Import/ImportModelsTest.php - - - - message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$entity_type\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/Import/ImportModelsTest.php - - - - message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$mapping_config\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/Import/ImportModelsTest.php - - - - message: '#^Access to an undefined property App\\Models\\ImportLog\:\:\$status\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/Import/ImportModelsTest.php - - - - message: '#^Access to an undefined property App\\Models\\ImportUnknownStatus\:\:\$status_ru\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/Import/ImportModelsTest.php - - message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#' identifier: property.notFound @@ -1530,6 +1416,12 @@ parameters: count: 2 path: tests/Feature/Import/ImportModelsTest.php + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#' + identifier: method.notFound + count: 8 + path: tests/Feature/Incidents/IncidentsWatchFailuresExpandedTest.php + - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#' identifier: method.notFound @@ -1551,25 +1443,31 @@ parameters: - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#' identifier: method.notFound - count: 5 + count: 6 + path: tests/Feature/LeadStatusesIndexTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\|Pest\\Support\\HigherOrderTapProxy\:\:actingAs\(\)\.$#' + identifier: method.notFound + count: 1 path: tests/Feature/LeadStatusesIndexTest.php - message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#' identifier: property.notFound - count: 16 + count: 14 path: tests/Feature/LookupsTest.php - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#' identifier: method.notFound - count: 3 + count: 5 path: tests/Feature/LookupsTest.php - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#' identifier: method.notFound - count: 4 + count: 3 path: tests/Feature/LookupsTest.php - @@ -1626,6 +1524,186 @@ parameters: count: 2 path: tests/Feature/PartitionsCreateMonthsTest.php + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Feature/Pd/DealCreatePdLogTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Feature/Pd/DealCreatePdLogTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:seed\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Feature/Pd/DealCreatePdLogTest.php + + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$project\.$#' + identifier: property.notFound + count: 1 + path: tests/Feature/Pd/DealExportPdLogTest.php + + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#' + identifier: property.notFound + count: 1 + path: tests/Feature/Pd/DealExportPdLogTest.php + + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#' + identifier: property.notFound + count: 3 + path: tests/Feature/Pd/DealExportPdLogTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Feature/Pd/DealExportPdLogTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:post\(\)\.$#' + identifier: method.notFound + count: 2 + path: tests/Feature/Pd/DealExportPdLogTest.php + + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$deal\.$#' + identifier: property.notFound + count: 3 + path: tests/Feature/Pd/DealViewAccessLogTest.php + + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$project\.$#' + identifier: property.notFound + count: 1 + path: tests/Feature/Pd/DealViewAccessLogTest.php + + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#' + identifier: property.notFound + count: 1 + path: tests/Feature/Pd/DealViewAccessLogTest.php + + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#' + identifier: property.notFound + count: 2 + path: tests/Feature/Pd/DealViewAccessLogTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Feature/Pd/DealViewAccessLogTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#' + identifier: method.notFound + count: 2 + path: tests/Feature/Pd/DealViewAccessLogTest.php + + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$adminId\.$#' + identifier: property.notFound + count: 6 + path: tests/Feature/Pd/ImpersonationAuditTest.php + + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#' + identifier: property.notFound + count: 5 + path: tests/Feature/Pd/ImpersonationAuditTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#' + identifier: method.notFound + count: 3 + path: tests/Feature/Pd/ImpersonationAuditTest.php + + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$project\.$#' + identifier: property.notFound + count: 2 + path: tests/Feature/Pd/PdFullFlowIntegrationTest.php + + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#' + identifier: property.notFound + count: 4 + path: tests/Feature/Pd/PdFullFlowIntegrationTest.php + + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#' + identifier: property.notFound + count: 3 + path: tests/Feature/Pd/PdFullFlowIntegrationTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Feature/Pd/PdFullFlowIntegrationTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:deleteJson\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Feature/Pd/PdFullFlowIntegrationTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Feature/Pd/PdFullFlowIntegrationTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:post\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Feature/Pd/PdFullFlowIntegrationTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Feature/Pd/PdFullFlowIntegrationTest.php + + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#' + identifier: property.notFound + count: 11 + path: tests/Feature/Pd/ReportFileDeletePdLogTest.php + + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#' + identifier: property.notFound + count: 5 + path: tests/Feature/Pd/ReportFileDeletePdLogTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Feature/Pd/ReportFileDeletePdLogTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Feature/Pd/ReportFileDeletePdLogTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:deleteJson\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Feature/Pd/ReportFileDeletePdLogTest.php + - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:mock\(\)\.$#' identifier: method.notFound @@ -1635,13 +1713,13 @@ parameters: - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#' identifier: method.notFound - count: 9 + count: 11 path: tests/Feature/Plan5/Projects/ProjectsActionsTest.php - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#' identifier: method.notFound - count: 12 + count: 21 path: tests/Feature/Plan5/Projects/ProjectsListShowTest.php - @@ -1656,12 +1734,48 @@ parameters: count: 14 path: tests/Feature/Plan5/Projects/ProjectsUpdateTest.php + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#' + identifier: property.notFound + count: 6 + path: tests/Feature/Project/ProjectCreateDedupTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:fail\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Feature/Project/ProjectCreateDedupTest.php + + - + message: '#^Call to an undefined method Symfony\\Component\\HttpFoundation\\Response\:\:getData\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Feature/Project/ProjectCreateDedupTest.php + - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#' identifier: method.notFound count: 1 path: tests/Feature/Project/QueryExceptionRenderTest.php + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#' + identifier: property.notFound + count: 9 + path: tests/Feature/Projects/ProjectMutationsAuditTest.php + + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#' + identifier: property.notFound + count: 5 + path: tests/Feature/Projects/ProjectMutationsAuditTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#' + identifier: method.notFound + count: 4 + path: tests/Feature/Projects/ProjectMutationsAuditTest.php + - message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#' identifier: property.notFound @@ -1872,6 +1986,36 @@ parameters: count: 2 path: tests/Feature/SaasAdminMiddlewareTest.php + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#' + identifier: method.notFound + count: 5 + path: tests/Feature/Scheduler/SchedulerHeartbeatTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Feature/Security/ApiKeyRegenerateAuditTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Feature/Security/ApiKeyRegenerateAuditTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#' + identifier: method.notFound + count: 3 + path: tests/Feature/Security/WebhookUrlChangeAuditTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:putJson\(\)\.$#' + identifier: method.notFound + count: 3 + path: tests/Feature/Security/WebhookUrlChangeAuditTest.php + - message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$project\.$#' identifier: property.notFound @@ -1920,6 +2064,12 @@ parameters: count: 1 path: tests/Feature/Supplier/CsvReconcileJobTest.php + - + message: '#^Call to an undefined method Mockery\\ExpectationInterface\|Mockery\\HigherOrderMessage\:\:once\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Feature/Supplier/DeleteSupplierProjectJobTest.php + - message: '#^Call to an undefined method Mockery\\ExpectationInterface\|Mockery\\HigherOrderMessage\:\:andThrow\(\)\.$#' identifier: method.notFound @@ -1939,10 +2089,22 @@ parameters: path: tests/Feature/Supplier/FailoverProjectChannelLiveSmokeTest.php - - message: '#^Call to an undefined method Mockery\\ExpectationInterface\|Mockery\\HigherOrderMessage\:\:once\(\)\.$#' - identifier: method.notFound + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$app\.$#' + identifier: property.notFound count: 1 - path: tests/Feature/Supplier/DeleteSupplierProjectJobTest.php + path: tests/Feature/Supplier/ImportSupplierProjectsCommandTest.php + + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#' + identifier: property.notFound + count: 3 + path: tests/Feature/Supplier/ImportSupplierProjectsCommandTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#' + identifier: method.notFound + count: 3 + path: tests/Feature/Supplier/ImportSupplierProjectsCommandTest.php - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#' @@ -1974,6 +2136,18 @@ parameters: count: 1 path: tests/Feature/Supplier/RouteSupplierLeadJobBillingTest.php + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:seed\(\)\.$#' + identifier: method.notFound + count: 4 + path: tests/Feature/Supplier/SupplierLeadDeliveryGuardTest.php + + - + message: '#^Parameter \#1 \$client of class App\\Services\\Supplier\\Import\\SupplierProjectImporter constructor expects App\\Services\\Supplier\\SupplierPortalClient, Mockery\\MockInterface given\.$#' + identifier: argument.type + count: 1 + path: tests/Feature/Supplier/SupplierProjectImporterTest.php + - message: '#^Call to an undefined method App\\Services\\Supplier\\PlaywrightBridge\:\:shouldReceive\(\)\.$#' identifier: method.notFound @@ -1983,27 +2157,9 @@ parameters: - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:mock\(\)\.$#' identifier: method.notFound - count: 2 + count: 6 path: tests/Feature/Supplier/SyncSupplierProjectJobTest.php - - - message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#' - identifier: property.notFound - count: 20 - path: tests/Feature/WebhookReceiveTest.php - - - - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:call\(\)\.$#' - identifier: method.notFound - count: 3 - path: tests/Feature/WebhookReceiveTest.php - - - - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#' - identifier: method.notFound - count: 14 - path: tests/Feature/WebhookReceiveTest.php - - message: '#^Access to an undefined property Pest\\Mixins\\Expectation\\:\:\$not\.$#' identifier: property.notFound @@ -2013,7 +2169,7 @@ parameters: - message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#' identifier: property.notFound - count: 7 + count: 8 path: tests/Feature/WebhookSettingsControllerTest.php - @@ -2043,9 +2199,33 @@ parameters: - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:putJson\(\)\.$#' identifier: method.notFound - count: 3 + count: 4 path: tests/Feature/WebhookSettingsControllerTest.php + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#' + identifier: property.notFound + count: 3 + path: tests/Feature/WebhookSsrfGuardTest.php + + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#' + identifier: property.notFound + count: 3 + path: tests/Feature/WebhookSsrfGuardTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Feature/WebhookSsrfGuardTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#' + identifier: method.notFound + count: 2 + path: tests/Feature/WebhookSsrfGuardTest.php + - message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$resolver\.$#' identifier: property.notFound @@ -2064,6 +2244,36 @@ parameters: count: 1 path: tests/Unit/ExampleTest.php + - + message: '#^Call to function property_exists\(\) with App\\Exceptions\\Billing\\InsufficientBalanceException and ''balanceLeads'' will always evaluate to false\.$#' + identifier: function.impossibleType + count: 1 + path: tests/Unit/Exceptions/InsufficientBalanceExceptionTest.php + + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tiers\.$#' + identifier: property.notFound + count: 8 + path: tests/Unit/Services/Billing/BalanceToLeadsConverterTest.php + + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$adminId\.$#' + identifier: property.notFound + count: 5 + path: tests/Unit/Services/Pd/ImpersonationAuditServiceTest.php + + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#' + identifier: property.notFound + count: 2 + path: tests/Unit/Services/Pd/ImpersonationAuditServiceTest.php + + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$token\.$#' + identifier: property.notFound + count: 6 + path: tests/Unit/Services/Pd/ImpersonationAuditServiceTest.php + - message: '#^Call to an undefined method App\\Services\\Supplier\\ProcessFactory\:\:shouldReceive\(\)\.$#' identifier: method.notFound @@ -2111,21 +2321,3 @@ parameters: identifier: argument.type count: 1 path: tests/Unit/Supplier/SupplierQuotaAllocatorTest.php - - - - message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#' - identifier: property.notFound - count: 6 - path: tests/Feature/Project/ProjectCreateDedupTest.php - - - - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:fail\(\)\.$#' - identifier: method.notFound - count: 1 - path: tests/Feature/Project/ProjectCreateDedupTest.php - - - - message: '#^Call to an undefined method Symfony\\Component\\HttpFoundation\\Response\:\:getData\(\)\.$#' - identifier: method.notFound - count: 1 - path: tests/Feature/Project/ProjectCreateDedupTest.php diff --git a/app/resources/views/emails/low_balance.blade.php b/app/resources/views/emails/low_balance.blade.php deleted file mode 100644 index b544108d..00000000 --- a/app/resources/views/emails/low_balance.blade.php +++ /dev/null @@ -1,26 +0,0 @@ - - - - - Низкий баланс - - -

Лидерра. Низкий баланс

- -

Здравствуйте, {{ $recipient->first_name ?? $recipient->email }}.

- -

На балансе аккаунта {{ $tenant->organization_name ?? $tenant->subdomain }} - осталось {{ $tenant->balance_leads }} лидов - (порог уведомления — {{ $thresholdLeads }} лидов).

- -

- Если баланс закончится — все входящие лиды будут отклоняться. -

- -

Откройте Биллинг в CRM, чтобы пополнить баланс.

- -

- Это автоматическое уведомление о событии «Низкий баланс». Чтобы изменить настройки уведомлений — перейдите в Настройки → Уведомления. -

- - diff --git a/app/resources/views/emails/zero_balance.blade.php b/app/resources/views/emails/zero_balance.blade.php deleted file mode 100644 index a3137d1f..00000000 --- a/app/resources/views/emails/zero_balance.blade.php +++ /dev/null @@ -1,27 +0,0 @@ - - - - - Баланс закончился - - -

Лидерра. Баланс закончился

- -

Здравствуйте, {{ $recipient->first_name ?? $recipient->email }}.

- -

Баланс аккаунта {{ $tenant->organization_name ?? $tenant->subdomain }} - обнулился — входящие лиды отклоняются.

- -

- Что делать: пополните баланс в разделе Биллинг. - После пополнения новые лиды снова начнут приниматься. -

- -

Уже отклонённые лиды не возвращаются — посмотрите их в журнале - «Отклонённые лиды» в разделе Отчёты.

- -

- Это автоматическое уведомление о событии «Нулевой баланс». Чтобы изменить настройки уведомлений — перейдите в Настройки → Уведомления. -

- - diff --git a/app/routes/web.php b/app/routes/web.php index 2c811d46..23219c68 100644 --- a/app/routes/web.php +++ b/app/routes/web.php @@ -270,12 +270,6 @@ Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/projects')->group(fu Route::patch('/{id}/toggle-active', 'App\Http\Controllers\Api\ProjectController@toggleActive')->name('projects.toggle')->where('id', '[0-9]+'); }); -// Receive endpoint для входящих webhook'ов (narrative §5.5). -// Auth — по `tenants.webhook_token` в URL (без middleware, проверка внутри controller). -// На prod: + HMAC-валидация X-Webhook-Signature + per-token rate-limit. -Route::post('/api/webhook/{token}', 'App\Http\Controllers\Api\WebhookReceiveController@receive') - ->where('token', '[A-Za-z0-9\-_]+'); - // Supplier-integration webhook (Plan 2/5, spec §5.1). // Platform-wide endpoint: единый {secret} в URL для всех лидов от crm.bp-gr.ru. // Auth: secret (system_settings.supplier_webhook_secret) + IP allowlist diff --git a/app/storage/_demo_split_tenants.php b/app/storage/_demo_split_tenants.php index f1ffd784..9dcdf53f 100644 --- a/app/storage/_demo_split_tenants.php +++ b/app/storage/_demo_split_tenants.php @@ -12,7 +12,6 @@ declare(strict_types=1); use App\Models\Project; use App\Models\Tenant; use App\Models\User; -use Illuminate\Support\Str; // ------------------------------------------------------------------- // 1. Проверяем исходное состояние @@ -77,7 +76,6 @@ foreach ($accounts as $a) { [ 'organization_name' => $a['org_name'], 'contact_email' => $user->email, - 'webhook_token' => Str::random(64), 'timezone' => 'Europe/Moscow', 'locale' => 'ru', 'is_trial' => true, diff --git a/app/tests/Feature/Admin/AdminPdSubjectRequestsControllerTest.php b/app/tests/Feature/Admin/AdminPdSubjectRequestsControllerTest.php index 1591fdbf..2cac2fbe 100644 --- a/app/tests/Feature/Admin/AdminPdSubjectRequestsControllerTest.php +++ b/app/tests/Feature/Admin/AdminPdSubjectRequestsControllerTest.php @@ -164,7 +164,6 @@ it('executeErasure anonymises user email first_name phone and writes pd_processi 'organization_name' => 'PD User Test', 'contact_email' => 'pd-u@test.local', 'status' => 'active', - 'webhook_token' => bin2hex(random_bytes(16)), 'balance_rub' => '0.00', 'balance_leads' => 0, 'is_trial' => false, diff --git a/app/tests/Feature/AdminBillingActionsTest.php b/app/tests/Feature/AdminBillingActionsTest.php index 63b2c149..446533ae 100644 --- a/app/tests/Feature/AdminBillingActionsTest.php +++ b/app/tests/Feature/AdminBillingActionsTest.php @@ -14,7 +14,6 @@ function makeBillingTenant(array $overrides = []): int 'subdomain' => 'bt-'.bin2hex(random_bytes(4)), 'organization_name' => 'Billing Test Co', 'contact_email' => 'bt-'.bin2hex(random_bytes(3)).'@test.local', - 'webhook_token' => bin2hex(random_bytes(16)), 'status' => 'active', 'balance_rub' => '5000.00', 'is_trial' => false, diff --git a/app/tests/Feature/AdminIncidentShowTest.php b/app/tests/Feature/AdminIncidentShowTest.php index 3ce15349..75c47e96 100644 --- a/app/tests/Feature/AdminIncidentShowTest.php +++ b/app/tests/Feature/AdminIncidentShowTest.php @@ -64,7 +64,6 @@ test('GET /api/admin/incidents/{id} разрешает имена affected_tenan 'subdomain' => 'inc-'.bin2hex(random_bytes(4)), 'organization_name' => 'Affected Org', 'contact_email' => 'a@test.local', - 'webhook_token' => bin2hex(random_bytes(16)), 'created_at' => now(), ]); $id = makeShowIncident($this->adminId, ['affected_tenant_ids' => '{'.$tenantId.'}']); diff --git a/app/tests/Feature/Console/VerifyAuditChainsTest.php b/app/tests/Feature/Console/VerifyAuditChainsTest.php index 834d9efd..460611eb 100644 --- a/app/tests/Feature/Console/VerifyAuditChainsTest.php +++ b/app/tests/Feature/Console/VerifyAuditChainsTest.php @@ -69,7 +69,6 @@ function ensureTenant(int $seed): int 'organization_name' => "Test Chain {$seed}", 'subdomain' => "test-chain-{$seed}", 'contact_email' => "chain{$seed}@example.com", - 'webhook_token' => bin2hex(random_bytes(16))."-seed{$seed}", 'status' => 'active', 'created_at' => now(), 'updated_at' => now(), diff --git a/app/tests/Feature/Http/Webhook/SupplierWebhookLoggingTest.php b/app/tests/Feature/Http/Webhook/SupplierWebhookLoggingTest.php deleted file mode 100644 index 34f39fae..00000000 --- a/app/tests/Feature/Http/Webhook/SupplierWebhookLoggingTest.php +++ /dev/null @@ -1,109 +0,0 @@ -where('key', 'supplier_webhook_secret')->update(['value' => 'test-secret-32chars-aaaaaaaaaaaaaa']); - SystemSetting::query()->where('key', 'supplier_ip_allowlist')->update(['value' => '[]']); - // Clear rate limiter between tests - RateLimiter::clear('supplier-webhook:127.0.0.1'); -}); - -it('logs status=received when lead is accepted (202)', function () { - Bus::fake(); - - $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [ - 'vid' => 900001, - 'project' => 'B1_log-test.ru', - 'phone' => '79991234567', - 'time' => time(), - ])->assertStatus(202); - - $log = DB::table('webhook_log') - ->where('status', 'received') - ->where('source', 'supplier') - ->latest('created_at') - ->first(); - - expect($log)->not->toBeNull(); - expect($log->status)->toBe('received'); - expect($log->source)->toBe('supplier'); - expect($log->tenant_id)->toBeNull(); -}); - -it('logs status=rejected_secret when secret is wrong (404)', function () { - $this->postJson('/api/webhook/supplier/wrong-secret-here', [ - 'vid' => 900002, - 'project' => 'B1_log-test.ru', - 'phone' => '79991234567', - 'time' => time(), - ])->assertStatus(404); - - $log = DB::table('webhook_log') - ->where('status', 'rejected_secret') - ->where('source', 'supplier') - ->latest('created_at') - ->first(); - - expect($log)->not->toBeNull(); - expect($log->status)->toBe('rejected_secret'); - expect($log->tenant_id)->toBeNull(); -}); - -it('logs status=rejected_ip when IP is not in allowlist (404)', function () { - SystemSetting::query()->where('key', 'supplier_ip_allowlist') - ->update(['value' => '["1.2.3.4"]']); - - $this->withServerVariables(['REMOTE_ADDR' => '5.6.7.8']) - ->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [ - 'vid' => 900003, - 'project' => 'B1_log-test.ru', - 'phone' => '79991234567', - 'time' => time(), - ])->assertStatus(404); - - $log = DB::table('webhook_log') - ->where('status', 'rejected_ip') - ->where('source', 'supplier') - ->latest('created_at') - ->first(); - - expect($log)->not->toBeNull(); - expect($log->status)->toBe('rejected_ip'); - expect($log->tenant_id)->toBeNull(); -}); - -it('logs status=rate_limited when per-IP rate limit exceeded (429)', function () { - Bus::fake(); - // Saturate the rate limiter - $key = 'supplier-webhook:127.0.0.1'; - $limit = 600; // RATE_LIMIT_PER_MINUTE constant - for ($i = 0; $i < $limit; $i++) { - RateLimiter::hit($key, 60); - } - - $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [ - 'vid' => 900004, - 'project' => 'B1_log-test.ru', - 'phone' => '79991234567', - 'time' => time(), - ])->assertStatus(429); - - $log = DB::table('webhook_log') - ->where('status', 'rate_limited') - ->where('source', 'supplier') - ->latest('created_at') - ->first(); - - expect($log)->not->toBeNull(); - expect($log->status)->toBe('rate_limited'); - expect($log->tenant_id)->toBeNull(); -}); diff --git a/app/tests/Feature/Notifications/BalanceNotificationsTest.php b/app/tests/Feature/Notifications/BalanceNotificationsTest.php index 820e8baa..73bd58af 100644 --- a/app/tests/Feature/Notifications/BalanceNotificationsTest.php +++ b/app/tests/Feature/Notifications/BalanceNotificationsTest.php @@ -2,19 +2,13 @@ declare(strict_types=1); -use App\Jobs\ProcessWebhookJob; use App\Mail\InvoicePaidNotification; -use App\Mail\LowBalanceNotification; use App\Mail\TopupSuccessNotification; -use App\Mail\ZeroBalanceNotification; use App\Models\InAppNotification; -use App\Models\RejectedDealsLog; use App\Models\Tenant; use App\Models\User; use App\Services\NotificationService; use Illuminate\Foundation\Testing\DatabaseTransactions; -use Illuminate\Support\Carbon; -use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Mail; uses(DatabaseTransactions::class); @@ -23,18 +17,6 @@ beforeEach(function () { Mail::fake(); }); -function balancePayload(int $vid = 500): array -{ - return [ - 'vid' => $vid, - 'project' => 'B2_Caranga', - 'tag' => 'Caranga', - 'phone' => '79000000'.$vid, - 'phones' => ['79000000'.$vid], - 'time' => time(), - ]; -} - function makeUserForBalance(Tenant $tenant, string $email, array $events = []): User { return User::factory()->create([ @@ -53,102 +35,6 @@ function makeUserForBalance(Tenant $tenant, string $email, array $events = []): ]); } -// ============== low_balance ============== - -test('low_balance: при пересечении порога сверху-вниз → email + inapp', function () { - // Default threshold: 10 (system_settings seeded). Установим balance=11. - $tenant = Tenant::factory()->create(['balance_leads' => 11]); - makeUserForBalance($tenant, 'on@example.ru'); - - (new ProcessWebhookJob($tenant->id, balancePayload()))->handle(); - - $tenant->refresh(); - expect($tenant->balance_leads)->toBe(10); // 11 → 10 (пересекли порог) - - Mail::assertSent(LowBalanceNotification::class, 1); - expect(InAppNotification::query()->where('event', 'low_balance')->count())->toBe(1); -}); - -test('low_balance: balance уже < threshold — НЕ шлёт повторно', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 5]); - makeUserForBalance($tenant, 'on@example.ru'); - - (new ProcessWebhookJob($tenant->id, balancePayload()))->handle(); - - $tenant->refresh(); - expect($tenant->balance_leads)->toBe(4); // 5 → 4 (всё ещё < threshold=10) - - // Не пересекали порог — НЕ шлём. - Mail::assertNothingSent(); - expect(InAppNotification::query()->where('event', 'low_balance')->count())->toBe(0); -}); - -test('low_balance: balance > threshold после decrement — НЕ шлёт', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 50]); - makeUserForBalance($tenant, 'on@example.ru'); - - (new ProcessWebhookJob($tenant->id, balancePayload()))->handle(); - - $tenant->refresh(); - expect($tenant->balance_leads)->toBe(49); - - Mail::assertNothingSent(); -}); - -test('low_balance: prefs.low_balance.email=false — только inapp', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 11]); - makeUserForBalance($tenant, 'on@example.ru', [ - 'low_balance' => ['email' => false, 'inapp' => true], - ]); - - (new ProcessWebhookJob($tenant->id, balancePayload()))->handle(); - - Mail::assertNothingSent(); - expect(InAppNotification::query()->where('event', 'low_balance')->count())->toBe(1); -}); - -// ============== zero_balance ============== - -test('zero_balance: первое отклонение → email + inapp', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 0]); - makeUserForBalance($tenant, 'on@example.ru'); - - (new ProcessWebhookJob($tenant->id, balancePayload()))->handle(); - - Mail::assertSent(ZeroBalanceNotification::class, 1); - expect(InAppNotification::query()->where('event', 'zero_balance')->count())->toBe(1); - expect(RejectedDealsLog::query()->where('tenant_id', $tenant->id)->count())->toBe(1); -}); - -test('zero_balance: 2-е отклонение в течение часа — НЕ дублирует email', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 0]); - makeUserForBalance($tenant, 'on@example.ru'); - - (new ProcessWebhookJob($tenant->id, balancePayload(vid: 1)))->handle(); - Mail::assertSent(ZeroBalanceNotification::class, 1); - - (new ProcessWebhookJob($tenant->id, balancePayload(vid: 2)))->handle(); - Mail::assertSent(ZeroBalanceNotification::class, 1); // всё ещё один - expect(RejectedDealsLog::query()->where('tenant_id', $tenant->id)->count())->toBe(2); -}); - -test('zero_balance: отклонение через >1ч — снова шлёт', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 0]); - makeUserForBalance($tenant, 'on@example.ru'); - - // Создаём старый RejectedDealsLog (>1ч назад) — он не должен суппрессить. - DB::table('rejected_deals_log')->insert([ - 'tenant_id' => $tenant->id, - 'reason' => RejectedDealsLog::REASON_ZERO_BALANCE, - 'payload' => json_encode(['vid' => 999]), - 'created_at' => Carbon::now()->subHours(2), - ]); - - (new ProcessWebhookJob($tenant->id, balancePayload()))->handle(); - - Mail::assertSent(ZeroBalanceNotification::class, 1); -}); - // ============== topup_success ============== test('topup_success: notifyTopupSuccess создаёт email + inapp', function () { @@ -204,18 +90,3 @@ test('invoice_paid: prefs=email:false — только inapp', function () { Mail::assertNothingSent(); expect(InAppNotification::query()->where('event', 'invoice_paid')->count())->toBe(1); }); - -// ============== isolation ============== - -test('balance events изолированы между тенантами', function () { - $tenantA = Tenant::factory()->create(['balance_leads' => 11]); - $tenantB = Tenant::factory()->create(['balance_leads' => 11]); - $userA = makeUserForBalance($tenantA, 'a@example.ru'); - makeUserForBalance($tenantB, 'b@example.ru'); - - (new ProcessWebhookJob($tenantA->id, balancePayload()))->handle(); - - Mail::assertSent(LowBalanceNotification::class, 1); - Mail::assertSent(fn (LowBalanceNotification $m) => $m->hasTo($userA->email)); - Mail::assertNotSent(fn (LowBalanceNotification $m) => $m->hasTo('b@example.ru')); -}); diff --git a/app/tests/Feature/Notifications/InAppNotificationTest.php b/app/tests/Feature/Notifications/InAppNotificationTest.php index 4f65e6f5..e050c072 100644 --- a/app/tests/Feature/Notifications/InAppNotificationTest.php +++ b/app/tests/Feature/Notifications/InAppNotificationTest.php @@ -2,8 +2,6 @@ declare(strict_types=1); -use App\Jobs\ProcessWebhookJob; -use App\Mail\NewLeadNotification; use App\Models\InAppNotification; use App\Models\Tenant; use App\Models\User; @@ -14,12 +12,8 @@ use Illuminate\Support\Facades\Mail; /** * Тесты in-app канала уведомлений (schema v8.10 in_app_notifications). * - * Канал inapp в матрице users.notification_preferences. INSERT row при - * триггере события (new_lead/...). UI читает unread-count и список - * последних 50 (этап 2b — отдельный коммит). - * - * Schema-default: notification_preferences.new_lead.inapp=true → в отличие - * от email, большинство user'ов получает in-app по умолчанию. + * Тесты через ProcessWebhookJob удалены — job убран как legacy-рудимент. + * Оставлен прямой вызов NotificationService::notifyInApp. */ uses(DatabaseTransactions::class); @@ -27,164 +21,6 @@ beforeEach(function () { Mail::fake(); }); -/** - * @return array - */ -function inAppPayload(int $vid = 300, ?int $time = null): array -{ - return [ - 'vid' => $vid, - 'project' => 'B2_Caranga', - 'tag' => 'Caranga', - 'phone' => '79001234567', - 'phones' => ['79001234567'], - 'time' => $time ?? time(), - ]; -} - -/** - * @param array $newLeadPrefs - */ -function makeUserWithInAppPrefs(Tenant $tenant, string $email, array $newLeadPrefs): User -{ - return User::factory()->create([ - 'tenant_id' => $tenant->id, - 'email' => $email, - 'notification_preferences' => [ - 'new_lead' => $newLeadPrefs, - 'reminder' => ['inapp' => true, 'push' => true, 'email' => true], - 'low_balance' => ['email' => true], - 'zero_balance' => ['email' => true], - 'topup_success' => ['email' => true], - 'invoice_paid' => ['email' => true], - 'new_device_login' => ['email' => true], - 'marketing' => ['email' => false], - ], - ]); -} - -test('webhook: in_app_notification создаётся для user с inapp=true', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 10]); - $user = makeUserWithInAppPrefs($tenant, 'on@example.ru', ['inapp' => true, 'email' => false]); - - (new ProcessWebhookJob($tenant->id, inAppPayload()))->handle(); - - expect(InAppNotification::query()->count())->toBe(1); - $notif = InAppNotification::query()->first(); - expect($notif->user_id)->toBe($user->id); - expect($notif->tenant_id)->toBe($tenant->id); - expect($notif->event)->toBe('new_lead'); - expect($notif->title)->toContain('Caranga'); - expect($notif->body)->toBe('79001234567'); // phone (no contact_name) - expect($notif->read_at)->toBeNull(); - expect($notif->payload['project_name'])->toBe('Caranga'); -}); - -test('webhook: user с inapp=false НЕ получает in-app row', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 10]); - makeUserWithInAppPrefs($tenant, 'off@example.ru', ['inapp' => false, 'email' => false]); - - (new ProcessWebhookJob($tenant->id, inAppPayload()))->handle(); - - expect(InAppNotification::query()->count())->toBe(0); -}); - -test('webhook: schema-default (inapp=true) ставит row', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 10]); - // Без override prefs — берётся schema DEFAULT (new_lead.inapp=true). - User::factory()->create([ - 'tenant_id' => $tenant->id, - 'email' => 'default@example.ru', - ]); - - (new ProcessWebhookJob($tenant->id, inAppPayload()))->handle(); - - expect(InAppNotification::query()->count())->toBe(1); -}); - -test('webhook: 2 user\'а с inapp=true получают по 1 row, 1 user с inapp=false — нет', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 10]); - $a = makeUserWithInAppPrefs($tenant, 'a@example.ru', ['inapp' => true]); - $b = makeUserWithInAppPrefs($tenant, 'b@example.ru', ['inapp' => true]); - makeUserWithInAppPrefs($tenant, 'c@example.ru', ['inapp' => false]); - - (new ProcessWebhookJob($tenant->id, inAppPayload()))->handle(); - - expect(InAppNotification::query()->count())->toBe(2); - expect(InAppNotification::query()->where('user_id', $a->id)->exists())->toBeTrue(); - expect(InAppNotification::query()->where('user_id', $b->id)->exists())->toBeTrue(); -}); - -test('webhook: inactive user НЕ получает in-app', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 10]); - User::factory()->inactive()->create([ - 'tenant_id' => $tenant->id, - 'email' => 'inactive@example.ru', - 'notification_preferences' => ['new_lead' => ['inapp' => true]], - ]); - - (new ProcessWebhookJob($tenant->id, inAppPayload()))->handle(); - - expect(InAppNotification::query()->count())->toBe(0); -}); - -test('webhook: user другого тенанта НЕ получает (RLS isolation)', function () { - $tenantA = Tenant::factory()->create(['balance_leads' => 10]); - $tenantB = Tenant::factory()->create(['balance_leads' => 10]); - $userA = makeUserWithInAppPrefs($tenantA, 'a@example.ru', ['inapp' => true]); - makeUserWithInAppPrefs($tenantB, 'b@example.ru', ['inapp' => true]); - - (new ProcessWebhookJob($tenantA->id, inAppPayload()))->handle(); - - expect(InAppNotification::query()->count())->toBe(1); - expect(InAppNotification::query()->first()->user_id)->toBe($userA->id); -}); - -test('webhook: дубль (Биз-19) НЕ создаёт повторный in-app row', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 10]); - makeUserWithInAppPrefs($tenant, 'on@example.ru', ['inapp' => true]); - - (new ProcessWebhookJob($tenant->id, inAppPayload(vid: 1)))->handle(); - expect(InAppNotification::query()->count())->toBe(1); - - // Второй webhook с тем же phone в окне 24ч → дубль, нет chargeNewLead → нет notify. - (new ProcessWebhookJob($tenant->id, inAppPayload(vid: 2)))->handle(); - expect(InAppNotification::query()->count())->toBe(1); -}); - -test('webhook: повторный vid (UPDATE) НЕ создаёт повторный in-app row', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 10]); - makeUserWithInAppPrefs($tenant, 'on@example.ru', ['inapp' => true]); - - (new ProcessWebhookJob($tenant->id, inAppPayload(vid: 100)))->handle(); - expect(InAppNotification::query()->count())->toBe(1); - - (new ProcessWebhookJob($tenant->id, inAppPayload(vid: 100)))->handle(); - expect(InAppNotification::query()->count())->toBe(1); -}); - -test('webhook: оба канала (inapp+email=true) — 1 in-app row + 1 email', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 10]); - makeUserWithInAppPrefs($tenant, 'both@example.ru', ['inapp' => true, 'email' => true]); - - (new ProcessWebhookJob($tenant->id, inAppPayload()))->handle(); - - expect(InAppNotification::query()->count())->toBe(1); - Mail::assertSent(NewLeadNotification::class, 1); -}); - -test('webhook: payload содержит deal_id для UI deep-link', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 10]); - makeUserWithInAppPrefs($tenant, 'on@example.ru', ['inapp' => true]); - - (new ProcessWebhookJob($tenant->id, inAppPayload()))->handle(); - - $notif = InAppNotification::query()->first(); - expect($notif->deal_id)->not->toBeNull(); - expect($notif->payload)->toHaveKey('deal_id'); - expect($notif->payload['deal_id'])->toBe($notif->deal_id); -}); - test('NotificationService::notifyInApp: вызов напрямую создаёт row', function () { $tenant = Tenant::factory()->create(); $user = User::factory()->create(['tenant_id' => $tenant->id]); diff --git a/app/tests/Feature/Notifications/NewLeadNotificationTest.php b/app/tests/Feature/Notifications/NewLeadNotificationTest.php deleted file mode 100644 index 99dd930d..00000000 --- a/app/tests/Feature/Notifications/NewLeadNotificationTest.php +++ /dev/null @@ -1,199 +0,0 @@ - - */ -function newLeadPayload(int $vid = 200, ?int $time = null): array -{ - return [ - 'vid' => $vid, - 'project' => 'B2_Caranga', - 'tag' => 'Caranga', - 'phone' => '79001234567', - 'phones' => ['79001234567'], - 'time' => $time ?? time(), - ]; -} - -/** - * @param array $newLeadPrefs - */ -function makeUserWithPrefs(Tenant $tenant, string $email, array $newLeadPrefs): User -{ - return User::factory()->create([ - 'tenant_id' => $tenant->id, - 'email' => $email, - 'notification_preferences' => [ - 'new_lead' => $newLeadPrefs, - 'reminder' => ['inapp' => true, 'push' => true, 'email' => true], - 'low_balance' => ['email' => true], - 'zero_balance' => ['email' => true], - 'topup_success' => ['email' => true], - 'invoice_paid' => ['email' => true], - 'new_device_login' => ['email' => true], - 'marketing' => ['email' => false], - ], - ]); -} - -test('webhook: NewLeadNotification отправляется user\'ам с email=true', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 10]); - $userOn = makeUserWithPrefs($tenant, 'on@example.ru', ['inapp' => true, 'push' => true, 'email' => true]); - - (new ProcessWebhookJob($tenant->id, newLeadPayload()))->handle(); - - Mail::assertSent(NewLeadNotification::class, 1); - Mail::assertSent(NewLeadNotification::class, function (NewLeadNotification $mail) use ($userOn): bool { - return $mail->manager->id === $userOn->id - && $mail->hasTo('on@example.ru'); - }); -}); - -test('webhook: user с email=false НЕ получает', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 10]); - makeUserWithPrefs($tenant, 'off@example.ru', ['inapp' => true, 'push' => true, 'email' => false]); - - (new ProcessWebhookJob($tenant->id, newLeadPayload()))->handle(); - - Mail::assertNothingSent(); -}); - -test('webhook: schema-default не шлёт (new_lead.email=false по дефолту)', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 10]); - // Не передаём notification_preferences — берётся schema DEFAULT. - User::factory()->create([ - 'tenant_id' => $tenant->id, - 'email' => 'default@example.ru', - ]); - - (new ProcessWebhookJob($tenant->id, newLeadPayload()))->handle(); - - Mail::assertNothingSent(); -}); - -test('webhook: рассылается всем активным user\'ам с email=true', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 10]); - makeUserWithPrefs($tenant, 'a@example.ru', ['email' => true]); - makeUserWithPrefs($tenant, 'b@example.ru', ['email' => true]); - makeUserWithPrefs($tenant, 'c@example.ru', ['email' => false]); - - (new ProcessWebhookJob($tenant->id, newLeadPayload()))->handle(); - - Mail::assertSent(NewLeadNotification::class, 2); - Mail::assertSent(fn (NewLeadNotification $mail) => $mail->hasTo('a@example.ru')); - Mail::assertSent(fn (NewLeadNotification $mail) => $mail->hasTo('b@example.ru')); - Mail::assertNotSent(fn (NewLeadNotification $mail) => $mail->hasTo('c@example.ru')); -}); - -test('webhook: inactive user с email=true НЕ получает (is_active=false)', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 10]); - User::factory()->inactive()->create([ - 'tenant_id' => $tenant->id, - 'email' => 'inactive@example.ru', - 'notification_preferences' => [ - 'new_lead' => ['email' => true], - ], - ]); - - (new ProcessWebhookJob($tenant->id, newLeadPayload()))->handle(); - - Mail::assertNothingSent(); -}); - -test('webhook: soft-deleted user НЕ получает', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 10]); - $user = makeUserWithPrefs($tenant, 'deleted@example.ru', ['email' => true]); - $user->delete(); - - (new ProcessWebhookJob($tenant->id, newLeadPayload()))->handle(); - - Mail::assertNothingSent(); -}); - -test('webhook: user другого тенанта НЕ получает (изоляция)', function () { - $tenantA = Tenant::factory()->create(['balance_leads' => 10]); - $tenantB = Tenant::factory()->create(['balance_leads' => 10]); - makeUserWithPrefs($tenantA, 'a@example.ru', ['email' => true]); - makeUserWithPrefs($tenantB, 'b@example.ru', ['email' => true]); - - (new ProcessWebhookJob($tenantA->id, newLeadPayload()))->handle(); - - Mail::assertSent(NewLeadNotification::class, 1); - Mail::assertSent(fn (NewLeadNotification $mail) => $mail->hasTo('a@example.ru')); - Mail::assertNotSent(fn (NewLeadNotification $mail) => $mail->hasTo('b@example.ru')); -}); - -test('webhook: дубль-сделка (Биз-19) НЕ шлёт повторное уведомление', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 10]); - makeUserWithPrefs($tenant, 'on@example.ru', ['email' => true]); - - // Первая сделка — master. - (new ProcessWebhookJob($tenant->id, newLeadPayload(vid: 1)))->handle(); - Mail::assertSent(NewLeadNotification::class, 1); - - // Вторая сделка с тем же phone в окне 24 ч — дубль, баланс НЕ списывается, - // chargeNewLead НЕ вызывается, уведомление НЕ шлётся. - (new ProcessWebhookJob($tenant->id, newLeadPayload(vid: 2)))->handle(); - Mail::assertSent(NewLeadNotification::class, 1); // всё ещё одно -}); - -test('webhook: повторный vid (idempotent UPDATE) НЕ шлёт повторное уведомление', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 10]); - makeUserWithPrefs($tenant, 'on@example.ru', ['email' => true]); - - (new ProcessWebhookJob($tenant->id, newLeadPayload(vid: 100)))->handle(); - Mail::assertSent(NewLeadNotification::class, 1); - - // Повторный webhook с тем же vid — UPDATE, не INSERT. wasRecentlyCreated=false → return. - (new ProcessWebhookJob($tenant->id, newLeadPayload(vid: 100)))->handle(); - Mail::assertSent(NewLeadNotification::class, 1); -}); - -test('webhook: balance=0 (RejectedDealsLog) НЕ шлёт NewLeadNotification', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 0]); - makeUserWithPrefs($tenant, 'on@example.ru', ['email' => true]); - - (new ProcessWebhookJob($tenant->id, newLeadPayload()))->handle(); - - // chargeNewLead НЕ вызывается при balance=0 — NewLeadNotification не шлётся. - // (ZeroBalanceNotification ШЛЁТСЯ — это покрывается отдельным тестом.) - Mail::assertNotSent(NewLeadNotification::class); - expect(Deal::query()->where('tenant_id', $tenant->id)->count())->toBe(0); -}); - -test('NewLeadNotification: subject содержит project_name', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 10]); - makeUserWithPrefs($tenant, 'on@example.ru', ['email' => true]); - - (new ProcessWebhookJob($tenant->id, newLeadPayload()))->handle(); - - Mail::assertSent(NewLeadNotification::class, function (NewLeadNotification $mail): bool { - return str_contains($mail->envelope()->subject, 'Caranga'); - }); -}); diff --git a/app/tests/Feature/PartitionsCreateMonthsTest.php b/app/tests/Feature/PartitionsCreateMonthsTest.php index c18cf953..6f412aa9 100644 --- a/app/tests/Feature/PartitionsCreateMonthsTest.php +++ b/app/tests/Feature/PartitionsCreateMonthsTest.php @@ -17,7 +17,7 @@ beforeEach(function () { $this->partitionsBefore = collect(DB::select(" SELECT relname FROM pg_class WHERE relkind = 'r' - AND relname ~ '^(deals|supplier_lead_costs|auth_log|activity_log|tenant_operations_log|webhook_log|balance_transactions|pd_processing_log|saas_admin_audit_log)_[0-9]{4}_[0-9]{2}$' + AND relname ~ '^(deals|supplier_lead_costs|auth_log|activity_log|tenant_operations_log|balance_transactions|pd_processing_log|saas_admin_audit_log)_[0-9]{4}_[0-9]{2}$' "))->pluck('relname')->all(); }); @@ -25,7 +25,7 @@ afterEach(function () { $partitionsAfter = collect(DB::select(" SELECT relname FROM pg_class WHERE relkind = 'r' - AND relname ~ '^(deals|supplier_lead_costs|auth_log|activity_log|tenant_operations_log|webhook_log|balance_transactions|pd_processing_log|saas_admin_audit_log)_[0-9]{4}_[0-9]{2}$' + AND relname ~ '^(deals|supplier_lead_costs|auth_log|activity_log|tenant_operations_log|balance_transactions|pd_processing_log|saas_admin_audit_log)_[0-9]{4}_[0-9]{2}$' "))->pluck('relname')->all(); // DETACH перед DROP: иначе `DROP TABLE ... CASCADE` сносит FK от @@ -61,7 +61,7 @@ test('идемпотентность: повторный запуск не па $afterFirst = collect(DB::select(" SELECT relname FROM pg_class WHERE relkind = 'r' - AND relname ~ '^(deals|supplier_lead_costs|auth_log|activity_log|tenant_operations_log|webhook_log|balance_transactions|pd_processing_log|saas_admin_audit_log)_[0-9]{4}_[0-9]{2}$' + AND relname ~ '^(deals|supplier_lead_costs|auth_log|activity_log|tenant_operations_log|balance_transactions|pd_processing_log|saas_admin_audit_log)_[0-9]{4}_[0-9]{2}$' "))->count(); // Повторный запуск — должен только skip'ать. @@ -71,14 +71,15 @@ test('идемпотентность: повторный запуск не па $afterSecond = collect(DB::select(" SELECT relname FROM pg_class WHERE relkind = 'r' - AND relname ~ '^(deals|supplier_lead_costs|auth_log|activity_log|tenant_operations_log|webhook_log|balance_transactions|pd_processing_log|saas_admin_audit_log)_[0-9]{4}_[0-9]{2}$' + AND relname ~ '^(deals|supplier_lead_costs|auth_log|activity_log|tenant_operations_log|balance_transactions|pd_processing_log|saas_admin_audit_log)_[0-9]{4}_[0-9]{2}$' "))->count(); expect($afterSecond)->toBe($afterFirst); - // Output второго запуска должен сказать «0 created» по всем 9 таблицам × 6 месяцев = 54 партиции. + // Output второго запуска должен сказать «0 created» по всем 8 таблицам × 6 месяцев = 48 партиций. + // (webhook_log удалён в миграции 2026_05_24_140000_drop_legacy_webhook_artefacts) $output = Artisan::output(); - expect($output)->toContain('0 created, 54 skipped'); + expect($output)->toContain('0 created, 48 skipped'); }); test('--ahead=0 создаёт только текущий месяц', function () { @@ -100,7 +101,6 @@ test('партиция корректно принимает INSERT в окно 'subdomain' => 'partition-test-'.uniqid(), 'organization_name' => 'PartitionTest', 'contact_email' => 'pt@test.local', - 'webhook_token' => str_repeat('p', 64), 'api_key_limit' => 5, ]); $projectId = DB::table('projects')->insertGetId([ diff --git a/app/tests/Feature/Pd/DealCreatePdLogTest.php b/app/tests/Feature/Pd/DealCreatePdLogTest.php index 390fe42c..b7a193aa 100644 --- a/app/tests/Feature/Pd/DealCreatePdLogTest.php +++ b/app/tests/Feature/Pd/DealCreatePdLogTest.php @@ -4,11 +4,9 @@ declare(strict_types=1); /** * 152-ФЗ: pd_processing_log 'created' записывается при создании сделки - * по всем трём путям — ручной API, поставщик (RouteSupplierLeadJob), - * вебхук (ProcessWebhookJob). + * по двум живым путям — ручной API, поставщик (RouteSupplierLeadJob). */ -use App\Jobs\ProcessWebhookJob; use App\Jobs\RouteSupplierLeadJob; use App\Models\Deal; use App\Models\Project; @@ -128,36 +126,3 @@ it('writes pd_processing_log created (supplier) when deal created via RouteSuppl expect($rows)->toBe(1); }); - -// ────────────────────────────────────────────────────────────────────────── -// Path C: webhook via ProcessWebhookJob -// ────────────────────────────────────────────────────────────────────────── - -it('writes pd_processing_log created (webhook) when deal created via ProcessWebhookJob', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 10]); - - $vid = 55566; - (new ProcessWebhookJob($tenant->id, [ - 'vid' => $vid, - 'project' => 'B2_PdWebhookTest', - 'tag' => 'PdWebhookTest', - 'phone' => '79001112233', - 'phones' => ['79001112233'], - 'time' => time(), - ]))->handle(); - - $deal = Deal::query()->where('tenant_id', $tenant->id)->where('source_crm_id', $vid)->first(); - expect($deal)->not->toBeNull(); - - $rows = DB::table('pd_processing_log') - ->where('action', 'created') - ->where('purpose', 'lead_create_webhook') - ->where('subject_type', 'lead') - ->where('subject_id', $deal->id) - ->where('tenant_id', $tenant->id) - ->whereNull('actor_tenant_user_id') - ->whereNull('actor_admin_user_id') - ->count(); - - expect($rows)->toBe(1); -}); diff --git a/app/tests/Feature/Plan4/Schema/SchemaDeltaTest.php b/app/tests/Feature/Plan4/Schema/SchemaDeltaTest.php index 6e2ad75c..ddfdef0f 100644 --- a/app/tests/Feature/Plan4/Schema/SchemaDeltaTest.php +++ b/app/tests/Feature/Plan4/Schema/SchemaDeltaTest.php @@ -19,8 +19,8 @@ it('tenants table has delivered_in_month column with CHECK >= 0', function () { expect(Schema::hasColumn('tenants', 'delivered_in_month'))->toBeTrue(); DB::table('tenants')->where('id', '<', 0)->update(['delivered_in_month' => 5]); // no-op expect(fn () => DB::statement( - 'INSERT INTO tenants (subdomain, organization_name, contact_email, webhook_token, delivered_in_month) '. - "VALUES ('t-neg-test', 'X', 'x@x', 'wtok-neg-test-99999999', -1)" + 'INSERT INTO tenants (subdomain, organization_name, contact_email, delivered_in_month) '. + "VALUES ('t-neg-test', 'X', 'x@x', -1)" ))->toThrow(QueryException::class); }); @@ -59,32 +59,33 @@ it('supplier_csv_reconcile_log table exists with required columns and status CHE ]))->toThrow(QueryException::class); }); -it('schema.sql v8.26 has correct metrics — 65 base tables, 123 indexes, 40 RLS policies', function () { +it('schema.sql v8.35 has correct metrics — 66 base tables, 120 indexes, 40 RLS policies', function () { // Замена destructive `migrate:fresh` (cross-test coupling: после DROP CASCADE остальные // Feature-тесты в той же сессии видели пустую БД). Static parse `db/schema.sql` — - // источник истины метрик из spec §2.4 / db/CHANGELOG_schema.md v8.26. + // источник истины метрик. // v8.21 (Sprint 4): +1 таблица import_unknown_statuses, +1 индекс, +1 RLS-политика. // v8.22 (Plan 6/C9): +1 GIN-индекс idx_projects_regions. // v8.25 (supplier-failover): +1 таблица supplier_manual_sync_queue, +2 индекса. // v8.26 (project-migration-redesign Plans 1-3): +1 таблица project_supplier_links (M:N pivot) // + 2 индекса (supplier_projects_platform_key_subject_unique, idx_psl_*). + // v8.30: +1 таблица scheduler_heartbeats (SaaS-level, hole #6). + // v8.31: 7 audit-таблиц переведены в PARTITION BY RANGE, hole #2. + // v8.35 (legacy webhook removal): −2 таблицы (webhook_log partitioned + rejected_deals_log) + // −5 индексов, −2 RLS-политики, −2 колонки tenants.webhook_token/webhook_token_rotated_at. $schemaPath = dirname(base_path()).DIRECTORY_SEPARATOR.'db'.DIRECTORY_SEPARATOR.'schema.sql'; expect(is_file($schemaPath) && is_readable($schemaPath))->toBeTrue(); $schema = file_get_contents($schemaPath); expect($schema)->not->toBeFalse(); - // v8.30: +1 таблица scheduler_heartbeats (SaaS-level, hole #6). - // v8.31: 7 audit-таблиц переведены в PARTITION BY RANGE, hole #2. - // - // 67 base tables = все CREATE TABLE минус PARTITION OF. + // 66 base tables = все CREATE TABLE минус PARTITION OF. $createTables = preg_match_all('/^CREATE TABLE\b/m', $schema); $partitionOf = preg_match_all('/CREATE TABLE\s+\w+\s+PARTITION OF\b/m', $schema); $baseTables = $createTables - $partitionOf; - expect($baseTables)->toBe(67); + expect($baseTables)->toBe(66); $createIndexes = preg_match_all('/^CREATE\s+(?:UNIQUE\s+)?INDEX\b/m', $schema); - expect($createIndexes)->toBe(126); // v8.31: +3 индекса audit-таблиц после partitioning + expect($createIndexes)->toBe(120); // v8.35: −5 индексов (webhook_log ×2, rejected_deals_log ×2, tenants.webhook_token ×1) $createPolicies = preg_match_all('/^CREATE\s+POLICY\b/m', $schema); - expect($createPolicies)->toBe(41); // v8.31: +1 политика на partitioned audit-таблицах + expect($createPolicies)->toBe(40); // v8.35: −2 политики (webhook_log + rejected_deals_log) }); diff --git a/app/tests/Feature/ProcessWebhookJobTest.php b/app/tests/Feature/ProcessWebhookJobTest.php deleted file mode 100644 index 919d1f93..00000000 --- a/app/tests/Feature/ProcessWebhookJobTest.php +++ /dev/null @@ -1,362 +0,0 @@ - $vid, - 'project' => 'B2_Caranga', // префикс должен обрезаться до 'Caranga' - 'tag' => 'Caranga', - 'phone' => '79001234567', - 'phones' => ['79001234567'], - 'time' => $time ?? time(), - ]; -} - -/** - * Создаёт активного поставщика и привязывает его к проекту через project_suppliers. - * Используется в тестах SupplierLeadCost-ветки. - */ -function seedSupplierForProject(Project $project, float $costRub = 50.00): int -{ - $supplierId = (int) DB::table('suppliers')->insertGetId([ - 'code' => 'b1-test-'.Str::lower(Str::random(6)), - 'name' => 'B1 Test', - 'accepts_types' => '{websites,calls}', - 'cost_rub' => $costRub, - 'channel' => 'sites', - 'quality_score' => 1.00, - 'is_active' => true, - 'sort_order' => 0, - ]); - - DB::table('project_suppliers')->insert([ - 'project_id' => $project->id, - 'supplier_id' => $supplierId, - 'is_active' => true, - 'created_at' => now(), - ]); - - return $supplierId; -} - -test('новая сделка: INSERT в deals + INSERT в webhook_dedup_keys, баланс -1', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 10]); - - (new ProcessWebhookJob($tenant->id, makePayload(vid: 100)))->handle(); - - $tenant->refresh(); - expect($tenant->balance_leads)->toBe(9); - - $deal = Deal::query()->where('tenant_id', $tenant->id)->first(); - expect($deal)->not->toBeNull(); - expect($deal->source_crm_id)->toBe(100); - expect($deal->phone)->toBe('79001234567'); - expect($deal->status)->toBe('new'); - expect($deal->project->name)->toBe('Caranga'); // префикс B2_ обрезан - - $dedup = WebhookDedupKey::query() - ->where('tenant_id', $tenant->id) - ->where('source_crm_id', 100) - ->first(); - expect($dedup)->not->toBeNull(); - expect($dedup->deal_id)->toBe($deal->id); -}); - -test('дубль vid: UPDATE существующей сделки, баланс НЕ списывается второй раз', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 10]); - $vid = 200; - - // Первый webhook - (new ProcessWebhookJob($tenant->id, makePayload(vid: $vid)))->handle(); - $tenant->refresh(); - expect($tenant->balance_leads)->toBe(9); - $dealsAfterFirst = Deal::query()->where('tenant_id', $tenant->id)->count(); - - // Второй webhook с тем же vid (но новым phone — будет UPDATE) - $payload2 = makePayload(vid: $vid); - $payload2['phone'] = '79009999999'; - (new ProcessWebhookJob($tenant->id, $payload2))->handle(); - - $tenant->refresh(); - expect($tenant->balance_leads)->toBe(9); // баланс не изменился - expect(Deal::query()->where('tenant_id', $tenant->id)->count())->toBe($dealsAfterFirst); - - $deal = Deal::query()->where('tenant_id', $tenant->id)->where('source_crm_id', $vid)->first(); - expect($deal->phone)->toBe('79009999999'); // обновлён phone - - // dedup-ключ всё ещё ровно один - expect(WebhookDedupKey::query()->where('tenant_id', $tenant->id)->where('source_crm_id', $vid)->count())->toBe(1); -}); - -test('баланс=0: запись в лог, без INSERT в deals и dedup_keys', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 0]); - - (new ProcessWebhookJob($tenant->id, makePayload(vid: 300)))->handle(); - - $tenant->refresh(); - expect($tenant->balance_leads)->toBe(0); - expect(Deal::query()->where('tenant_id', $tenant->id)->count())->toBe(0); - expect(WebhookDedupKey::query()->where('tenant_id', $tenant->id)->count())->toBe(0); -}); - -test('изоляция тенантов: одинаковый vid у разных тенантов = разные сделки', function () { - $tenantA = Tenant::factory()->create(['balance_leads' => 10]); - $tenantB = Tenant::factory()->create(['balance_leads' => 10]); - - (new ProcessWebhookJob($tenantA->id, makePayload(vid: 555)))->handle(); - (new ProcessWebhookJob($tenantB->id, makePayload(vid: 555)))->handle(); - - expect(Deal::query()->where('tenant_id', $tenantA->id)->count())->toBe(1); - expect(Deal::query()->where('tenant_id', $tenantB->id)->count())->toBe(1); - expect(WebhookDedupKey::query()->count())->toBeGreaterThanOrEqual(2); - - $tenantA->refresh(); - $tenantB->refresh(); - expect($tenantA->balance_leads)->toBe(9); - expect($tenantB->balance_leads)->toBe(9); -}); - -test('findOrCreate проекта: повторный webhook с тем же project не создаёт дубля', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 10]); - - (new ProcessWebhookJob($tenant->id, makePayload(vid: 401)))->handle(); - (new ProcessWebhookJob($tenant->id, makePayload(vid: 402)))->handle(); - - expect(Project::query()->where('tenant_id', $tenant->id)->count())->toBe(1); -}); - -test('ON DELETE CASCADE: удаление сделки очищает webhook_dedup_keys', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 10]); - - (new ProcessWebhookJob($tenant->id, makePayload(vid: 700)))->handle(); - - $deal = Deal::query()->where('tenant_id', $tenant->id)->first(); - DB::table('deals') - ->where('id', $deal->id) - ->where('received_at', $deal->received_at) - ->delete(); - - expect(WebhookDedupKey::query() - ->where('tenant_id', $tenant->id) - ->where('source_crm_id', 700) - ->count())->toBe(0); -}); - -test('новая сделка создаёт BalanceTransaction (lead_charge -1)', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 10]); - - (new ProcessWebhookJob($tenant->id, makePayload(vid: 800)))->handle(); - - $deal = Deal::query()->where('tenant_id', $tenant->id)->first(); - $tx = BalanceTransaction::query() - ->where('tenant_id', $tenant->id) - ->where('type', BalanceTransaction::TYPE_LEAD_CHARGE) - ->first(); - - expect($tx)->not->toBeNull(); - expect($tx->amount_leads)->toBe(-1); - expect($tx->balance_leads_after)->toBe(9); - expect($tx->related_type)->toBe(Deal::class); - expect($tx->related_id)->toBe($deal->id); -}); - -test('дубль vid НЕ создаёт BalanceTransaction', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 10]); - $vid = 801; - - (new ProcessWebhookJob($tenant->id, makePayload(vid: $vid)))->handle(); - (new ProcessWebhookJob($tenant->id, makePayload(vid: $vid)))->handle(); - - expect(BalanceTransaction::query() - ->where('tenant_id', $tenant->id) - ->count())->toBe(1); -}); - -test('новая сделка создаёт ActivityLog event=deal.created', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 10]); - - (new ProcessWebhookJob($tenant->id, makePayload(vid: 802)))->handle(); - - $deal = Deal::query()->where('tenant_id', $tenant->id)->first(); - $log = ActivityLog::query() - ->where('tenant_id', $tenant->id) - ->where('deal_id', $deal->id) - ->first(); - - expect($log)->not->toBeNull(); - expect($log->event)->toBe(ActivityLog::EVENT_DEAL_CREATED); - expect($log->user_id)->toBeNull(); - expect($log->context)->toBe(['source' => 'webhook']); -}); - -test('баланс=0 пишет в RejectedDealsLog с reason=zero_balance', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 0]); - - (new ProcessWebhookJob($tenant->id, makePayload(vid: 803)))->handle(); - - $rejected = RejectedDealsLog::query() - ->where('tenant_id', $tenant->id) - ->first(); - - expect($rejected)->not->toBeNull(); - expect($rejected->reason)->toBe(RejectedDealsLog::REASON_ZERO_BALANCE); - expect($rejected->payload['vid'])->toBe(803); -}); - -test('SupplierLeadCost создаётся со snapshot cost_rub из supplier', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 10]); - $project = Project::factory()->create([ - 'tenant_id' => $tenant->id, - 'name' => 'Caranga', // совпадает с обрезанным project из payload - ]); - $supplierId = seedSupplierForProject($project, costRub: 75.50); - - (new ProcessWebhookJob($tenant->id, makePayload(vid: 804)))->handle(); - - $deal = Deal::query()->where('tenant_id', $tenant->id)->first(); - $cost = SupplierLeadCost::query() - ->where('deal_id', $deal->id) - ->where('received_at', $deal->received_at) - ->first(); - - expect($cost)->not->toBeNull(); - expect($cost->supplier_id)->toBe($supplierId); - expect((string) $cost->cost_rub)->toBe('75.50'); - expect($cost->supplier_lead_id)->toBe(804); -}); - -test('SupplierLeadCost НЕ создаётся если у проекта нет активного supplier', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 10]); - - (new ProcessWebhookJob($tenant->id, makePayload(vid: 805)))->handle(); - - $deal = Deal::query()->where('tenant_id', $tenant->id)->first(); - expect(SupplierLeadCost::query() - ->where('deal_id', $deal->id) - ->count())->toBe(0); - - // Сделка всё равно создаётся, баланс списан, ActivityLog есть. - expect($deal)->not->toBeNull(); - $tenant->refresh(); - expect($tenant->balance_leads)->toBe(9); -}); - -// ============================================================================= -// Spec B: no phone dedup — supplier owns dedup, Лидерра charges everything delivered -// ============================================================================= - -test('charges both leads with same phone but different vid (no phone dedup, Spec B)', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 5]); - $phone = '79007770010'; - - // First webhook — distinct vid - $payload1 = makePayload(vid: 951); - $payload1['phone'] = $phone; - $payload1['phones'] = [$phone]; - (new ProcessWebhookJob($tenant->id, $payload1))->handle(); - - // Second webhook — same phone, different vid - $payload2 = makePayload(vid: 952); - $payload2['phone'] = $phone; - $payload2['phones'] = [$phone]; - (new ProcessWebhookJob($tenant->id, $payload2))->handle(); - - $tenant->refresh(); - // Both charged — balance_leads decremented twice. - expect($tenant->balance_leads)->toBe(3); - - // Two distinct deals exist for this tenant. - $deals = Deal::query()->where('tenant_id', $tenant->id)->get(); - expect($deals)->toHaveCount(2); - - // Neither deal has duplicate_of_id set. - foreach ($deals as $deal) { - expect($deal->duplicate_of_id)->toBeNull(); - } -}); - -// ============================================================================= -// failed() callback — финальная обработка после исчерпания ретраев -// ============================================================================= - -test('failed() пишет упавший job в failed_webhook_jobs', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 10]); - $webhookLogId = (int) DB::table('webhook_log')->insertGetId([ - 'tenant_id' => $tenant->id, - 'raw_payload' => json_encode(['vid' => 1001]), - 'received_at' => now(), - ]); - $payload = makePayload(vid: 1001); - - $job = new ProcessWebhookJob($tenant->id, $payload, webhookLogId: $webhookLogId); - $job->failed(new RuntimeException('boom: db down')); - - $row = DB::table('failed_webhook_jobs') - ->where('tenant_id', $tenant->id) - ->first(); - - expect($row)->not->toBeNull(); - expect($row->webhook_log_id)->toBe($webhookLogId); - expect($row->exception)->toBe('boom: db down'); - expect($row->retry_count)->toBe(3); - expect($row->resolved_at)->toBeNull(); - expect(json_decode($row->raw_payload, true)['vid'])->toBe(1001); -}); - -test('failed() работает БЕЗ webhookLogId (NULL ok)', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 10]); - - $job = new ProcessWebhookJob($tenant->id, makePayload(vid: 1002)); - $job->failed(new RuntimeException('no webhook log id')); - - $row = DB::table('failed_webhook_jobs')->where('tenant_id', $tenant->id)->first(); - expect($row)->not->toBeNull(); - expect($row->webhook_log_id)->toBeNull(); -}); - -test('failed() записывает payload с UTF-8 кириллицей корректно', function () { - $tenant = Tenant::factory()->create(['balance_leads' => 10]); - $payload = makePayload(vid: 1003); - $payload['contact_name'] = 'Дмитрий Петров'; - - $job = new ProcessWebhookJob($tenant->id, $payload); - $job->failed(new RuntimeException('utf-8 test')); - - $row = DB::table('failed_webhook_jobs')->where('tenant_id', $tenant->id)->first(); - $decoded = json_decode($row->raw_payload, true); - - expect($decoded['contact_name'])->toBe('Дмитрий Петров'); -}); diff --git a/app/tests/Feature/RlsSmokeTest.php b/app/tests/Feature/RlsSmokeTest.php index 2cc97861..4987626d 100644 --- a/app/tests/Feature/RlsSmokeTest.php +++ b/app/tests/Feature/RlsSmokeTest.php @@ -45,14 +45,12 @@ SQL); 'subdomain' => 'rls-tenant-a-'.uniqid(), 'organization_name' => 'RLS Tenant A', 'contact_email' => 'a@rls-test.local', - 'webhook_token' => 'whtA'.str_pad((string) random_int(0, 999999999), 60, '0', STR_PAD_LEFT), 'api_key_limit' => 5, ]); $this->tenant2Id = DB::table('tenants')->insertGetId([ 'subdomain' => 'rls-tenant-b-'.uniqid(), 'organization_name' => 'RLS Tenant B', 'contact_email' => 'b@rls-test.local', - 'webhook_token' => 'whtB'.str_pad((string) random_int(0, 999999999), 60, '0', STR_PAD_LEFT), 'api_key_limit' => 5, ]); diff --git a/app/tests/Feature/WebhookReceiveTest.php b/app/tests/Feature/WebhookReceiveTest.php deleted file mode 100644 index 64aa8999..00000000 --- a/app/tests/Feature/WebhookReceiveTest.php +++ /dev/null @@ -1,286 +0,0 @@ -tenant = Tenant::factory()->create([ - 'webhook_token' => 'whk_test_'.bin2hex(random_bytes(8)), - 'balance_leads' => 100, - ]); - // Чистим RateLimiter между тестами — иначе lockout из одного теста - // загрязняет следующий. - RateLimiter::clear("webhook:{$this->tenant->id}"); - - // Audit-fix B3: дефолт isHmacRequired() изменён на true. Тесты, проверяющие - // НЕ-HMAC аспекты (payload-валидация, rate-limit, CSRF), явно ставят флаг в - // false — иначе запрос без подписи получит 401 ещё до этих проверок. - SystemSetting::firstOrCreate( - ['key' => 'webhook_hmac_required'], - ['value' => 'false', 'type' => 'bool', 'description' => 'test default', 'updated_at' => now()], - ); - SystemSetting::where('key', 'webhook_hmac_required')->update(['value' => 'false']); -}); - -test('POST /api/webhook/{token} с валидным payload возвращает 202 + dispatch ProcessWebhookJob', function () { - Bus::fake(); - - $payload = [ - 'vid' => 12345, - 'project' => 'Натяжные потолки', - 'phone' => '+7 (999) 123-45-67', - 'time' => time(), - 'tag' => 'ya_direct', - ]; - - $r = $this->postJson("/api/webhook/{$this->tenant->webhook_token}", $payload); - - $r->assertStatus(202); - expect($r->json('status'))->toBe('accepted'); - expect($r->json('tenant_id'))->toBe($this->tenant->id); - - Bus::assertDispatched(ProcessWebhookJob::class, function ($job) use ($payload) { - return $job->tenantId === $this->tenant->id - && $job->data['vid'] === $payload['vid'] - && $job->data['phone'] === $payload['phone']; - }); -}); - -test('POST с unknown token → 404', function () { - Bus::fake(); - $r = $this->postJson('/api/webhook/whk_nonexistent_token_12345', [ - 'vid' => 1, - 'project' => 'X', - 'phone' => '+7 (999) 000-00-00', - 'time' => time(), - ]); - $r->assertStatus(404); - Bus::assertNothingDispatched(); -}); - -test('POST без обязательных полей → 422', function () { - Bus::fake(); - - $r = $this->postJson("/api/webhook/{$this->tenant->webhook_token}", [ - // Нет vid/project/phone/time - ]); - - $r->assertStatus(422); - $errors = $r->json('errors'); - expect($errors)->toHaveKeys(['vid', 'project', 'phone', 'time']); - Bus::assertNothingDispatched(); -}); - -test('POST с вредной структурой (vid=строка, time=отрицательный) → 422', function () { - Bus::fake(); - - $r = $this->postJson("/api/webhook/{$this->tenant->webhook_token}", [ - 'vid' => 'не-число', - 'project' => 'X', - 'phone' => '+7 (999) 000-00-00', - 'time' => -1, - ]); - - $r->assertStatus(422); - Bus::assertNothingDispatched(); -}); - -test('POST к webhook НЕ требует CSRF (внешний клиент)', function () { - Bus::fake(); - // Симулируем запрос БЕЗ X-XSRF-TOKEN — CSRF middleware не должен проверять - // /api/webhook/* (см. bootstrap/app.php validateCsrfTokens except). - $r = $this->postJson("/api/webhook/{$this->tenant->webhook_token}", [ - 'vid' => 1, - 'project' => 'X', - 'phone' => '+7 (999) 000-00-00', - 'time' => time(), - ]); - $r->assertStatus(202); -}); - -test('POST с `phones` array (multi-phone payload) принимается', function () { - Bus::fake(); - $r = $this->postJson("/api/webhook/{$this->tenant->webhook_token}", [ - 'vid' => 1, - 'project' => 'Окна', - 'phone' => '+7 (999) 000-00-00', - 'phones' => ['+7 (999) 000-00-01', '+7 (999) 000-00-02'], - 'time' => time(), - ]); - $r->assertStatus(202); - Bus::assertDispatched(ProcessWebhookJob::class, function ($job) { - return is_array($job->data['phones']) && count($job->data['phones']) === 2; - }); -}); - -test('HMAC: валидная подпись sha256=hex(hmac_sha256(body, token)) проходит', function () { - Bus::fake(); - $payload = [ - 'vid' => 1, - 'project' => 'X', - 'phone' => '+7 (999) 000-00-00', - 'time' => time(), - ]; - $rawBody = json_encode($payload); - $signature = 'sha256='.hash_hmac('sha256', $rawBody, $this->tenant->webhook_token); - - $r = $this->call('POST', "/api/webhook/{$this->tenant->webhook_token}", [], [], [], [ - 'CONTENT_TYPE' => 'application/json', - 'HTTP_ACCEPT' => 'application/json', - 'HTTP_X_WEBHOOK_SIGNATURE' => $signature, - ], $rawBody); - - $r->assertStatus(202); - Bus::assertDispatched(ProcessWebhookJob::class); -}); - -test('HMAC: невалидная подпись → 401, dispatch НЕ происходит', function () { - Bus::fake(); - $payload = [ - 'vid' => 1, - 'project' => 'X', - 'phone' => '+7 (999) 000-00-00', - 'time' => time(), - ]; - $rawBody = json_encode($payload); - - $r = $this->call('POST', "/api/webhook/{$this->tenant->webhook_token}", [], [], [], [ - 'CONTENT_TYPE' => 'application/json', - 'HTTP_ACCEPT' => 'application/json', - 'HTTP_X_WEBHOOK_SIGNATURE' => 'sha256=deadbeef'.str_repeat('0', 56), - ], $rawBody); - - $r->assertStatus(401); - expect($r->json('message'))->toContain('HMAC'); - Bus::assertNothingDispatched(); -}); - -test('HMAC: настройка отсутствует → HMAC обязателен по умолчанию (B3) → 401', function () { - Bus::fake(); - // Audit-fix B3: code-default isHmacRequired() = true. Удаляем настройку, - // чтобы проверить именно отсутствие ключа в system_settings. - SystemSetting::where('key', 'webhook_hmac_required')->delete(); - - $r = $this->postJson("/api/webhook/{$this->tenant->webhook_token}", [ - 'vid' => 1, - 'project' => 'X', - 'phone' => '+7 (999) 000-00-00', - 'time' => time(), - ]); - $r->assertStatus(401); - Bus::assertNothingDispatched(); -}); - -test('rate-limit: системный лимит RPS×60 в минуту, 429 + Retry-After на превышении', function () { - Bus::fake(); - // Устанавливаем низкий лимит через system_settings — иначе тест слишком долгий - // (default 100 RPS = 6000/мин). Подменяем через update. - SystemSetting::where('key', 'webhook_rate_limit_rps')->update(['value' => '1']); - - $payload = [ - 'vid' => 1, - 'project' => 'X', - 'phone' => '+7 (999) 000-00-00', - 'time' => time(), - ]; - - // 1 RPS × 60 = 60 запросов/мин. Делаем 60 успешных. - for ($i = 0; $i < 60; $i++) { - $r = $this->postJson("/api/webhook/{$this->tenant->webhook_token}", $payload); - $r->assertStatus(202); - } - - // 61-й — превышение. - $r = $this->postJson("/api/webhook/{$this->tenant->webhook_token}", $payload); - $r->assertStatus(429); - expect($r->json('retry_after'))->toBeInt()->toBeGreaterThan(0); - expect($r->headers->get('Retry-After'))->not->toBeNull(); -}); - -test('webhook_hmac_required=true: запрос без X-Webhook-Signature → 401', function () { - Bus::fake(); - SystemSetting::firstOrCreate( - ['key' => 'webhook_hmac_required'], - ['value' => 'true', 'type' => 'bool', 'description' => 'тест', 'updated_at' => now()], - ); - SystemSetting::where('key', 'webhook_hmac_required')->update(['value' => 'true']); - - $r = $this->postJson("/api/webhook/{$this->tenant->webhook_token}", [ - 'vid' => 1, - 'project' => 'X', - 'phone' => '+7 (999) 000-00-00', - 'time' => time(), - ]); - $r->assertStatus(401); - expect($r->json('message'))->toContain('требуется'); - Bus::assertNothingDispatched(); -}); - -test('webhook_hmac_required=true: с валидной HMAC-подписью → 202', function () { - Bus::fake(); - SystemSetting::firstOrCreate( - ['key' => 'webhook_hmac_required'], - ['value' => 'true', 'type' => 'bool', 'description' => 'тест', 'updated_at' => now()], - ); - SystemSetting::where('key', 'webhook_hmac_required')->update(['value' => 'true']); - - $payload = [ - 'vid' => 1, 'project' => 'X', 'phone' => '+7 (999) 000-00-00', 'time' => time(), - ]; - $rawBody = json_encode($payload); - $signature = 'sha256='.hash_hmac('sha256', $rawBody, $this->tenant->webhook_token); - - $r = $this->call('POST', "/api/webhook/{$this->tenant->webhook_token}", [], [], [], [ - 'CONTENT_TYPE' => 'application/json', - 'HTTP_ACCEPT' => 'application/json', - 'HTTP_X_WEBHOOK_SIGNATURE' => $signature, - ], $rawBody); - - $r->assertStatus(202); -}); - -test('webhook_hmac_required=false: header опционален → 202 без подписи', function () { - Bus::fake(); - SystemSetting::firstOrCreate( - ['key' => 'webhook_hmac_required'], - ['value' => 'false', 'type' => 'bool', 'description' => 'тест', 'updated_at' => now()], - ); - SystemSetting::where('key', 'webhook_hmac_required')->update(['value' => 'false']); - - $r = $this->postJson("/api/webhook/{$this->tenant->webhook_token}", [ - 'vid' => 1, 'project' => 'X', 'phone' => '+7 (999) 000-00-00', 'time' => time(), - ]); - $r->assertStatus(202); -}); - -test('rate-limit: ключ изолирован per-token (другой tenant не блокирует)', function () { - Bus::fake(); - SystemSetting::where('key', 'webhook_rate_limit_rps')->update(['value' => '1']); - - $tenantOther = Tenant::factory()->create([ - 'webhook_token' => 'whk_other_'.bin2hex(random_bytes(8)), - ]); - RateLimiter::clear("webhook:{$tenantOther->id}"); - - $payload = [ - 'vid' => 1, 'project' => 'X', 'phone' => '+7 (999) 000-00-00', 'time' => time(), - ]; - - // Заполняем лимит первого tenant'а - for ($i = 0; $i < 60; $i++) { - $this->postJson("/api/webhook/{$this->tenant->webhook_token}", $payload)->assertStatus(202); - } - $this->postJson("/api/webhook/{$this->tenant->webhook_token}", $payload)->assertStatus(429); - - // Второй tenant — без проблем. - $r = $this->postJson("/api/webhook/{$tenantOther->webhook_token}", $payload); - $r->assertStatus(202); -}); diff --git a/cspell-words.txt b/cspell-words.txt index 54260597..717575e9 100644 --- a/cspell-words.txt +++ b/cspell-words.txt @@ -1710,3 +1710,38 @@ FNS # Hole #2 partitioning (23.05.2026) партиционировать дёшева + +# Controller-offload agents spec (24.05.2026) +синков +эскалировать +эскалирует +митигации +Версионная +квирках +NTFS +маппинге +dogfooded + +# Controller-offload agents level 1+2 (24.05.2026) +бинари +ребейзе +dok +аддон +синкни +фейл +пинуются +маунт +pgrep +захардкоженной +ребейза +токену +тултип + +# Билинг v2 Спек C (25.05.2026) +Atol +uniqid +ОФД +брейнсторме +префлайт +Префлайт +матченным diff --git a/db/CHANGELOG_schema.md b/db/CHANGELOG_schema.md index 280b171e..aaebe976 100644 --- a/db/CHANGELOG_schema.md +++ b/db/CHANGELOG_schema.md @@ -1,10 +1,10 @@ # CHANGELOG schema.sql — Лидерра -**Назначение:** консолидированный журнал изменений `schema.sql`. Содержит записи в обратном хронологическом порядке (v8.35 → v8.34 → v8.33 → v8.32 → v8.31 → v8.30 → v8.29 → v8.28 → v8.27 → v8.26 → v8.25 → v8.24 → v8.23 → v8.22 → v8.21 → v8.20 → v8.19 → v8.18 → v8.17 → v8.16 → v8.15 → v8.14 → v8.13 → v8.12 → v8.11 → v8.10 → v8.9 → v8.8 → v8.7 → v8.6 → v8.5 → v8.4 → v8.3 → v8.2), как принято в keep-a-changelog. +**Назначение:** консолидированный журнал изменений `schema.sql`. Содержит записи в обратном хронологическом порядке (v8.36 → v8.35 → v8.34 → v8.33 → v8.32 → v8.31 → v8.30 → v8.29 → v8.28 → v8.27 → v8.26 → v8.25 → v8.24 → v8.23 → v8.22 → v8.21 → v8.20 → v8.19 → v8.18 → v8.17 → v8.16 → v8.15 → v8.14 → v8.13 → v8.12 → v8.11 → v8.10 → v8.9 → v8.8 → v8.7 → v8.6 → v8.5 → v8.4 → v8.3 → v8.2), как принято в keep-a-changelog. -**Файл схемы:** `schema.sql` (текущая версия — v8.35, консолидированная — разворачивает БД с нуля). +**Файл схемы:** `schema.sql` (текущая версия — v8.36, консолидированная — разворачивает БД с нуля). -## v8.35 (2026-05-24) — Billing v2 Spec C: флаги заморозки + balance_freeze_log +## v8.36 (2026-05-25) — Billing v2 Spec C: флаги заморозки + balance_freeze_log - **+колонка `tenants.frozen_by_balance_at TIMESTAMP NULL`** — флаг заморозки тенанта по балансу. Не NULL = баланс < стоимость дня лидов. Устанавливается `PreflightBalanceService`. - **+колонка `projects.preflight_blocked_at TIMESTAMP NULL`** — флаг точечной блокировки проекта по preflight-проверке. @@ -14,6 +14,39 @@ - **+индекс `balance_freeze_log_tenant_idx`** — по (tenant_id, created_at DESC). - Миграция: `2026_05_24_100000_add_balance_freeze_to_tenants_and_projects` - Метрики: +1 таблица, +3 индекса, +1 RLS-политика, +2 колонки. (Сверять с header `db/schema.sql`.) +- **NB:** номер версии v8.36 (поверх v8.35 legacy webhook removal) — ветка `feat/billing-v2-spec-c` ребейзнута через merge main 25.05.2026. + +## v8.35 (2026-05-24) — legacy direct webhook removal + +Финальная уборка прямого webhook-канала (тенант → Лидерра). Вся инфраструктура канала +упразднена; CSV-канал (поставщик → Лидерра) сохранён полностью. + +**Удалено:** + +- **Таблица `webhook_log`** (partitioned RANGE по `received_at`) + все дочерние партиции (DROP CASCADE). + Хранила payload входящих webhook от тенантов. Канал прямого приёма упразднён. +- **Таблица `rejected_deals_log`** (регулярная) — журнал отвергнутых лидов прямого webhook-канала. +- **Колонки `tenants.webhook_token` + `tenants.webhook_token_rotated_at`** — токен аутентификации + прямого webhook. Индекс `idx_tenants_webhook_token` удалён вместе с колонкой. +- **Seed-строка `low_balance_threshold_leads`** в `system_settings` — использовалась только + удалённым `LowBalanceNotification` mailable'ом. +- **Seed-строки `webhook_log_retention_days` + `webhook_log_retention_months`** в `system_settings`. + +**Оставлено (НЕ удалено):** + +- **`webhook_dedup_keys`** — используется CSV-каналом (`HistoricalImportService`) для идемпотентности. +- **`failed_webhook_jobs.webhook_log_id`** — orphan BIGINT (без FK с v8.31/W1); оставлен. +- **`outbound_webhook_subscriptions` + `outbound_webhook_deliveries`** — исходящий webhook (тенант → внешний URL); не затронут. + +**Метрики:** −2 таблицы / −5 индексов / −2 RLS-политики. +66 base tables (65 regular + 8 partitioned parents) / 120 indexes / 40 RLS policies. + +**Миграция:** `2026_05_24_140000_drop_legacy_webhook_artefacts` + +**Связанные изменения кода:** + +- `MonthlyPartitionManager::PARTITIONED_TABLES` — убрана строка `webhook_log` +- `PdErasureService::eraseSubject()` — убрана секция erasure по `webhook_log` ## v8.34 (2026-05-23) — Billing v2 Spec B: drop deals(duplicate_of_id) index diff --git a/db/schema.sql b/db/schema.sql index 655fc066..72368bc3 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1,13 +1,14 @@ -- ============================================================================= -- schema.sql — единая схема БД для SaaS-аналога crm.bp-gr.ru («Лидерра») --- Версия: v8.35 (24.05.2026 — Billing v2 Spec C: +balance_freeze_log / +tenants.frozen_by_balance_at / +projects.preflight_blocked_at) +-- Версия: v8.36 (25.05.2026 — Billing v2 Spec C: +balance_freeze_log / +tenants.frozen_by_balance_at / +projects.preflight_blocked_at) +-- Базовая версия: v8.35 (24.05.2026 — legacy direct webhook removal: DROP webhook_log (partitioned) + rejected_deals_log + tenants.webhook_token/webhook_token_rotated_at; webhook_dedup_keys сохранена (CSV-канал)) -- Базовая версия: v8.34 (23.05.2026 — Billing v2 Spec B: −индекс deals(duplicate_of_id) — телефонный дедуп удалён) --- Базовая версия: v8.31 (23.05.2026 — партиционирование 7 audit-таблиц помесячно (hole #2): auth_log / activity_log / tenant_operations_log / webhook_log / balance_transactions / pd_processing_log / saas_admin_audit_log; PK → (id, created_at|received_at); FK на webhook_log удалены (W1); retention defaults в system_settings) +-- Базовая версия: v8.31 (23.05.2026 — партиционирование 6 audit-таблиц помесячно (hole #2): auth_log / activity_log / tenant_operations_log / balance_transactions / pd_processing_log / saas_admin_audit_log; PK → (id, created_at|received_at); retention defaults в system_settings) -- Базовая версия: v8.30 (23.05.2026 — scheduler_heartbeats: пульс планировщика, SaaS-level без RLS, 11 cron-задач, hole #6) -- Базовая версия: v8.29 (22.05.2026 — webhook_log: supplier audit columns) -- Базовая версия: v8.28 (22.05.2026 — tenant_operations_log: журнал тенант-уровневых операций вне сделок (проекты, API-ключи, webhook URL), append-only hash-chain, P2 operational journaling closure) -- Базовая версия: v8.27 (21.05.2026 — drop projects.archived_at: feature архива заменена настоящим удалением с защитой по сделкам (ProjectService::delete())) --- Метрики: 76 базовые таблицы (67 regular + 9 partitioned parents: deals + supplier_lead_costs + 7 audit) + 12 партиций / 127 индексов / 43 RLS-политика / 5 функций / 15 триггеров +-- Метрики: 74 базовые таблицы (66 regular + 8 partitioned parents: deals + supplier_lead_costs + 6 audit) + 12 партиций / 123 индекса / 41 RLS-политика / 5 функций / 15 триггеров -- Базовая версия: v8.25 (19.05.2026 — supplier_manual_sync_queue: SaaS-level Tier 3 очередь резерва канала миграции проектов) -- Базовая версия: v8.24 (18.05.2026 — supplier_leads.vid → nullable для CSV-recovered лидов (Путь 2)) -- Базовая версия: v8.20 (11.05.2026 — Plan 5 frontend projects UI: projects.archived_at TIMESTAMPTZ NULL для soft archive flow; tenants.limits JSONB NOT NULL DEFAULT '{}' для per-tenant project/user лимитов) @@ -631,8 +632,7 @@ CREATE TABLE tenants ( contact_email VARCHAR(255) NOT NULL, status VARCHAR(20) DEFAULT 'active' CHECK (status IN ('active','suspended','pending_email_confirm','deleted')), - webhook_token VARCHAR(64) UNIQUE NOT NULL, - webhook_token_rotated_at TIMESTAMPTZ, + -- webhook_token / webhook_token_rotated_at удалены в v8.35 (legacy direct webhook removal) timezone VARCHAR(50) DEFAULT 'Europe/Moscow', locale VARCHAR(10) DEFAULT 'ru', -- Биллинг @@ -682,9 +682,9 @@ CREATE TABLE tenants ( ); CREATE INDEX idx_tenants_subdomain ON tenants(subdomain) WHERE deleted_at IS NULL; -CREATE INDEX idx_tenants_webhook_token ON tenants(webhook_token) WHERE deleted_at IS NULL AND status = 'active'; +-- idx_tenants_webhook_token удалён в v8.35 (legacy direct webhook removal) CREATE INDEX idx_tenants_inactive ON tenants(last_activity_at) WHERE deleted_at IS NULL; --- v8.35: частичный индекс для заморозки (sparse — большинство тенантов не заморожены) +-- v8.36: частичный индекс для заморозки (sparse — большинство тенантов не заморожены) CREATE INDEX tenants_frozen_by_balance_idx ON tenants (frozen_by_balance_at) WHERE frozen_by_balance_at IS NOT NULL; -- Forward FK на tenants для SaaS-админских таблиц, объявленных выше @@ -1426,7 +1426,7 @@ CREATE INDEX idx_outbound_subs_secret_prefix ON outbound_webhook_subscriptions -- ----------------------------------------------------------------------------- -- outbound_webhook_deliveries — журнал попыток доставки (v8.4) --- Retention 90 дней (как webhook_log). См. §19.10.6 (retry-логика 7 попыток +-- Retention 90 дней. См. §19.10.6 (retry-логика 7 попыток -- от 30 секунд до 24 часов). -- ----------------------------------------------------------------------------- CREATE TABLE outbound_webhook_deliveries ( @@ -1930,32 +1930,9 @@ COMMENT ON TABLE in_app_notifications IS 'read_at при клике. RLS: tenant isolation.'; --- ----------------------------------------------------------------------------- --- webhook_log — лог принятых webhook (раздел 5.7) --- РЕТЕНШН: system_settings.webhook_log_retention_days (по умолчанию 90 дней) --- ----------------------------------------------------------------------------- --- v8.31: партиционирована помесячно по received_at (hole #2). PK → (id, received_at). --- FK из failed_webhook_jobs/rejected_deals_log удалены (W1 — невозможны на составном PK --- партиционированной таблицы с единичным FK-столбцом). -CREATE TABLE webhook_log ( - id BIGSERIAL, - tenant_id BIGINT REFERENCES tenants(id) ON DELETE CASCADE, -- NULL для platform-level событий (supplier webhook) - raw_payload JSONB NOT NULL, -- содержит ПДн → удаляется при анонимизации - received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- v8.31: NOT NULL (partition key) - processed_at TIMESTAMPTZ, - deal_id BIGINT, -- БЕЗ FK (deals партиционирована) - error TEXT, - -- v8.29: supplier webhook audit columns - source VARCHAR(50), -- 'supplier' | 'tenant' - status VARCHAR(50), -- 'received' | 'rejected_secret' | 'rejected_ip' | 'rate_limited' - lead_id BIGINT, -- supplier_leads.id при статусе 'received' - ip_address INET, -- клиентский IP - created_at TIMESTAMPTZ DEFAULT NOW(), - PRIMARY KEY (id, received_at) -- v8.31: composite PK -) PARTITION BY RANGE (received_at); - -CREATE INDEX idx_webhook_log_tenant_received ON webhook_log(tenant_id, received_at DESC); -CREATE INDEX idx_webhook_log_status ON webhook_log(status, created_at DESC); +-- webhook_log удалена в v8.35 (legacy direct webhook removal). +-- Канал входящих webhook (прямой приём от тенантов) упразднён. +-- webhook_dedup_keys сохранена — используется CSV-каналом (HistoricalImportService). -- ----------------------------------------------------------------------------- @@ -1980,21 +1957,8 @@ CREATE INDEX idx_failed_webhook_unresolved ON failed_webhook_jobs(failed_at DESC CREATE INDEX idx_failed_webhook_jobs_log ON failed_webhook_jobs(webhook_log_id); -- v8.11 (audit O-perf-02) --- ----------------------------------------------------------------------------- --- rejected_deals_log — лог отвергнутых лидов при balance=0 (раздел 5.7) --- РЕТЕНШН: бессрочно (опционально 12 месяцев) --- ----------------------------------------------------------------------------- -CREATE TABLE rejected_deals_log ( - id BIGSERIAL PRIMARY KEY, - tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, - webhook_log_id BIGINT, -- v8.31: FK удалён (W1 — webhook_log партиционирована, composite PK несовместим с одиночным FK) - reason VARCHAR(50) NOT NULL, -- zero_balance, validation_failed, ... - payload JSONB, - created_at TIMESTAMPTZ DEFAULT NOW() -); - -CREATE INDEX idx_rejected_tenant_created ON rejected_deals_log(tenant_id, created_at DESC); -CREATE INDEX idx_rejected_deals_log_webhook ON rejected_deals_log(webhook_log_id); -- v8.11 (audit O-perf-03) +-- rejected_deals_log удалена в v8.35 (legacy direct webhook removal). +-- Rejection-журнал для прямого webhook-канала упразднён вместе с каналом. -- ----------------------------------------------------------------------------- @@ -2814,7 +2778,7 @@ VALUES INSERT INTO system_settings (key, value, type, description) VALUES ('schema_version', '8.3', 'string', 'Текущая версия схемы БД'), ('trial_bonus_leads', '50', 'int', 'Стартовый бонус лидов для нового тенанта (fallback для tariff_plans.trial_bonus_leads)'), - ('low_balance_threshold_leads', '10', 'int', 'Порог email-предупреждения о низком балансе'), + -- low_balance_threshold_leads удалён в v8.35 (использовался только LowBalanceNotification — удалена вместе с webhook-каналом) ('inactive_warn_months', '11', 'int', 'Через сколько месяцев простоя слать предупреждение'), ('inactive_delete_months', '12', 'int', 'Через сколько месяцев простоя удалять данные'), ('webhook_rate_limit_rps', '100', 'int', 'Лимит запросов в секунду на токен Webhook'), @@ -2822,7 +2786,7 @@ INSERT INTO system_settings (key, value, type, description) VALUES ('api_rate_limit_per_minute', '60', 'int', 'Лимит запросов API на ключ в минуту'), ('login_max_attempts', '5', 'int', 'Макс. неудачных попыток входа в окне 15 минут'), ('password_min_length', '10', 'int', 'Минимальная длина пароля'), - ('webhook_log_retention_days', '90', 'int', 'Сколько дней хранить raw_payload Webhook'), + -- webhook_log_retention_days удалён в v8.35 (webhook_log таблица удалена) -- VAPID (Web Push, раздел 17.4) ('vapid_public_key', '', 'string', 'VAPID public key (для подписки)'), ('vapid_private_key', '', 'string', 'VAPID private key (ШИФРОВАН) — заполнить при инсталляции'), @@ -2850,11 +2814,11 @@ INSERT INTO system_settings (key, value, type, description) VALUES -- v8.18 (Plan 2/5): supplier-webhook secret + IP allowlist для defense-in-depth. ('supplier_webhook_secret', '__SET_ON_DEPLOY__', 'string', 'Platform-wide секрет (≥32 chars) для /api/webhook/supplier/{secret}. См. spec §5.1.'), ('supplier_ip_allowlist', '[]', 'json', 'Список IP/CIDR поставщика crm.bp-gr.ru. Пустой массив = пропускать всех (DEV); на prod заполнить.'), - -- v8.31: retention для 7 audit-таблиц после partitioning (hole #2). Используется PartitionsDropExpired (cron Sundays 03:00 МСК). + -- v8.31: retention для audit-таблиц после partitioning (hole #2). Используется PartitionsDropExpired (cron Sundays 03:00 МСК). + -- webhook_log_retention_months удалён в v8.35 (webhook_log таблица удалена). ('auth_log_retention_months', '24', 'int', 'Retention auth_log в месяцах (hole #2)'), ('activity_log_retention_months', '36', 'int', 'Retention activity_log (hole #2)'), ('tenant_operations_log_retention_months', '24', 'int', 'Retention tenant_operations_log (hole #2)'), - ('webhook_log_retention_months', '3', 'int', 'Retention webhook_log (hole #2)'), ('balance_transactions_retention_months', '84', 'int', 'Retention balance_transactions, 7л НК РФ (hole #2)'), ('pd_processing_log_retention_months', '36', 'int', 'Retention pd_processing_log, 152-ФЗ 3 года (hole #2)'), ('saas_admin_audit_log_retention_months', '84', 'int', 'Retention saas_admin_audit_log, 7л (hole #2)'); @@ -2927,9 +2891,8 @@ ALTER TABLE import_unknown_statuses ENABLE ROW LEVEL SECURITY; ALTER TABLE activity_log ENABLE ROW LEVEL SECURITY; ALTER TABLE tenant_operations_log ENABLE ROW LEVEL SECURITY; -- v8.31: перенесено сюда (была inline) ALTER TABLE reminders ENABLE ROW LEVEL SECURITY; -ALTER TABLE webhook_log ENABLE ROW LEVEL SECURITY; +-- webhook_log / rejected_deals_log: таблицы удалены в v8.35 ALTER TABLE failed_webhook_jobs ENABLE ROW LEVEL SECURITY; -ALTER TABLE rejected_deals_log ENABLE ROW LEVEL SECURITY; ALTER TABLE tariff_subscriptions ENABLE ROW LEVEL SECURITY; ALTER TABLE saas_invoices ENABLE ROW LEVEL SECURITY; ALTER TABLE saas_invoice_items ENABLE ROW LEVEL SECURITY; -- через invoice_id косвенно (см. политику ниже) @@ -2969,9 +2932,8 @@ CREATE POLICY tenant_isolation ON import_unknown_statuses USING (tenant_id = cur CREATE POLICY tenant_isolation ON activity_log USING (tenant_id = current_setting('app.current_tenant_id')::bigint); CREATE POLICY tenant_isolation ON tenant_operations_log USING (tenant_id = current_setting('app.current_tenant_id')::bigint); -- v8.31: перенесено из inline CREATE POLICY tenant_isolation ON reminders USING (tenant_id = current_setting('app.current_tenant_id')::bigint); -CREATE POLICY tenant_isolation ON webhook_log USING (tenant_id = current_setting('app.current_tenant_id')::bigint); +-- webhook_log / rejected_deals_log policies удалены в v8.35 (таблицы удалены) CREATE POLICY tenant_isolation ON failed_webhook_jobs USING (tenant_id = current_setting('app.current_tenant_id')::bigint); -CREATE POLICY tenant_isolation ON rejected_deals_log USING (tenant_id = current_setting('app.current_tenant_id')::bigint); CREATE POLICY tenant_isolation ON tariff_subscriptions USING (tenant_id = current_setting('app.current_tenant_id')::bigint); CREATE POLICY tenant_isolation ON saas_invoices USING (tenant_id = current_setting('app.current_tenant_id')::bigint); CREATE POLICY tenant_isolation ON saas_upd_documents USING (tenant_id = current_setting('app.current_tenant_id')::bigint); diff --git a/docs/Pravila_raboty_Claude_v1_1.md b/docs/Pravila_raboty_Claude_v1_1.md index e0a0974c..6982cb30 100644 --- a/docs/Pravila_raboty_Claude_v1_1.md +++ b/docs/Pravila_raboty_Claude_v1_1.md @@ -1,10 +1,12 @@ # Правила работы Claude в проекте «Лидерра» -**Версия:** v1.39 (22.05.2026) -**Дата:** 22.05.2026 +**Версия:** v1.40 (24.05.2026) +**Дата:** 24.05.2026 **Назначение:** настройки проекта (Project instructions) — Claude читает этот файл в каждом чате и следует правилам ниже. **Статус документа:** ✅ утверждён. Содержимое скопировано в поле "Project instructions" Claude.ai. Файл хранится в архиве как служебный документ. +**Что изменилось в v1.40 относительно v1.39:** Делегирование проектным AI-агентам — §2.4 (новая подсекция) описывает обязанность контроллера передавать класс задач 4 узко-специализированным агентам в `.claude/agents/`: `normative-sync` (#84, синк 4 нормативных файлов после крупной задачи), `prod-deploy-validator` (#85, 8 SSH pre-flight перед выкатом на liderra.ru), плюс прежние `pest-parallel-debugger` и `rls-reviewer`. Project-агенты регистрируются в `docs/registry/nodes.yaml` (subcategory `project-agent`) для missed-activation детектора, но **не входят в Tooling канон счётчиков** #1-#83 (footer-числа не двигаются). Архитектурных изменений §§1, §3–§16: 0. Связано: CLAUDE.md v2.28+ (§3.9), spec `docs/superpowers/specs/2026-05-24-controller-offload-agents-design.md`, agent files `.claude/agents/{normative-sync,prod-deploy-validator}.md`. + **Что изменилось в v1.39 относительно v1.38:** C1 marketing-tooling — §13.2 +абзац «Off-phase marketing-tooling»: #74 marketing (Anthropic, первичный решатель C1), #75 marketingskills (вендорен MIT, материал/резерв), #76 brand-voice (Anthropic, вербальный бренд), #77 marketing-ru (self-authored project-скил, РФ-специфика + 152-ФЗ маркетинг), #78 Яндекс.Метрика MCP (READ-ONLY), #79 Яндекс.Директ+Wordstat MCP (**Wordstat-only**, Direct-мутации отключены per IS9), #80 Telegram MCP, #81 Postiz (self-host, AGPL-3.0 internal), #82 DataForSEO (**DEFERRED**, pending Б-1/бюджет), #83 Unisender Go (**DEFERRED**, pending согласования + 152-ФЗ). 18-я off-phase подкатегория, раздел C1. Не UI → вне R6.0/R6.1/R14. Границы — ADR-015. Счётчики — канон Tooling §0. Архитектурных изменений §§1–12, §14–§16: 0. Связано: Tooling v2.23+, PSR_v1 v3.22+, CLAUDE.md v2.27+; план `docs/superpowers/plans/2026-05-22-c1-marketing-tooling.md`. **Что изменилось в v1.38 относительно v1.37:** A8 infosec install-sync — ZAP #68 + Ward #70 установлены портативно 21.05.2026 (без choco, по выбору заказчика «оба портативно») → в §13.2 абзаце «Off-phase infosec-tooling» статус **PENDING INSTALL снят** для обоих (ZAP: ZAP 2.17.0 + MCP-аддон на portable Temurin JRE 17; Ward: собран portable Go → `bin/ward.exe` v0.4.1); setup-доки `docs/security/{zap,ward}-setup.md`. Архитектурных изменений §§1–16: 0. Связано: Tooling v2.21, PSR_v1 v3.21, CLAUDE.md v2.25; план `docs/superpowers/plans/2026-05-21-a8-infosec-tooling.md`. @@ -238,6 +240,27 @@ Claude — **системный архитектор-документалист* - Не продолжает работу при обнаружении противоречия между файлами архива «молча» — всегда сообщает заказчику. - Не загружает в контекст всю документацию v8.0+ целиком — обращается по разделам через `project_knowledge_search`. +### 2.4. Делегирование проектным AI-агентам (v1.40) + +В `.claude/agents/` лежат проектные AI-агенты (Sonnet 4.6, узко-специализированные). Контроллер ОБЯЗАН делегировать им следующие классы задач: + +| Условие | Агент | Brief-фраза заказчика-триггер | +|---------|-------|-------------------------------| +| Закрытие крупной off-phase интеграции / brain governance артефакта / принятого ADR — синк 4 нормативных файлов (Pravila / PSR_v1 / Tooling / CLAUDE.md) | `normative-sync` (#84, [agent file](../.claude/agents/normative-sync.md), [spec §3](superpowers/specs/2026-05-24-controller-offload-agents-design.md)) | «синкни нормативку», «закрыли X — нужен синк» | +| Любой выкат на боевой `liderra.ru` (push с миграциями, scp-патч, ручной деплой) — 8 pre-flight SSH проверок | `prod-deploy-validator` (#85, [agent file](../.claude/agents/prod-deploy-validator.md), [spec §4](superpowers/specs/2026-05-24-controller-offload-agents-design.md)) | «проверь готовность боевого», «ready to deploy» | +| Диагностика Pest 4 `--parallel` failures (квирки 72/73/77) | `pest-parallel-debugger` ([agent file](../.claude/agents/pest-parallel-debugger.md)) | «pest упал на parallel», «классифицируй фейл» | +| RLS-compliance review при правке `db/schema.sql` или `db/migrations/` | `rls-reviewer` ([agent file](../.claude/agents/rls-reviewer.md)) | «проверь RLS на миграции», «RLS review» | + +**Дисциплина делегирования:** + +- Контроллер вызывает агента **по триггеру**, не дожидаясь явного запроса заказчика — для `normative-sync` сразу после крупной задачи; для `prod-deploy-validator` обязательно ДО любых SSH-команд на боевой. +- Заказчик может явно отменить вызов («не зови сейчас X»). Live-отмена — только на текущее действие. +- Если агент возвращает эскалацию — контроллер передаёт её заказчику без угадывания, не пытается обойти. +- Project-агенты **не входят в Tooling Прил. Н канон счётчиков** (#1-#83 — реестр инструментов; project-агенты идут #84+ в `docs/registry/nodes.yaml` с подкатегорией `project-agent`). Footer-счётчики «N формализованных» изменяются только при добавлении в Tooling-канон, не при добавлении project-агента. +- Полный perimeter / триггеры / границы — в `description`-frontmatter каждого агент-файла. Это первичный источник «когда звать»; настоящая таблица — индекс контракта, не дублирующая декларация. + +**Naming-convention:** `agent_file: ".claude/agents/.md"` атрибут в `docs/registry/nodes.yaml` маркирует узел как project-агент (отличает от Tooling-инструментов, у которых `tooling_section` атрибут). + --- ## 3. Формат ответов и работы с файлами diff --git a/docs/observer/.pii-counters.json b/docs/observer/.pii-counters.json new file mode 100644 index 00000000..640c88b6 --- /dev/null +++ b/docs/observer/.pii-counters.json @@ -0,0 +1,5 @@ +{ + "2026-05": { + "WIN_USER_PATH": 6 + } +} diff --git a/docs/observer/STATUS.md b/docs/observer/STATUS.md index 0bfdc3ef..3b750ab5 100644 --- a/docs/observer/STATUS.md +++ b/docs/observer/STATUS.md @@ -1,6 +1,6 @@ # Brain Status (auto-generated) -Last updated: 2026-05-23T16:38:59.719Z +Last updated: 2026-05-24T13:01:24.122Z | Контролёр | Состояние | Детали | |---|---|---| @@ -8,23 +8,41 @@ Last updated: 2026-05-23T16:38:59.719Z | C2 Cross-ref consistency | ✅ | [cross-ref-checker] OK — 0 drift in 4 files | | C3 Observer-of-observer | ✅ | [observer-of-observer] OK — last read 0 week(s) ago | | C4 Сигнальный статус | ✅ | This file (self-reference) | -| C5 Observer-coverage | ⚠️ | 165 episode(s) this month · Stop-hook + post-commit OK · 21 missed activation(s) — see /brain-retro | +| C5 Observer-coverage | ⚠️ | 135 episode(s) this month · .git/hooks/post-commit not installed (run: npx lefthook install --force) · 17 missed activation(s) — see /brain-retro | | C6 Chain map sync | ✅ | [chain-map-checker] OK — 16 chains in sync | ## Метрики (информационные, не алерты) -- Observer evidence: 165 episodes this month, 0 observer_error markers, 83 PII matches before filter -- Legacy v1 episodes (not in factor analysis): 26 -- Last /brain-retro: 0 day(s) ago -- Использование узлов: см. `/brain-retro` (раз в спринт). missed_activations: 21. **Неиспользованные узлы — не алерт, если профильной задачи не было** (Pravila §16.4 v1.36; capability-readiness; см. memory `feedback_brain_unused_tools_not_problem` — outside-repo memory store). +- Observer evidence: 135 episodes this month, 0 observer_error markers, 6 PII matches before filter +- Legacy v1 episodes (not in factor analysis): 11 +- Last /brain-retro: 1 day(s) ago +- Использование узлов: см. `/brain-retro` (раз в спринт). missed_activations: 17. **Неиспользованные узлы — не алерт, если профильной задачи не было** (Pravila §16.4 v1.36; capability-readiness; см. memory `feedback_brain_unused_tools_not_problem` — outside-repo memory store). + +## Метрики дисциплины + +Baseline дисциплины роутера (этап 2 router discipline overhaul, spec 2026-05-23). Цель — увидеть «точку До» перед enforcement-хуком этапа 3. + +| Тип задачи | Эпизодов | % с триггер-матчем | % через скил | +|---|---|---|---| +| bugfix | 7 | 28.6% | 42.9% | +| feature | 5 | 0.0% | 0.0% | +| analysis | 4 | 0.0% | 25.0% | +| planning | 2 | 0.0% | 0.0% | +| refactor | 1 | 0.0% | 0.0% | +| cleanup | 1 | 0.0% | 0.0% | +| monitoring | 1 | 0.0% | 0.0% | + +Router step distribution: 1: 55, 2: 45, 3: 12, 5: 18 + +Boundaries applied (ADR / границы): 13 of 130 эпизодов (10.0%). ## Активные многоэтапные проекты - **Router discipline overhaul** ([spec](../superpowers/specs/2026-05-23-router-discipline-overhaul-design.md)) - Этап 1 (машиночитаемый реестр) ✅ закрыт 2026-05-23 — `docs/registry/nodes.yaml` (83 узла + 16 chains L1-L16), `tools/registry-load.mjs` + `tools/registry-render.mjs` (16 тестов), auto-render Tooling §4.0 + routing-off-phase, lefthook job 17 (warn-only). - - Этап 2 (измерения + классификатор-парсер) ⏸ ждёт «продолжаем» от заказчика. Plan: TBD. - - Этап 3 (принуждение — хук на routing) — не начат. - - Этап 4 (уборка правил) — не начат. + - Этап 2 (измерения + классификатор-парсер) ✅ закрыт 2026-05-24 + влит в main 2026-05-24 — discipline-metrics (3 среза), brain-retro-analyzer переключён на реестр, STATUS.md блок «Метрики дисциплины», baseline snapshot `docs/observer/baselines/2026-05-24-pre-enforcement.md`. Plan: `docs/superpowers/plans/2026-05-24-router-overhaul-stage-2-measurements.md`. + - Этап 3 (принуждение — хук на routing) — Phase A+B (классификатор + 3 хука: router-prehook/tool-gate/stop-gate в `.claude/settings.json`) ✅ + влит в main 2026-05-24. Гейт работает в режиме **`warn-only`** (только stderr-предупреждения, никакой блокировки). Bug-fix `bec69aa5`: `deriveRouterStep` в `tools/discipline-metrics.mjs` — шаг роутера теперь выводится из наблюдаемых признаков (был захардкоженной константой 1). **Follow-up 3 fixes 2026-05-24** (после ANTHROPIC_API_KEY + рестарта CC выявлены при инспекции state): (a) UTF-8 stdin helper `tools/router-stdin-helper.mjs` через `StringDecoder` + подключение к 3 хукам (русский в state-файл и Anthropic API без mojibake); (b) `tools/observer-state-enricher.mjs` — pure helper для чтения `router-state-.json`; (c) `parseTranscript` обогащение `primary_rationale` 4 полями (`recommended_node` override + `recommended_chain` + `chain_progress` + `chain_completed`). 538 tools-тестов GREEN. Plan: `docs/superpowers/plans/2026-05-24-router-stage3-three-fixes.md`. CHECKPOINT B: дать warn-only накопить реальные наблюдения с **починенным** сторожем (план говорит «минимум 24 часа»), затем Task 9 — переключение в `enforce` + 2 новых метрики (domain-hit-rate / chain-completion). Plan: `docs/superpowers/plans/2026-05-24-router-overhaul-stage-3-enforcement.md`. + - Этап 4 (уборка устаревших правил, deprecation `observer-classification-map.json` → удаление) — не начат. ## Алерт-индикаторы diff --git a/docs/observer/active-projects.md b/docs/observer/active-projects.md index 462bb648..adb0b3c3 100644 --- a/docs/observer/active-projects.md +++ b/docs/observer/active-projects.md @@ -1,5 +1,5 @@ - **Router discipline overhaul** ([spec](../superpowers/specs/2026-05-23-router-discipline-overhaul-design.md)) - Этап 1 (машиночитаемый реестр) ✅ закрыт 2026-05-23 — `docs/registry/nodes.yaml` (83 узла + 16 chains L1-L16), `tools/registry-load.mjs` + `tools/registry-render.mjs` (16 тестов), auto-render Tooling §4.0 + routing-off-phase, lefthook job 17 (warn-only). - - Этап 2 (измерения + классификатор-парсер) ⏸ ждёт «продолжаем» от заказчика. Plan: TBD. - - Этап 3 (принуждение — хук на routing) — не начат. - - Этап 4 (уборка правил) — не начат. + - Этап 2 (измерения + классификатор-парсер) ✅ закрыт 2026-05-24 + влит в main 2026-05-24 — discipline-metrics (3 среза), brain-retro-analyzer переключён на реестр, STATUS.md блок «Метрики дисциплины», baseline snapshot `docs/observer/baselines/2026-05-24-pre-enforcement.md`. Plan: `docs/superpowers/plans/2026-05-24-router-overhaul-stage-2-measurements.md`. + - Этап 3 (принуждение — хук на routing) — Phase A+B (классификатор + 3 хука: router-prehook/tool-gate/stop-gate в `.claude/settings.json`) ✅ + влит в main 2026-05-24. Гейт работает в режиме **`warn-only`** (только stderr-предупреждения, никакой блокировки). Bug-fix `bec69aa5`: `deriveRouterStep` в `tools/discipline-metrics.mjs` — шаг роутера теперь выводится из наблюдаемых признаков (был захардкоженной константой 1). **Follow-up 3 fixes 2026-05-24** (после ANTHROPIC_API_KEY + рестарта CC выявлены при инспекции state): (a) UTF-8 stdin helper `tools/router-stdin-helper.mjs` через `StringDecoder` + подключение к 3 хукам (русский в state-файл и Anthropic API без mojibake); (b) `tools/observer-state-enricher.mjs` — pure helper для чтения `router-state-.json`; (c) `parseTranscript` обогащение `primary_rationale` 4 полями (`recommended_node` override + `recommended_chain` + `chain_progress` + `chain_completed`). 538 tools-тестов GREEN. Plan: `docs/superpowers/plans/2026-05-24-router-stage3-three-fixes.md`. CHECKPOINT B: дать warn-only накопить реальные наблюдения с **починенным** сторожем (план говорит «минимум 24 часа»), затем Task 9 — переключение в `enforce` + 2 новых метрики (domain-hit-rate / chain-completion). Plan: `docs/superpowers/plans/2026-05-24-router-overhaul-stage-3-enforcement.md`. + - Этап 4 (уборка устаревших правил, deprecation `observer-classification-map.json` → удаление) — не начат. diff --git a/docs/observer/baselines/2026-05-24-pre-enforcement.md b/docs/observer/baselines/2026-05-24-pre-enforcement.md new file mode 100644 index 00000000..0bdd699d --- /dev/null +++ b/docs/observer/baselines/2026-05-24-pre-enforcement.md @@ -0,0 +1,98 @@ +# Baseline дисциплины роутера — pre-enforcement snapshot + +**Дата:** 2026-05-24 +**Источник данных:** `docs/observer/episodes-2026-05.jsonl` +**Этап:** Router discipline overhaul, Stage 2 (Measurements). Зафиксирован для сравнения с пост-enforcement цифрами этапа 3. +**Spec:** `docs/superpowers/specs/2026-05-23-router-discipline-overhaul-design.md` +**Plan:** `docs/superpowers/plans/2026-05-24-router-overhaul-stage-2-measurements.md` +**Commit:** e239160a (snapshot creation) → 436284c5 (F1 top-5 nodes fix) + +## Объём данных + +- Эпизодов всего: 129 (124 v2+ + 5 v1) +- v2+ эпизодов (анализируется): 124 +- v1 эпизодов пропущено: 5 +- Observer-error маркеров: 0 + +## Цифры + +### Дисциплина по типам задач + +| Тип задачи | Эпизодов | % с триггер-матчем | % через скил | +|---|---|---|---| +| bugfix | 6 | 33.3% | 33.3% | +| analysis | 4 | 0% | 25.0% | +| feature | 5 | 0% | 0% | +| planning | 2 | 0% | 0% | +| refactor | 1 | 0% | 0% | +| cleanup | 1 | 0% | 0% | +| monitoring | 1 | 0% | 0% | + +### Распределение по шагам роутера + +- distribution: `{"1": 124}` +- total: 124 +- **suspicious: true** — >90% эпизодов остановились на step=1; sentinel-bug парсера, требует исследования в этапе 3 + +### Применение границ (ADR) + +- Total: 124 +- With boundaries: 13 +- Rate: 10.5% +- By path_type: + - `improvised`: 112 эпизодов, 11 с boundaries, 9.8% + - `regulated`: 12 эпизодов, 2 с boundaries, 16.7% + +### Missed activations + +- Total: 17 + +By classification: + +```json +{ + "bugfix": 4, + "feature": 5, + "refactor": 1, + "planning": 2, + "cleanup": 1, + "monitoring": 1, + "analysis": 3 +} +``` + +By node (top 5 по количеству): + +```json +{ + "#19": 12, + "#34": 5, + "#18": 4, + "#25": 3, + "#39": 3 +} +``` + +*(#53 также имеет count 3, следующие: #11:#12:#41:#42 = 2)* + +## Контекст + +Это «точка До» перед включением enforcement-хука этапа 3. После недели работы хука повторно снимем эти цифры и сравним. + +**Цели overhaul'а (из spec'а §acceptance criteria):** + +- Дисциплина (% эпизодов с матченным триггером на классифицированных задачах): **≥75%** (baseline зафиксирован выше — сейчас 33.3% лишь у bugfix, остальные 0%). +- Missed activations: **≤5/неделю** (baseline: 17 за месяц). +- % feature/planning без skill: **≤10%** (baseline: feature 0%, planning 0% — обе категории нарушают цель). + +## Заметка о suspicious-флаге + +`suspicious: true` в `routerStep` указывает, что **все 124 v2+ эпизода имеют `step=1`**. Это означает, что парсер `tools/observer-transcript-parser.mjs` пока не enrich'ит фактический шаг роутера — поле `primary_rationale.step` сейчас постоянно `1` (sentinel default). Этот пропуск самой инструментовки наблюдателя — отдельный задел для этапа 3 (нужно либо расширить парсер, чтобы он различал шаги, либо явно вычислять step из контекста). До этого срез по router_step **не информативен**. + +## Воспроизводимость + +```bash +node tools/brain-retro-analyzer.mjs docs/observer/episodes-2026-05.jsonl +``` + +Источник classificationMap + dormancy — `docs/registry/nodes.yaml` (через `tools/registry-to-classification-map.mjs`). diff --git a/docs/observer/episodes-2026-05.jsonl b/docs/observer/episodes-2026-05.jsonl index 3d430470..5f19a423 100644 --- a/docs/observer/episodes-2026-05.jsonl +++ b/docs/observer/episodes-2026-05.jsonl @@ -127,3 +127,9 @@ {"schema_version":2,"task_id":"5b6651ff-f331-4c8e-bea5-52cabad98d25","task_ref":"5b6651ff-f331-4c8e-bea5-52cabad98d25","timestamps":{"started_at":"2026-05-23T09:01:32.327Z","ended_at":"2026-05-23T09:07:38.033Z"},"path_type":"improvised","outcome":"unknown","prompt_signal":"neutral","decision_provenance":{"kind":"autonomous","claude_would_have_chosen":null},"environment":{"economy_level":100,"model":"claude-opus-4-7","post_compaction":false,"session_turn":9,"parallel_session":true},"task_size":{"tool_calls":20,"files_touched":3,"files":["c:\\моя\\проекты\\портал crm\\Документация\\ПИЛОТ.md","C:\\Users\\***\\.claude\\projects\\c---------------------crm-------------\\memory\\project_billing_v2.md","C:\\Users\\***\\.claude\\projects\\c---------------------crm-------------\\memory\\MEMORY.md"]},"task_cost":{"input_tokens":57,"output_tokens":39150,"cache_read_input_tokens":16690884,"cache_creation_input_tokens":79466,"web_search_requests":0,"web_fetch_requests":0,"iterations":"0[object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object]"},"primary_rationale":{"step":1,"node_chosen":"direct","chain_ref":null,"triggers_matched":[],"candidates_considered":["✅ Пуш — `527f628a..596371e9` на `main`. До этого `c76038d0` уже включал мой spec+plan через параллельную push'ку.","✅ Бэкап-ветка `feat/billing-v2-spec-a` на GitHub (стартовая точка `c76038d0` для свежей сессии).","✅ ПИЛОТ.md обновлён — snapshot prefix про Биллинг v2 + §6 п.9 (pending Phase A merge).","✅ Память:","Новый топик-файл [memory/project_billing_v2.md](C:\\Users\\***\\.claude\\projects\\c---------------------crm-------------\\memory\\project_billing_v2.md) — все решения брейнсторма, артефакты, статус 3 спеков.","MEMORY.md — bump origin/main HEAD до `c76038d0` + индекс-строка для project_billing_v2."],"boundaries_applied":[],"hard_floor":{"invoked":false,"rules":[]},"task_classification":"other"},"events":[{"kind":"tool_summary","counts":{"Bash":8,"Read":3,"Edit":7,"Write":1,"TodoWrite":1}},{"kind":"hook_fired","counts":{"PreToolUse:Bash":8,"PostToolUse:Bash":8,"PreToolUse:Read":3,"PostToolUse:Read":3,"PreToolUse:Edit":7,"PostToolUse:Edit":7,"PreToolUse:Write":1,"PostToolUse:Write":1,"PreToolUse:TodoWrite":1,"PostToolUse:TodoWrite":1},"errors":0}]} {"schema_version":2,"task_id":"8409f21e-2d54-48b6-8cff-c0fa5e32ba1b","task_ref":"8409f21e-2d54-48b6-8cff-c0fa5e32ba1b","timestamps":{"started_at":"2026-05-23T09:05:04.562Z","ended_at":"2026-05-23T09:13:07.148Z"},"path_type":"improvised","outcome":"unknown","prompt_signal":"new_task","decision_provenance":{"kind":"user_chose_from_options","node":"Коммитить применённые правки (4 файла + retro-отчёт)?","options_offered":["Коммитить применённые правки (4 файла + retro-отчёт)?","Сделать A3 (сузить классификатор для «своих» правок)?","Или достаточно — закрываем?"],"claude_would_have_chosen":"Коммитить применённые правки (4 файла + retro-отчёт)?"},"environment":{"economy_level":100,"model":"claude-opus-4-7","post_compaction":false,"session_turn":5,"parallel_session":false},"task_size":{"tool_calls":29,"files_touched":4,"files":["C:\\Users\\***\\.claude\\projects\\c---------------------crm-------------\\memory\\project_state.md","C:\\Users\\***\\.claude\\projects\\c---------------------crm-------------\\memory\\reference_github.md","C:\\Users\\***\\.claude\\projects\\c---------------------crm-------------\\memory\\project_observer_pii_leak_2026-05-23.md","C:\\Users\\***\\.claude\\projects\\c---------------------crm-------------\\memory\\MEMORY.md"]},"task_cost":{"input_tokens":131,"output_tokens":45552,"cache_read_input_tokens":14907411,"cache_creation_input_tokens":130866,"web_search_requests":0,"web_fetch_requests":0,"iterations":"0[object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object]"},"primary_rationale":{"step":1,"node_chosen":"direct","chain_ref":null,"triggers_matched":[],"candidates_considered":["Лог снят со staging — НЕ запушен на main (PII остался только в локальном working copy)","Зафиксировано в commit message и в новой memory-записи как открытая задача **DO-PII-1**","Расширить регулярку в `tools/observer-pii-filter.mjs` на паттерн `7[0-9]{10}` голый","Перегенерировать чистый эпизодный лог и коммитнуть"],"boundaries_applied":[],"hard_floor":{"invoked":false,"rules":[]},"task_classification":"memory-sync"},"events":[{"kind":"tool_summary","counts":{"Bash":21,"Read":3,"Edit":4,"Write":1}},{"kind":"error","tool":"Edit","summary":"File has been modified since read, either by the user or by a li"},{"kind":"hook_fired","counts":{"PreToolUse:Bash":21,"PostToolUse:Bash":21,"PreToolUse:Read":3,"PostToolUse:Read":3,"PreToolUse:Edit":6,"PostToolUse:Edit":3,"PreToolUse:Write":2,"PostToolUse:Write":1},"errors":0},{"kind":"retry"}]} {"schema_version":2,"task_id":"074bda0e-d16e-481a-8230-97662ead7ec5","task_ref":"074bda0e-d16e-481a-8230-97662ead7ec5","timestamps":{"started_at":"2026-05-23T09:22:30.457Z","ended_at":"2026-05-23T09:26:03.542Z"},"path_type":"improvised","outcome":"unknown","prompt_signal":"new_task","decision_provenance":{"kind":"autonomous","claude_would_have_chosen":null},"environment":{"economy_level":100,"model":"claude-opus-4-7","post_compaction":false,"session_turn":2,"parallel_session":false},"task_size":{"tool_calls":11,"files_touched":3,"files":["c:\\моя\\проекты\\портал crm\\Документация\\docs\\observer\\README.md","c:\\моя\\проекты\\портал crm\\Документация\\docs\\observer\\STATUS.md","c:\\моя\\проекты\\портал crm\\Документация\\docs\\observer\\notes\\2026-05-23-brain-retro.md"]},"task_cost":{"input_tokens":31,"output_tokens":11197,"cache_read_input_tokens":2214304,"cache_creation_input_tokens":388923,"web_search_requests":0,"web_fetch_requests":0,"iterations":"0[object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object]"},"primary_rationale":{"step":1,"node_chosen":"direct","chain_ref":null,"triggers_matched":[],"candidates_considered":["**Дисциплина роутинга** — `path_type` regulated vs improvised (13.8% vs 81.9% — рост в 5× по сравнению с прошлым ретро).","**Кто принимает решения** — autonomous (74%) / collaborative A/B/C-выбор (24%) / навязано заказчиком (2%). Здоровый паттерн: заказчик НЕ навязывает методы.","**Missed activations** — есть профильная задача, есть профильный узел, узел не использован. Сейчас 40 (после очистки шума маппинга упадёт до ~15).","**Цепочки причин** — общие файлы между эпизодами с разрывом ≥5 мин (например, `SyncSupplierProjectJob` 20→22.05 = retry-storm fix).","**Skill-покрытие L1–L15 канонических связок** — какие из 15 цепочек реально использовались.","**Outcome inference** — детерминированный вывод `success`/`soft_success`/`blocked`/`unknown` из событий+сигнала.","**Tool-mix по фазам/типам** — на 128 эпизодов: Bash 917 / Edit 320 / Read 320 / Playwright (snapshot 45 + click 50 + navigate 49) / Agent 25 / Skill 19. Видно, что Playwright стал нормой для верификации.","**Error/retry density** — 142 error / 124 retry / 17 time_burn на 128 эпизодов. По каким классификациям/моделям/тулзам они скапливаются (сейчас спринты A8 install и audit journaling генерируют большинство).","**Economy-level × outcome** — корреляция режима экономии (0/5/100) с blocked-эпизодами (только 100=108 / 5=6 / 0=2 — выборка для «0» слишком мала, но один из двух blocked).","**Post-compaction × качество** — 43 эпизода после компакции, ухудшается ли исход.","**Время хода** — `ended_at - started_at` по типам задач (сейчас не агрегируется).","**Subagent-делегирование** — 25 `subagent_invoked` событий, можно посмотреть какие классификации делегируются и с каким outcome.","**PII-фильтр trend** — `.pii-counters.json` показал 58 матчей в мае; рост/спад числа — индикатор риска утечки (см. сегодняшний RU-phone leak DO-PII-1).","**Hook-эффективность** — 100 срабатываний хуков (имена сейчас в `'?'`, парсер не разворачивает — это улучшение парсера)."],"boundaries_applied":[],"hard_floor":{"invoked":false,"rules":[]},"task_classification":"analysis"},"events":[{"kind":"tool_summary","counts":{"Bash":7,"Read":4}},{"kind":"error","tool":"Bash","summary":"Exit code 1\nTraceback (most recent call last):\r\n File \"\", line 36, in <"},{"kind":"hook_fired","counts":{"PreToolUse:Bash":7,"PostToolUse:Bash":6,"PreToolUse:Read":4,"PostToolUse:Read":4},"errors":0},{"kind":"retry"}]} +{"schema_version":3,"task_id":"1ff47cd9-c1e0-4277-ad1b-1f7754f0569a","task_ref":"1ff47cd9-c1e0-4277-ad1b-1f7754f0569a","timestamps":{"started_at":"2026-05-24T03:24:32.613Z","ended_at":"2026-05-24T04:33:17.315Z"},"path_type":"regulated","outcome":"unknown","prompt_signal":"new_task","decision_provenance":{"kind":"autonomous","claude_would_have_chosen":null},"environment":{"economy_level":100,"model":"claude-opus-4-7","post_compaction":false,"session_turn":4,"parallel_session":false},"task_size":{"tool_calls":80,"files_touched":7,"files":["c:\\моя\\проекты\\портал crm\\Документация\\.claude\\skills\\subagent-driven-development\\references\\git-safety-checklist.md","C:\\Users\\***\\.claude\\plugins\\cache\\superpowers-dev\\superpowers\\5.1.0\\skills\\subagent-driven-development\\implementer-prompt.md","C:\\Users\\***\\.claude\\plugins\\cache\\superpowers-dev\\superpowers\\5.1.0\\skills\\subagent-driven-development\\spec-reviewer-prompt.md","C:\\Users\\***\\.claude\\plugins\\cache\\superpowers-dev\\superpowers\\5.1.0\\skills\\subagent-driven-development\\code-quality-reviewer-prompt.md","C:\\Users\\***\\.claude\\projects\\c---------------------crm-------------\\memory\\project_router_overhaul.md","C:\\Users\\***\\.claude\\projects\\c---------------------crm-------------\\memory\\MEMORY.md","c:\\моя\\проекты\\портал crm\\Документация\\.claude\\worktrees\\router-stage2-measurements\\docs\\observer\\baselines\\2026-05-24-pre-enforcement.md"]},"task_cost":{"input_tokens":231,"output_tokens":140299,"cache_read_input_tokens":41773904,"cache_creation_input_tokens":735069,"web_search_requests":0,"web_fetch_requests":0,"iterations":"0[object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object]"},"primary_rationale":{"step":1,"node_chosen":"subagent-driven-development","chain_ref":null,"triggers_matched":[],"candidates_considered":[],"boundaries_applied":[],"hard_floor":{"invoked":true,"rules":["Pravila §12"]},"task_classification":"bugfix","recommended_node":null},"events":[{"kind":"skill_invoked","skill":"subagent-driven-development"},{"kind":"skill_invoked","skill":"superpowers:subagent-driven-development"},{"kind":"skill_invoked","skill":"superpowers:using-git-worktrees"},{"kind":"tool_summary","counts":{"Skill":3,"ToolSearch":2,"Bash":28,"TodoWrite":10,"Read":7,"EnterWorktree":1,"PowerShell":8,"Agent":16,"Write":1,"Edit":4}},{"kind":"error","tool":"Bash","summary":"Exit code 2\npackage.json\n---\nls: cannot access 'node_modules': No such file or d"},{"kind":"error","tool":"PowerShell","summary":"Exit code 1\nC:\\моя\\проекты\\портал crm\\Документация\\.claude\\worktrees\\router-stag"},{"kind":"error","tool":"Bash","summary":"Exit code 2\ne239160a2e338a9bd8ba5e2cae0a5e3ce574e345\ndocs(brain): baseline pre-e"},{"kind":"error","tool":"Edit","summary":"File has not been read yet. Read it first before writing to it.<"},{"kind":"error","tool":"Edit","summary":"File has not been read yet. Read it first before writing to it.<"},{"kind":"hook_fired","counts":{"PreToolUse:Skill":3,"PostToolUse:Skill":3,"PreToolUse:ToolSearch":2,"PostToolUse:ToolSearch":2,"PreToolUse:Bash":28,"PostToolUse:Bash":26,"PreToolUse:TodoWrite":10,"PostToolUse:TodoWrite":10,"PreToolUse:Read":7,"PostToolUse:Read":7,"PreToolUse:EnterWorktree":1,"PostToolUse:EnterWorktree":1,"PreToolUse:PowerShell":8,"PostToolUse:PowerShell":7,"PreToolUse:Agent":32,"PostToolUse:Agent":16,"PreToolUse:Write":1,"PostToolUse:Write":1,"PreToolUse:Edit":2,"PostToolUse:Edit":2},"scripts":{"inline:ee9b8c077fcab23d":3,"inline:e2f5fa75197ed8a7":63,"inline:62a129f5897d02fb":3,"inline:123781b7a77c2213":3,"inline:171492082025e488":3,"inline:0d75e3df119ecf14":3},"errors":0},{"kind":"retry"},{"kind":"retry"},{"kind":"retry"},{"kind":"retry"},{"kind":"retry"},{"kind":"time_burn","duration_ms":4124702},{"kind":"subagent_invoked","subagent_type":"general-purpose","model":"sonnet","description":"Task 1: classification triggers in registry"},{"kind":"subagent_invoked","subagent_type":"general-purpose","model":"sonnet","description":"Spec review Task 1"},{"kind":"subagent_invoked","subagent_type":"general-purpose","model":"sonnet","description":"Code quality review Task 1"},{"kind":"subagent_invoked","subagent_type":"general-purpose","model":"sonnet","description":"Task 2: registry adapter"},{"kind":"subagent_invoked","subagent_type":"general-purpose","model":"sonnet","description":"Spec+quality review Task 2"},{"kind":"subagent_invoked","subagent_type":"general-purpose","model":"sonnet","description":"Task 3: discipline-metrics"},{"kind":"subagent_invoked","subagent_type":"general-purpose","model":"sonnet","description":"Spec+quality review Task 3"},{"kind":"subagent_invoked","subagent_type":"general-purpose","model":"sonnet","description":"Task 4: integrate analyzer"},{"kind":"subagent_invoked","subagent_type":"general-purpose","model":"sonnet","description":"Review Task 4"},{"kind":"subagent_invoked","subagent_type":"general-purpose","model":"sonnet","description":"Task 5: STATUS.md discipline"},{"kind":"subagent_invoked","subagent_type":"general-purpose","model":"sonnet","description":"Review Task 5"},{"kind":"subagent_invoked","subagent_type":"general-purpose","model":"sonnet","description":"Task 6: baseline snapshot"},{"kind":"subagent_invoked","subagent_type":"general-purpose","model":"sonnet","description":"Review Task 6"},{"kind":"subagent_invoked","subagent_type":"general-purpose","model":"sonnet","description":"Fix Task 6 F1"},{"kind":"subagent_invoked","subagent_type":"general-purpose","model":"sonnet","description":"Task 7: continuity (active-projects)"},{"kind":"subagent_invoked","subagent_type":"general-purpose","model":"sonnet","description":"Final review всей серии"}]} +{"schema_version":3,"task_id":"1ff47cd9-c1e0-4277-ad1b-1f7754f0569a","task_ref":"1ff47cd9-c1e0-4277-ad1b-1f7754f0569a","timestamps":{"started_at":"2026-05-24T04:43:14.875Z","ended_at":"2026-05-24T04:45:22.663Z"},"path_type":"regulated","outcome":"unknown","prompt_signal":"new_task","decision_provenance":{"kind":"autonomous","claude_would_have_chosen":null},"environment":{"economy_level":100,"model":"claude-opus-4-7","post_compaction":false,"session_turn":5,"parallel_session":false},"task_size":{"tool_calls":12,"files_touched":0,"files":[]},"task_cost":{"input_tokens":54,"output_tokens":24656,"cache_read_input_tokens":5760546,"cache_creation_input_tokens":1012203,"web_search_requests":0,"web_fetch_requests":0,"iterations":"0[object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object]"},"primary_rationale":{"step":1,"node_chosen":"superpowers:verification-before-completion","chain_ref":null,"triggers_matched":[],"candidates_considered":[],"boundaries_applied":[],"hard_floor":{"invoked":true,"rules":["Pravila §12"]},"task_classification":"question","recommended_node":null},"events":[{"kind":"skill_invoked","skill":"superpowers:verification-before-completion"},{"kind":"tool_summary","counts":{"Skill":1,"Bash":11}},{"kind":"error","tool":"Bash","summary":"Exit code 128\n=== Git state ===\n6bd0eb59ebbb764d375d7aca0ed61c5e9700b40c\nworktre"},{"kind":"error","tool":"Bash","summary":"Cancelled: parallel tool call Bash(cd \"c:/моя/проекты/портал crm"},{"kind":"error","tool":"Bash","summary":"Cancelled: parallel tool call Bash(cd \"c:/моя/проекты/портал crm"},{"kind":"error","tool":"Bash","summary":"Cancelled: parallel tool call Bash(cd \"c:/моя/проекты/портал crm"},{"kind":"error","tool":"Bash","summary":"Exit code 1\n✅ ok C:\\моя\\проекты\\портал crm\\Документация\\.claude\\worktrees\\rout"},{"kind":"error","tool":"Bash","summary":"Cancelled: parallel tool call Bash(cd \"c:/моя/проекты/портал crm"},{"kind":"hook_fired","counts":{"PreToolUse:Skill":1,"PostToolUse:Skill":1,"PreToolUse:Bash":7,"PostToolUse:Bash":5},"scripts":{"inline:ee9b8c077fcab23d":1,"inline:e2f5fa75197ed8a7":7},"errors":0},{"kind":"retry"},{"kind":"retry"},{"kind":"retry"},{"kind":"retry"},{"kind":"retry"},{"kind":"retry"}]} +{"schema_version":3,"task_id":"1ff47cd9-c1e0-4277-ad1b-1f7754f0569a","task_ref":"1ff47cd9-c1e0-4277-ad1b-1f7754f0569a","timestamps":{"started_at":"2026-05-24T06:20:51.158Z","ended_at":"2026-05-24T06:21:11.035Z"},"path_type":"improvised","outcome":"unknown","prompt_signal":"new_task","decision_provenance":{"kind":"autonomous","claude_would_have_chosen":null},"environment":{"economy_level":100,"model":"claude-opus-4-7","post_compaction":false,"session_turn":17,"parallel_session":false},"task_size":{"tool_calls":0,"files_touched":0,"files":[]},"task_cost":{"input_tokens":6,"output_tokens":575,"cache_read_input_tokens":26831,"cache_creation_input_tokens":457500,"web_search_requests":0,"web_fetch_requests":0,"iterations":"0[object Object]"},"primary_rationale":{"step":1,"node_chosen":"direct","chain_ref":null,"triggers_matched":[],"candidates_considered":[],"boundaries_applied":[],"hard_floor":{"invoked":false,"rules":[]},"task_classification":"question","recommended_node":null},"events":[]} +{"schema_version":3,"task_id":"1ff47cd9-c1e0-4277-ad1b-1f7754f0569a","task_ref":"1ff47cd9-c1e0-4277-ad1b-1f7754f0569a","timestamps":{"started_at":"2026-05-24T06:29:15.596Z","ended_at":"2026-05-24T06:29:36.163Z"},"path_type":"improvised","outcome":"unknown","prompt_signal":"new_task","decision_provenance":{"kind":"autonomous","claude_would_have_chosen":null},"environment":{"economy_level":100,"model":"claude-opus-4-7","post_compaction":false,"session_turn":18,"parallel_session":false},"task_size":{"tool_calls":0,"files_touched":0,"files":[]},"task_cost":{"input_tokens":6,"output_tokens":344,"cache_read_input_tokens":484331,"cache_creation_input_tokens":1159,"web_search_requests":0,"web_fetch_requests":0,"iterations":"0[object Object]"},"primary_rationale":{"step":1,"node_chosen":"direct","chain_ref":null,"triggers_matched":[],"candidates_considered":[],"boundaries_applied":[],"hard_floor":{"invoked":false,"rules":[]},"task_classification":"question","recommended_node":null},"events":[]} +{"schema_version":3,"task_id":"1ff47cd9-c1e0-4277-ad1b-1f7754f0569a","task_ref":"1ff47cd9-c1e0-4277-ad1b-1f7754f0569a","timestamps":{"started_at":"2026-05-24T06:32:52.067Z","ended_at":"2026-05-24T06:33:30.162Z"},"path_type":"improvised","outcome":"unknown","prompt_signal":"neutral","decision_provenance":{"kind":"autonomous","claude_would_have_chosen":null},"environment":{"economy_level":100,"model":"claude-opus-4-7","post_compaction":false,"session_turn":19,"parallel_session":false},"task_size":{"tool_calls":0,"files_touched":0,"files":[]},"task_cost":{"input_tokens":12,"output_tokens":3498,"cache_read_input_tokens":970980,"cache_creation_input_tokens":1234,"web_search_requests":0,"web_fetch_requests":0,"iterations":"0[object Object][object Object]"},"primary_rationale":{"step":1,"node_chosen":"direct","chain_ref":null,"triggers_matched":[],"candidates_considered":[],"boundaries_applied":[],"hard_floor":{"invoked":false,"rules":[]},"task_classification":"other","recommended_node":null},"events":[]} +{"schema_version":3,"task_id":"1ff47cd9-c1e0-4277-ad1b-1f7754f0569a","task_ref":"1ff47cd9-c1e0-4277-ad1b-1f7754f0569a","timestamps":{"started_at":"2026-05-24T08:25:55.490Z","ended_at":"2026-05-24T08:33:29.631Z"},"path_type":"regulated","outcome":"unknown","prompt_signal":"neutral","decision_provenance":{"kind":"autonomous","claude_would_have_chosen":null},"environment":{"economy_level":100,"model":"claude-opus-4-7","post_compaction":false,"session_turn":28,"parallel_session":false},"task_size":{"tool_calls":14,"files_touched":2,"files":["c:\\моя\\проекты\\портал crm\\Документация\\.claude\\worktrees\\router-stage3-enforcement\\tools\\router-prehook.mjs","C:\\Users\\***\\.claude\\projects\\c---------------------crm-------------\\memory\\project_router_overhaul.md"]},"task_cost":{"input_tokens":259542,"output_tokens":67234,"cache_read_input_tokens":27536732,"cache_creation_input_tokens":2376895,"web_search_requests":0,"web_fetch_requests":0,"iterations":"0[object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object][object Object]"},"primary_rationale":{"step":1,"node_chosen":"superpowers:systematic-debugging","chain_ref":["L8"],"triggers_matched":[],"candidates_considered":[],"boundaries_applied":[],"hard_floor":{"invoked":true,"rules":["Pravila §12"]},"task_classification":"other","recommended_node":null},"events":[{"kind":"skill_invoked","skill":"superpowers:systematic-debugging"},{"kind":"tool_summary","counts":{"Bash":7,"Agent":1,"Read":1,"Edit":3,"Skill":1,"TodoWrite":1}},{"kind":"hook_fired","counts":{"PreToolUse:Bash":14,"PostToolUse:Bash":7,"PreToolUse:Agent":2,"PostToolUse:Agent":1,"PreToolUse:Read":1,"PostToolUse:Read":1,"PreToolUse:Edit":7,"PostToolUse:Edit":3,"PreToolUse:Skill":1,"PostToolUse:Skill":1,"PreToolUse:TodoWrite":1,"PostToolUse:TodoWrite":1},"scripts":{"tools/router-tool-gate.mjs":21,"inline:e2f5fa75197ed8a7":23,"inline:62a129f5897d02fb":7,"inline:123781b7a77c2213":7,"inline:171492082025e488":3,"inline:0d75e3df119ecf14":3,"inline:ee9b8c077fcab23d":1},"errors":0},{"kind":"subagent_invoked","subagent_type":"claude-code-guide","model":null,"description":"UserPromptSubmit hook format"}]} diff --git a/docs/registry/nodes.yaml b/docs/registry/nodes.yaml index e005365a..e445720c 100644 --- a/docs/registry/nodes.yaml +++ b/docs/registry/nodes.yaml @@ -160,6 +160,8 @@ nodes: triggers: - {keyword: "php code style", weight: 1.0} - {keyword: "форматтер", weight: 1.0} + - {classification: "refactor", weight: 1.0} + - {classification: "cleanup", weight: 1.0} boundaries: [] chain_membership: [] attributes: @@ -176,6 +178,8 @@ nodes: triggers: - {keyword: "статанализ php", weight: 1.0} - {keyword: "типы", weight: 1.0} + - {classification: "refactor", weight: 1.0} + - {classification: "cleanup", weight: 1.0} boundaries: [] chain_membership: ["L14"] attributes: @@ -400,6 +404,11 @@ nodes: triggers: - {keyword: "sast", weight: 1.0} - {keyword: "security static analysis", weight: 1.0} + - {keyword: "статический анализ", weight: 1.0} + - {keyword: "sast scan", weight: 1.0} + - {keyword: "secret pattern", weight: 1.0} + - {keyword: "уязвимость в коде", weight: 1.0} + - {classification: "analysis", weight: 1.0} boundaries: - {relation: "связка binary+mcp"} chain_membership: ["L15", "L6"] @@ -545,6 +554,8 @@ nodes: dormancy_reason: null triggers: - {keyword: "отладка production runtime errors", weight: 1.0} + - {classification: "bugfix", weight: 1.0} + - {classification: "monitoring", weight: 1.0} boundaries: [] chain_membership: ["L13", "L8"] attributes: @@ -561,6 +572,7 @@ nodes: - {keyword: "отладка redis/memurai очередей", weight: 1.0} - {keyword: "кэша", weight: 1.0} - {keyword: "pest-race", weight: 1.0} + - {classification: "monitoring", weight: 1.0} boundaries: - {relation: "read-only"} chain_membership: ["L13", "L8"] @@ -578,6 +590,8 @@ nodes: - {keyword: "архитектурные решения", weight: 1.0} - {keyword: "adr", weight: 1.0} - {keyword: "enforcement", weight: 1.0} + - {keyword: "architecture decision record", weight: 1.0} + - {keyword: "архитектурное решение", weight: 1.0} boundaries: - {relation: "adr-judge lefthook job 9"} chain_membership: ["L4", "L5"] @@ -595,6 +609,9 @@ nodes: triggers: - {keyword: "c4", weight: 1.0} - {keyword: "architecture-диаграммы", weight: 1.0} + - {keyword: "mermaid диаграмма", weight: 1.0} + - {keyword: "c4 диаграмма", weight: 1.0} + - {keyword: "c4 модель", weight: 1.0} boundaries: [] chain_membership: ["L4"] attributes: @@ -610,6 +627,10 @@ nodes: dormancy_reason: null triggers: - {keyword: "справочник архитектурных паттернов", weight: 1.0} + - {keyword: "clean architecture", weight: 1.0} + - {keyword: "hexagonal", weight: 1.0} + - {keyword: "ddd", weight: 1.0} + - {keyword: "domain-driven", weight: 1.0} boundaries: [] chain_membership: ["L5"] attributes: @@ -627,6 +648,10 @@ nodes: - {keyword: "deep аудит безопасности", weight: 1.0} - {keyword: "diff", weight: 1.0} - {keyword: "supply-chain", weight: 1.0} + - {keyword: "глубокий security audit", weight: 1.0} + - {keyword: "supply chain risk", weight: 1.0} + - {keyword: "audit context", weight: 1.0} + - {classification: "analysis", weight: 1.0} boundaries: [] chain_membership: ["L15", "L6"] attributes: @@ -642,6 +667,10 @@ nodes: dormancy_reason: null triggers: - {keyword: "inline-блокировка уязвимых паттернов", weight: 1.0} + - {keyword: "inline уязвимость", weight: 1.0} + - {keyword: "code security warning", weight: 1.0} + - {keyword: "уязвимый паттерн", weight: 1.0} + - {keyword: "secret pattern detection", weight: 1.0} boundaries: - {relation: "блокирующий PreToolUse (sys.exit 2)"} chain_membership: ["L6"] @@ -659,6 +688,7 @@ nodes: triggers: - {keyword: "prd эпик issue код", weight: 1.0} - {keyword: "dev-проекты", weight: 1.0} + - {classification: "planning", weight: 1.0} boundaries: [] chain_membership: ["L9"] attributes: @@ -677,6 +707,7 @@ nodes: - {keyword: "роадмап", weight: 1.0} - {keyword: "метрики", weight: 1.0} - {keyword: "продуктовые церемонии", weight: 1.0} + - {classification: "planning", weight: 1.0} boundaries: [] chain_membership: ["L9"] attributes: @@ -693,6 +724,10 @@ nodes: triggers: - {keyword: "направление зависимостей", weight: 1.0} - {keyword: "границы слоёв", weight: 1.0} + - {keyword: "архитектурная зависимость", weight: 1.0} + - {keyword: "layer dependency", weight: 1.0} + - {keyword: "deptrac.yaml", weight: 1.0} + - {classification: "refactor", weight: 1.0} boundaries: - {relation: "lefthook job 10"} chain_membership: ["L14", "L5"] @@ -757,6 +792,10 @@ nodes: dormancy_reason: null triggers: - {keyword: "introspection openapi/rest-спек", weight: 1.0} + - {keyword: "openapi", weight: 1.0} + - {keyword: "swagger", weight: 1.0} + - {keyword: "спека api", weight: 1.0} + - {keyword: "rest api", weight: 1.0} boundaries: - {relation: "read-only"} chain_membership: ["L7"] @@ -774,6 +813,9 @@ nodes: triggers: - {keyword: "тестирование llm-промптов", weight: 1.0} - {keyword: "eval", weight: 1.0} + - {keyword: "eval промпта", weight: 1.0} + - {keyword: "llm test", weight: 1.0} + - {keyword: "prompt regression", weight: 1.0} boundaries: - {relation: "только вручную/CI, никогда в хук"} chain_membership: ["L10"] @@ -790,6 +832,10 @@ nodes: dormancy_reason: null triggers: - {keyword: "классический ml-воркфлоу", weight: 1.0} + - {keyword: "ml модель", weight: 1.0} + - {keyword: "статистика", weight: 1.0} + - {keyword: "корреляция", weight: 1.0} + - {keyword: "машинное обучение", weight: 1.0} boundaries: [] chain_membership: ["L10"] attributes: @@ -819,6 +865,10 @@ nodes: dormancy_reason: null triggers: - {keyword: "документирование/оптимизация бизнес-процессов", weight: 1.0} + - {keyword: "бизнес-процесс документ", weight: 1.0} + - {keyword: "runbook", weight: 1.0} + - {keyword: "capacity plan", weight: 1.0} + - {keyword: "risk assessment", weight: 1.0} boundaries: [] chain_membership: ["L4"] attributes: @@ -835,6 +885,9 @@ nodes: triggers: - {keyword: "моделирование to-be процесса", weight: 1.0} - {keyword: "bpmn 2.0", weight: 1.0} + - {keyword: "bpmn", weight: 1.0} + - {keyword: "моделирование процесса", weight: 1.0} + - {keyword: "swimlane", weight: 1.0} boundaries: - {relation: "self-authored project skill"} chain_membership: ["L3", "L4"] @@ -851,6 +904,10 @@ nodes: triggers: - {keyword: "анализ as-is процесса", weight: 1.0} - {keyword: "discovery из кода", weight: 1.0} + - {keyword: "discovery процесса", weight: 1.0} + - {keyword: "узкое место", weight: 1.0} + - {keyword: "bottleneck", weight: 1.0} + - {classification: "analysis", weight: 1.0} boundaries: - {relation: "self-authored project skill; ADR-009 граница с #55"} chain_membership: ["L3"] @@ -882,6 +939,8 @@ nodes: - {keyword: "интервью-discovery", weight: 1.0} - {keyword: "jtbd", weight: 1.0} - {keyword: "feature system режим", weight: 1.0} + - {keyword: "discovery", weight: 1.0} + - {keyword: "интервью заказчика", weight: 1.0} boundaries: - {adr: "ADR-009", role: "граница с #53 process-analysis: discovery-interview = интервью человека; process-analysis = discovery из кода"} chain_membership: ["L1", "L2"] @@ -898,6 +957,9 @@ nodes: triggers: - {keyword: "создание standalone-скилов", weight: 1.0} - {keyword: "eval", weight: 1.0} + - {keyword: "создать скил", weight: 1.0} + - {keyword: "новый skill", weight: 1.0} + - {keyword: "skill.md", weight: 1.0} boundaries: [] chain_membership: ["L11"] attributes: @@ -912,6 +974,10 @@ nodes: dormancy_reason: null triggers: - {keyword: "разработка claude-плагинов", weight: 1.0} + - {keyword: "плагин claude code", weight: 1.0} + - {keyword: "plugin.json", weight: 1.0} + - {keyword: "новый плагин", weight: 1.0} + - {keyword: "marketplace плагин", weight: 1.0} boundaries: [] chain_membership: ["L11"] attributes: @@ -926,6 +992,10 @@ nodes: dormancy_reason: null triggers: - {keyword: "генерация хуков (только по явному /hookify)", weight: 1.0} + - {keyword: "хук claude", weight: 1.0} + - {keyword: "новый hook", weight: 1.0} + - {keyword: "pretooluse хук", weight: 1.0} + - {keyword: "генерация хука", weight: 1.0} boundaries: - {adr: "ADR-010", role: "HK1 pre-check коллизии с существующими хуками перед генерацией"} chain_membership: ["L11"] @@ -955,6 +1025,10 @@ nodes: dormancy_reason: null triggers: - {keyword: "актуальная документация библиотек/sdk", weight: 1.0} + - {keyword: "актуальная документация библиотеки", weight: 1.0} + - {keyword: "лайвдоки", weight: 1.0} + - {keyword: "документация пакета", weight: 1.0} + - {keyword: "документация sdk", weight: 1.0} boundaries: - {relation: "первый выбор для документации известной библиотеки; WebFetch — fallback на конкретный URL; WebSearch — без знания библиотеки"} chain_membership: [] @@ -974,6 +1048,11 @@ nodes: - {keyword: "us-gaap-отчётность", weight: 1.0} - {keyword: "проводки", weight: 1.0} - {keyword: "close", weight: 1.0} + - {keyword: "reconciliation", weight: 1.0} + - {keyword: "variance", weight: 1.0} + - {keyword: "journal entry", weight: 1.0} + - {keyword: "financial statements", weight: 1.0} + - {keyword: "audit support", weight: 1.0} boundaries: - {adr: "ADR-012", role: "граница C6/C7; US-GAAP-скилы частично применимы; SOX not-applicable РФ; warehouse-MCP DEFERRED"} chain_membership: [] @@ -994,6 +1073,16 @@ nodes: - {keyword: "идемпотентность", weight: 1.0} - {keyword: "тариф-резолюция", weight: 1.0} - {keyword: "дрейф reconcile", weight: 1.0} + - {keyword: "списание", weight: 1.0} + - {keyword: "биллинг", weight: 1.0} + - {keyword: "тариф", weight: 1.0} + - {keyword: "баланс", weight: 1.0} + - {keyword: "начисление лида", weight: 1.0} + - {keyword: "lead_charges", weight: 1.0} + - {keyword: "копейки", weight: 1.0} + - {keyword: "csv reconcile", weight: 1.0} + - {keyword: "bcmath", weight: 1.0} + - {keyword: "bcadd", weight: 1.0} boundaries: - {adr: "ADR-012", role: "граница с process-*/D3/ru-tax; аудит кода биллинга, не налогов и не процесса"} chain_membership: ["L13"] @@ -1013,6 +1102,13 @@ nodes: - {keyword: "налоговая база", weight: 1.0} - {keyword: "налогооблагаемое событие", weight: 1.0} - {keyword: "выгрузка бухгалтеру", weight: 1.0} + - {keyword: "ндс", weight: 1.0} + - {keyword: "усн", weight: 1.0} + - {keyword: "налог на прибыль", weight: 1.0} + - {keyword: "выручка", weight: 1.0} + - {keyword: "проводка", weight: 1.0} + - {keyword: "дт/кт", weight: 1.0} + - {keyword: "бухгалтер", weight: 1.0} boundaries: - {adr: "ADR-012", role: "закрывает РФ-gap US-GAAP-плагина #61; ≠ billing-audit #62 (корректность кода), ≠ D1/D2"} chain_membership: ["L13"] @@ -1030,6 +1126,11 @@ nodes: - {keyword: "авто-рефакторинг", weight: 1.0} - {keyword: "version-upgrade laravel", weight: 1.0} - {keyword: "удаление мёртвого кода", weight: 1.0} + - {keyword: "автоматический рефакторинг", weight: 1.0} + - {keyword: "версия php", weight: 1.0} + - {keyword: "deprecated php", weight: 1.0} + - {keyword: "code modernization", weight: 1.0} + - {classification: "refactor", weight: 1.0} boundaries: - {adr: "ADR-013", role: "BT1 vs Pint (трансформация vs форматирование); BT2 vs Larastan (комплементарны); BT3 vs deptrac"} chain_membership: ["L14"] @@ -1046,6 +1147,11 @@ nodes: dormancy_reason: null triggers: - {keyword: "метрики качества/сложности/архитектуры php-кода", weight: 1.0} + - {keyword: "метрики качества кода", weight: 1.0} + - {keyword: "complexity", weight: 1.0} + - {keyword: "architecture metrics", weight: 1.0} + - {keyword: "качество кода php", weight: 1.0} + - {classification: "refactor", weight: 1.0} boundaries: - {adr: "ADR-013", role: "BT4 vs Pint/Larastan; уникум — оси complexity + architecture"} chain_membership: ["L14"] @@ -1065,6 +1171,13 @@ nodes: - {keyword: "паттерн controller/service/job", weight: 1.0} - {keyword: "rls", weight: 1.0} - {keyword: "деньги", weight: 1.0} + - {keyword: "controller", weight: 1.0} + - {keyword: "service", weight: 1.0} + - {keyword: "job", weight: 1.0} + - {keyword: "eloquent", weight: 1.0} + - {keyword: "partition", weight: 1.0} + - {keyword: "lockforupdate", weight: 1.0} + - {keyword: "dispatch", weight: 1.0} boundaries: - {adr: "ADR-013", role: "BT5 ≠ architecture-patterns #38 (generic vs проектные конвенции); BT6 ≠ billing-audit #62"} chain_membership: [] @@ -1098,6 +1211,10 @@ nodes: - {keyword: "обход входа", weight: 1.0} - {keyword: "инъекции", weight: 1.0} - {keyword: "xss", weight: 1.0} + - {keyword: "dast", weight: 1.0} + - {keyword: "scan running portal", weight: 1.0} + - {keyword: "проникновение в работающий портал", weight: 1.0} + - {classification: "security", weight: 1.0} boundaries: - {adr: "ADR-014", role: "IS1 ≠ Semgrep #25 (динамика vs статика); IS2 ≠ Nuclei #69 (глубина vs широта)"} chain_membership: ["L15"] @@ -1113,6 +1230,11 @@ nodes: dormancy_reason: null triggers: - {keyword: "известные уязвимости/экспозиция/слабый tls снаружи", weight: 1.0} + - {keyword: "nuclei", weight: 1.0} + - {keyword: "уязвимость по шаблону", weight: 1.0} + - {keyword: "cve scan", weight: 1.0} + - {keyword: "nuclei сканер", weight: 1.0} + - {classification: "security", weight: 1.0} boundaries: - {adr: "ADR-014", role: "IS2 ≠ ZAP #68 (широта vs глубина — комплементарны)"} chain_membership: ["L15"] @@ -1129,6 +1251,10 @@ nodes: triggers: - {keyword: "безопасность настроек laravel", weight: 1.0} - {keyword: ".env/config/заголовки/cookie/secrets/deps", weight: 1.0} + - {keyword: "laravel security config", weight: 1.0} + - {keyword: "env audit", weight: 1.0} + - {keyword: "secrets config", weight: 1.0} + - {classification: "security", weight: 1.0} boundaries: - {adr: "ADR-014", role: "IS3 ≠ Larastan #12/Semgrep #25; заменил Enlightn (abandoned/L13)"} chain_membership: ["L15"] @@ -1144,6 +1270,14 @@ nodes: dormancy_reason: null triggers: - {keyword: "аудит пдн / соответствие 152-фз", weight: 1.0} + - {keyword: "пдн", weight: 1.0} + - {keyword: "персональные данные", weight: 1.0} + - {keyword: "152-фз", weight: 1.0} + - {keyword: "согласие на обработку", weight: 1.0} + - {keyword: "телефон лида", weight: 1.0} + - {keyword: "маскирование", weight: 1.0} + - {keyword: "pd_subject_request", weight: 1.0} + - {classification: "security", weight: 1.0} boundaries: - {adr: "ADR-014", role: "IS4 ≠ pg_anonymizer #29 (аудит vs инструмент маскирования); IS5 ≠ D2 (техника vs юридическое оформление)"} chain_membership: ["L15"] @@ -1161,6 +1295,11 @@ nodes: - {keyword: "stride угрозы портала", weight: 1.0} - {keyword: "going-public", weight: 1.0} - {keyword: "что защищать первым", weight: 1.0} + - {keyword: "stride", weight: 1.0} + - {keyword: "моделирование угроз", weight: 1.0} + - {keyword: "attack surface", weight: 1.0} + - {keyword: "точки входа", weight: 1.0} + - {classification: "security", weight: 1.0} boundaries: - {adr: "ADR-014", role: "IS6 ≠ Trail of Bits #39 (портал+STRIDE+going-public vs generic deep code-audit)"} chain_membership: ["L15"] @@ -1177,6 +1316,11 @@ nodes: triggers: - {keyword: "прогон безопасности перед релизом", weight: 1.0} - {keyword: "go/no-go", weight: 1.0} + - {keyword: "go-live", weight: 1.0} + - {keyword: "выход в интернет", weight: 1.0} + - {keyword: "публикация в прод", weight: 1.0} + - {keyword: "security release gate", weight: 1.0} + - {classification: "security", weight: 1.0} boundaries: - {adr: "ADR-014", role: "IS7 ≠ audit-portal (только безопасность + go-live-вердикт vs полный 14-фазный аудит)"} chain_membership: ["L15"] @@ -1197,6 +1341,12 @@ nodes: - {keyword: "email-цепочка", weight: 1.0} - {keyword: "конкурент-бриф", weight: 1.0} - {keyword: "performance-report", weight: 1.0} + - {keyword: "email-рассылка", weight: 1.0} + - {keyword: "лендинг", weight: 1.0} + - {keyword: "реклама", weight: 1.0} + - {keyword: "лидген", weight: 1.0} + - {keyword: "вебинар", weight: 1.0} + - {classification: "marketing", weight: 1.0} boundaries: - {adr: "ADR-015", role: "MKT1 vs C2/C3; MKT2 vs product-management #42; MKT3 vs marketingskills #75 (решатель, не материал)"} chain_membership: ["L16"] @@ -1220,6 +1370,12 @@ nodes: - {keyword: "lead-magnets", weight: 1.0} - {keyword: "pricing", weight: 1.0} - {keyword: "marketing-psychology", weight: 1.0} + - {keyword: "маркетинговая фреймворк", weight: 1.0} + - {keyword: "aida", weight: 1.0} + - {keyword: "pas", weight: 1.0} + - {keyword: "fab", weight: 1.0} + - {keyword: "usp", weight: 1.0} + - {classification: "marketing", weight: 1.0} boundaries: - {adr: "ADR-015", role: "MKT3 — материал/резерв-библиотека, не решатель; решатель = marketing #74"} chain_membership: [] @@ -1238,6 +1394,10 @@ nodes: - {keyword: "голос бренда", weight: 1.0} - {keyword: "brand guidelines для текстов", weight: 1.0} - {keyword: "тон копирайта", weight: 1.0} + - {keyword: "voice", weight: 1.0} + - {keyword: "тональность", weight: 1.0} + - {keyword: "позиционирование", weight: 1.0} + - {classification: "marketing", weight: 1.0} boundaries: - {adr: "ADR-015", role: "MKT6 — вербальный бренд vs Brandbook v2 визуальный бренд; взаимодополняют"} chain_membership: [] @@ -1258,6 +1418,12 @@ nodes: - {keyword: "telegram как каналы", weight: 1.0} - {keyword: "конверсия лендинга лидерры", weight: 1.0} - {keyword: "маркетинг 152-фз согласия на рассылки", weight: 1.0} + - {keyword: "рф-канал", weight: 1.0} + - {keyword: "вконтакте", weight: 1.0} + - {keyword: "telegram-канал", weight: 1.0} + - {keyword: "unisender", weight: 1.0} + - {keyword: "российский рынок", weight: 1.0} + - {classification: "marketing", weight: 1.0} boundaries: - {adr: "ADR-015", role: "MKT9 — 152-ФЗ cross-ref #71; закрывает РФ-специфику маркетинга"} chain_membership: ["L16"] @@ -1278,6 +1444,9 @@ nodes: - {keyword: "гео", weight: 1.0} - {keyword: "демография", weight: 1.0} - {keyword: "поведение", weight: 1.0} + - {keyword: "яндекс.метрика", weight: 1.0} + - {keyword: "статистика посещений", weight: 1.0} + - {classification: "marketing", weight: 1.0} boundaries: - {adr: "ADR-015", role: "MKT8 READ-ONLY; активация при живом лендинге (Б-1)"} chain_membership: ["L16"] @@ -1294,6 +1463,10 @@ nodes: triggers: - {keyword: "подбор ключевых слов wordstat", weight: 1.0} - {keyword: "частотность запросов рф", weight: 1.0} + - {keyword: "ключевые слова", weight: 1.0} + - {keyword: "wordstat", weight: 1.0} + - {keyword: "поисковые запросы", weight: 1.0} + - {classification: "marketing", weight: 1.0} boundaries: - {adr: "ADR-015", role: "MKT8 — Direct-мутации НЕ активированы; только Wordstat-модуль (5 read-only tools)"} chain_membership: ["L16"] @@ -1311,6 +1484,9 @@ nodes: - {keyword: "постинг в telegram-канал", weight: 1.0} - {keyword: "управление", weight: 1.0} - {keyword: "получение аналитики канала", weight: 1.0} + - {keyword: "telegram", weight: 1.0} + - {keyword: "telegram-бот", weight: 1.0} + - {classification: "marketing", weight: 1.0} boundaries: - {adr: "ADR-015", role: "MKT8 — SESSION_STRING только .env; выделенный аккаунт"} chain_membership: ["L16"] @@ -1327,6 +1503,10 @@ nodes: triggers: - {keyword: "планирование и публикация в 30+ соцсетей включая vk и telegram", weight: 1.0} - {keyword: "контент-календарь", weight: 1.0} + - {keyword: "smm-планировщик", weight: 1.0} + - {keyword: "постинг в соцсети", weight: 1.0} + - {keyword: "postiz", weight: 1.0} + - {classification: "marketing", weight: 1.0} boundaries: - {adr: "ADR-015", role: "MKT7 AGPL self-host без дистрибуции; покрывает VK-постинг"} chain_membership: ["L16"] @@ -1366,6 +1546,48 @@ nodes: attributes: tooling_section: "§4.58 #83" + - id: "#84" + name: "normative-sync" + slug: "normative-sync" + category: "off-phase" + subcategory: "project-agent" + status: "active" + dormancy_reason: null + triggers: + - {classification: "normative_sync_needed", weight: 1.0} + - {keyword: "синкни нормативку", weight: 1.0} + - {keyword: "нормативный синк", weight: 1.0} + - {keyword: "закрыли задачу синк", weight: 0.9} + boundaries: + - {relation: "Контроллер обязан звать после закрытия крупной off-phase интеграции, brain governance артефакта, принятого ADR — Pravila §2.4"} + - {pair: "#85", relation: "Парный project-агент — оба не входят в Tooling-канон счётчиков (#1-#83), это .claude/agents/ файлы"} + chain_membership: [] + attributes: + agent_file: ".claude/agents/normative-sync.md" + spec: "docs/superpowers/specs/2026-05-24-controller-offload-agents-design.md §3" + tooling_section: null + + - id: "#85" + name: "prod-deploy-validator" + slug: "prod-deploy-validator" + category: "off-phase" + subcategory: "project-agent" + status: "active" + dormancy_reason: null + triggers: + - {classification: "prod_deploy_imminent", weight: 1.0} + - {keyword: "готовность боевого", weight: 1.0} + - {keyword: "проверь прод", weight: 1.0} + - {keyword: "ready to deploy", weight: 1.0} + boundaries: + - {relation: "Контроллер обязан звать перед любым выкатом на liderra.ru — Pravila §2.4. READ-ONLY по дизайну."} + - {pair: "#84", relation: "Парный project-агент"} + chain_membership: [] + attributes: + agent_file: ".claude/agents/prod-deploy-validator.md" + spec: "docs/superpowers/specs/2026-05-24-controller-offload-agents-design.md §4" + tooling_section: null + chains: L1: name: "feature discovery & implementation chain" diff --git a/docs/routing-off-phase.md b/docs/routing-off-phase.md index 031e7d93..da72407f 100644 --- a/docs/routing-off-phase.md +++ b/docs/routing-off-phase.md @@ -26,11 +26,40 @@ | Классификация | Рекомендуемый узел | Вес | |---|---|---| +| `analysis` | #25 Semgrep + Semgrep MCP | 1 | +| `analysis` | #39 Trail of Bits Skills | 1 | +| `analysis` | #53 process-analysis | 1 | | `bugfix` | #18 Pest 4 | 1 | +| `bugfix` | #34 Sentry MCP | 1 | | `bugfix` | #19 Superpowers v5.1.0 | 0.8 | +| `cleanup` | #11 Laravel Pint | 1 | +| `cleanup` | #12 Larastan | 1 | | `feature` | #19 Superpowers v5.1.0 | 1 | +| `marketing` | #74 marketing | 1 | +| `marketing` | #75 marketingskills | 1 | +| `marketing` | #76 brand-voice | 1 | +| `marketing` | #77 marketing-ru | 1 | +| `marketing` | #78 Яндекс.Метрика MCP | 1 | +| `marketing` | #79 Яндекс.Директ+Wordstat MCP | 1 | +| `marketing` | #80 Telegram MCP | 1 | +| `marketing` | #81 Postiz | 1 | +| `monitoring` | #34 Sentry MCP | 1 | +| `monitoring` | #35 Redis MCP | 1 | | `planning` | #19 Superpowers v5.1.0 | 1 | +| `planning` | #41 CCPM | 1 | +| `planning` | #42 product-management | 1 | +| `refactor` | #11 Laravel Pint | 1 | +| `refactor` | #12 Larastan | 1 | +| `refactor` | #43 deptrac | 1 | +| `refactor` | #64 Rector | 1 | +| `refactor` | #65 PHP Insights | 1 | | `refactor` | #19 Superpowers v5.1.0 | 0.8 | +| `security` | #68 OWASP ZAP | 1 | +| `security` | #69 Nuclei | 1 | +| `security` | #70 Ward | 1 | +| `security` | #71 pdn-152fz-audit | 1 | +| `security` | #72 threat-model | 1 | +| `security` | #73 security-go-live | 1 | diff --git a/docs/superpowers/plans/2026-05-17-sprint5c-billing-admin.md b/docs/superpowers/plans/2026-05-17-sprint5c-billing-admin.md index c66608a3..6da7141c 100644 --- a/docs/superpowers/plans/2026-05-17-sprint5c-billing-admin.md +++ b/docs/superpowers/plans/2026-05-17-sprint5c-billing-admin.md @@ -26,6 +26,7 @@ scope P2. Решение заказчика — `disabled` + tooltip (как 5A ловит pointer-события → активатор tooltip навешивается на оборачивающий ``. **Files:** + - Modify: `app/resources/js/components/billing/BalanceCard.vue` - Create: `app/tests/Frontend/BalanceCard.spec.ts` @@ -144,6 +145,7 @@ git commit -m "feat(billing): E2 — disabled+tooltip на кнопках Авт заказчика — убрать баннер и файл `mockBilling.ts` целиком. **Files:** + - Modify: `app/resources/js/views/BillingView.vue` - Delete: `app/resources/js/composables/mockBilling.ts` - Modify: `app/tests/Frontend/BillingView.spec.ts` @@ -214,6 +216,7 @@ CSRF — латентный баг для прода). Остальные адм ванный `api/admin.ts` + `apiClient`. Задача — вынести вызовы pricing-tiers/suppliers в `api/admin.ts`. **Files:** + - Modify: `app/resources/js/api/admin.ts` - Modify: `app/resources/js/views/admin/AdminPricingTiersView.vue` - Modify: `app/resources/js/views/admin/AdminSupplierPricesView.vue` @@ -295,11 +298,13 @@ export async function updateAdminSupplier( `scheduled: ref>({})`, `editor: ref(...)`, `defaultEditor: PricingTierEditorRow[]`). 4. `load()` — заменить тело: + ```ts const data = await getPricingTiers(); active.value = data.active; scheduled.value = data.scheduled; ``` + 5. `submit()` — заменить `await axios.post('/api/admin/pricing-tiers', { tiers: editor.value });` на `await createPricingTiers(editor.value);`. 6. `confirmDelete()` — заменить `await axios.delete(\`/api/admin/pricing-tiers/scheduled/${effectiveFrom}\`);` @@ -319,8 +324,10 @@ export async function updateAdminSupplier( - [ ] **Step 4: Переписать `AdminPricingTiersView.spec.ts` на мок api/admin** Эталон паттерна — `app/tests/Frontend/AdminBillingViewActions.spec.ts`. Ключевые правки: + 1. Убрать `import axios from 'axios';` и `vi.mock('axios');`. 2. Добавить partial-мок: + ```ts vi.mock('../../resources/js/api/admin', async (importOriginal) => { const orig = await importOriginal(); @@ -328,19 +335,24 @@ export async function updateAdminSupplier( }); const adminApi = await import('../../resources/js/api/admin'); ``` + 3. Добавить хелпер ошибки (копия из эталона): + ```ts function makeAxiosError(message: string, status = 422): unknown { return Object.assign(new Error(message), { isAxiosError: true, response: { status, data: { message } } }); } ``` + 4. `mockTiers` — оставить (это `AdminPricingTier[]`). 5. Первый `describe` `beforeEach`: + ```ts vi.mocked(adminApi.getPricingTiers).mockResolvedValue({ active: mockTiers, scheduled: {} }); vi.mocked(adminApi.createPricingTiers).mockResolvedValue({ effective_from: '2026-06-01' }); vi.mocked(adminApi.deleteScheduledPricingTier).mockResolvedValue(undefined); ``` + 6. Тест `submits POST ...` → `expect(adminApi.createPricingTiers).toHaveBeenCalledWith(expect.arrayContaining([expect.objectContaining({ tier_no: 7, leads_in_tier: null })]));` 7. Тест `confirmDelete triggers DELETE ...` → `expect(adminApi.deleteScheduledPricingTier).toHaveBeenCalledWith('2026-06-01');` (`window.confirm = vi.fn(() => true)` — оставить, T5 уберёт). 8. `describe` error handling — убрать `axios.isAxiosError` блок; в каждом тесте заменить @@ -351,6 +363,7 @@ export async function updateAdminSupplier( - [ ] **Step 5: Переписать `AdminSupplierPricesView.spec.ts` на мок api/admin** Аналогично Step 4: + 1. Убрать axios; `vi.mock('../../resources/js/api/admin', ...)` с `getAdminSuppliers`/`updateAdminSupplier` как `vi.fn()`. 2. `makeAxiosError` хелпер. 3. `beforeEach`: `vi.mocked(adminApi.getAdminSuppliers).mockResolvedValue(mockSuppliers);` @@ -386,6 +399,7 @@ backend `AdminPricingTiersController@store:92` хардкодит `startOfMonth( показывает `nextMonthStart` в кнопке и заголовке диалога. G7 — дать админу выбрать дату. **Files:** + - Modify: `app/app/Http/Controllers/Api/AdminPricingTiersController.php` - Modify: `app/resources/js/api/admin.ts` - Modify: `app/resources/js/views/admin/AdminPricingTiersView.vue` @@ -430,14 +444,17 @@ Expected: FAIL — `effective_from` сейчас игнорируется (пе - [ ] **Step 3: Backend — принять `effective_from` в `store()`** В `AdminPricingTiersController@store`: + 1. Перед `$request->validate([...])` вычислить `$todayMsk = Carbon::now('Europe/Moscow')->toDateString();` 2. В массив правил добавить: `'effective_from' => ['sometimes', 'date_format:Y-m-d', 'after:'.$todayMsk],` 3. Заменить строку `$effectiveFrom = Carbon::now('Europe/Moscow')->startOfMonth()->addMonth()->toDateString();` на: + ```php $effectiveFrom = $request->input('effective_from') ?? Carbon::now('Europe/Moscow')->startOfMonth()->addMonth()->toDateString(); ``` + (`$todayMsk` из шага 1 переиспользуется правилом валидации; вычислять до `validate`.) - [ ] **Step 4: Прогнать Pest — убедиться, что проходит** @@ -465,9 +482,11 @@ export async function createPricingTiers( - [ ] **Step 6: Frontend — date-picker в редакторе сетки** В `AdminPricingTiersView.vue`: + 1. Добавить ref после `nextMonthStart` computed: `const effectiveFrom = ref(nextMonthStart.value);` 2. Добавить computed для `min` (завтра): + ```ts const minEffectiveFrom = computed(() => { const d = new Date(); @@ -475,7 +494,9 @@ export async function createPricingTiers( return d.toISOString().slice(0, 10); }); ``` + 3. В диалоге-редакторе перед `` добавить поле: + ```vue ``` + 4. Заголовок диалога: `Новая сетка (effective_from = {{ effectiveFrom }})` (вместо `nextMonthStart`). Кнопку открытия редактора `Редактировать сетку (с {{ nextMonthStart }})` — оставить `nextMonthStart` (это дефолтная подсказка до открытия диалога). @@ -548,6 +570,7 @@ confirm()», но в `AdminBillingView` `confirm()` уже нет — Sprint 3D `v-dialog`'и; фактический оставшийся браузерный confirm в админ-биллинге — здесь, в pricing-tiers.) **Files:** + - Modify: `app/resources/js/views/admin/AdminPricingTiersView.vue` - Modify: `app/tests/Frontend/AdminPricingTiersView.spec.ts` @@ -591,15 +614,19 @@ Expected: FAIL — `deleteDialogOpen`/`deleteTarget`/`performDelete` ещё не - [ ] **Step 3: Заменить `window.confirm` на `v-dialog`-flow** В `AdminPricingTiersView.vue`: + 1. Добавить state: `const deleteDialogOpen = ref(false);` и `const deleteTarget = ref(null);` 2. Заменить функцию `confirmDelete` — теперь только открывает диалог: + ```ts function confirmDelete(effectiveFrom: string): void { deleteTarget.value = effectiveFrom; deleteDialogOpen.value = true; } ``` + 3. Добавить `performDelete` — фактическое удаление (тело — бывший `confirmDelete` без `window.confirm`): + ```ts async function performDelete(): Promise { const effectiveFrom = deleteTarget.value; @@ -619,7 +646,9 @@ Expected: FAIL — `deleteDialogOpen`/`deleteTarget`/`performDelete` ещё не } } ``` + 4. В `