Дата старта (решение владельца 06.08.2026). Портал не передаёт кабинету МТС ни одной
даты — их ставит кабинет своими умолчаниями. Живой прогон показал: старт оказался
ЗАВТРАШНИМ, тогда как экран обещал клиенту показы «7 дней», подразумевая сегодня.
Робот читает день начала из ТОЙ ЖЕ строки списка, куда и так ходит за вердиктом —
ни одного лишнего захода в кабинет; портал хранит его в client_tg_campaigns.starts_on
и показывает клиенту «Показы начнутся 7 августа». Проверено на ЖИВОМ кабинете:
три задания подряд вернули startDate 2026-08-07 по кампании МТС 2237821.
Мастер перестал молчать о том, что день начала ставит кабинет, а не мы.
Ф-2, карточка приёмки Т-Ф4. У кампаний в движении видно «Проверяли 5 минут назад».
Считаются только ЗАКОНЧЕННЫЕ проверки, включая неудачные: задание в очереди работой
не является, а неудачная проверка — всё равно признак жизни. Именно в такой тишине
владелец 36 часов не знал, что робот вообще не может войти в кабинет.
Ф-3. Имена полей в ошибках формы по-русски: «Лимит на объявление не может быть меньше
1 ₽» вместо «Поле budget cap rub должно быть не меньше 1». Серая кнопка «Запустить»
называет причину и шаг, куда вернуться, а не гаснет молча.
Карточки Т-Р3 и Т-Р4 закрыты тестами (живьём не воспроизвести). Попутно найдено: обе
защиты, стерегущие ЕДИНСТВЕННОЕ место траты живых денег роботом, были без единого
теста. Сторожа доказаны вырезанием.
Тесты: ClientTg 378 зелёных, экраны 2158, робот 197. Статанализ 0, стиль 0, типы чисто.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Поймано на боевых данных 06.08.2026, ДО первого нажатия кнопки. В списке
застрявших оказались три кампании, а не одна — и у двух из них заморозку уже
отпустили, денег за ними не осталось.
Экран же показывал смету и подписывал её «Заморожено у клиента: 268,80 ₽».
Владелец нажал бы «вернуть деньги», не вернулось бы ничего, а он считал бы,
что вернул. Обещать возврат того, чего нет, — то же враньё, что зелёная
галочка над невыполненной работой.
Теперь в списке идёт живая заморозка, посчитанная одним запросом на весь
список. Когда её нет — так и написано, и кнопка меняет обещание на «просто
закрыть кампанию». Отдельно предупреждаем, когда списание пойдёт прямо с
баланса клиента, а не из отложенного: у кампании №10 на боевом ровно этот
случай — кабинету уплачено 180 ₽, а заморозки уже нет.
Проверено: модуль 355 тестов, экраны 16 тестов, статанализ 0, типы чисты.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Кампания, брошенная роботом на полпути, попадала в needs_review или
draft_ready — статусы, из которых не вело ни одного перехода. Замороженные
деньги клиента запирались навсегда, снять их мог только программист правкой
боевой базы. В бою 06.08.2026 так заперло 268,80 ₽ по кампании №14.
Теперь в админке есть карточка «Застрявшие кампании»: владелец видит номер
кампании в кабинете МТС, запертую сумму и уже уплаченную МТС сумму — и решает
сам. Кнопка «списать по факту» показывается ТОЛЬКО когда МТС уже уплачено;
иначе списывать было бы нечего, кроме сметы — ровно та беда, ради которой
заморозку и заводили.
Заодно: клиенту больше не показывают внутренние адреса и команды запуска —
причина отказа переводится на человеческий язык. Портал перестал принимать
пустой отчёт робота как успешный.
Проверено: модуль 354 теста, экраны 13 тестов, статанализ 0 ошибок.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Надзиратель зарезал датчик своим ножом: положил годную по форме ложь в папку
миграций, где обзвон живёт тремя файлами. Датчик остался ЗЕЛЁНЫМ. Допущение
первой редакции — «ложь живёт в трёх папках» — неверно: обзвон живёт ещё в
database и в routes. Ложь под зелёным датчиком хуже, чем ложь без датчика:
следующий человек видит зелёное и решает, что класс закрыт.
Списка папок не завёл — это тот же список разрешений, вывернутый наизнанку, и он
ослеп бы снова на седьмой папке. Обход идёт по ВСЕМУ, исключается поимённо и с
доводом то, что портал про себя НЕ ПИШЕТ:
• vendor, node_modules — чужой код, мы его не пишем и править не вправе;
• storage и public/storage — мусор рантайма: журналы, кэши, подставные диски
проверок. Это следы, а не бумага;
• bootstrap/cache — СГЕНЕРИРОВАННАЯ копия настроек: соврала бы эхом нашей же
строки и покраснела бы вторым разом за то же самое;
• public/build, public/hot — собранный фронт, машинная копия исходников;
• .env — тайны: строка оттуда не должна попасть в текст падения проверки;
• символические ссылки — public/storage увёл бы обход в storage мимо
исключения, да ещё и по кругу.
Расширение файла тоже перестало быть условием: прежний датчик смотрел только
php — то же допущение, только по другой оси. Отсеивается не «не тот язык», а
«не текст»: двоичное, не-UTF-8 и всё крупнее двух мегабайт.
Про db расширил, и вот граница. В обход вошёл канон схемы db с расширением sql:
он описывает базу такой, какая она сейчас, обзвон в нём расписан, и на прошлой
смене ложь нашлась именно там. НЕ вошли docs и CHANGELOG_schema.md — это
летопись: запись от 05.08 была правдой того дня, и править её значит подделывать
записанное.
Заведён датчик на сам датчик. Слепота пришла тихо: зелёный цвет означал не
«чисто», а «не смотрел». Теперь обход обязан доказать досягаемость четырёх
дальних точек в разных углах — миграция обзвона, ежедневные команды, настройки
и канон схемы в другом корне. Сузит кто-нибудь обход — покраснеет сразу.
Показан красным трижды: ножом надзирателя в миграции, тем же ножом в канон схемы
как в не-php файл в другом корне, и сужением обхода. Оба разрезанных файла
возвращены, слепки сошлись знак в знак: 8fb6d531b и 29dbbe675.
Цена сбита с 30 до 8 секунд внутри прогона: регистр складывается один раз на
файл, а не на каждой строке.
Слепо и дальше, говорю вслух: перечень слов конечен, и ложь, сказанная иначе,
пройдёт мимо. Закрыть это перечнем слов нельзя в принципе.
Прогон обзвона 214 из 214, статанализ 0 ошибок.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Мест оказалось не три, а пять. Четвёртое назвал надзиратель, пятое нашлось при
доделке.
• ObzvonStiraniePoTrebovaniyuTest — заголовок был написан в будущем времени, а
комментарий утверждал, что диска в настройках пока не завели. Оба стали
ложью в день постройки хранилища, а проверка осталась зелёной и сама себя не
показала бы никогда: она подменяла настройки портала своими;
• ObzvonChistkaNaObyomeTest, заготовка z22vDisk — словами не врёт, и потому
опаснее: та же подмена настроек руками. Сторожа объёма стерегли СВОЮ ЖЕ
подпорку и остались бы зелёными, убери хранилище из настроек портала.
В обоих местах подмена настроек убрана, Storage::fake оставлен: он не трогает
настройки, а уводит корень диска в storage/framework/testing/disks и сам за
собой прибирает. Без него проверки клали бы и удаляли файлы в настоящей папке
записей разговоров на машине, где их запустили.
Заголовок стирания переписан по существу: проверка стережёт ВЕСЬ путь живого
человека — обращение, PdErasureService, переходник обзвона, диск. Соседний
сторож зовёт переходник напрямую и разрыв в середине дороги не увидел бы.
Датчик на весь класс: перечень живых строк, утверждающих в настоящем или
будущем времени, что хранилища записей нет, обязан быть ПУСТ.
🔴 Датчик написан на PHP, а не на grep, и это замер, а не вкус:
printf 'ДИСКА ещё НЕТ' | grep -i "диска" → НЕ находит.
Локаль здесь C.UTF-8, и складывать регистр кириллицы grep не умеет вовсе —
никаким ключом. Собранный им перечень соврёт коротким списком, на чём
надзиратель и обжёгся. mb_strtolower кириллица родная.
🪤 Вторая ловушка, моя: шаблон с [^.] между словом и отрицанием не находит
фразу «Диска … в config/filesystems.php ещё НЕТ» — точка в имени файла
рвёт совпадение. Датчик проверяет вхождение подстроки, никаких «между».
Датчик показан красным трижды: на возвращённой прежней строке, на моём же
комментарии с дословной цитатой прежней лжи, и рядом с grep, который на той же
строке нашёл ноль. Комментарий переписан пересказом, а не ослаблен датчик: ложь
в кавычках для читателя вскользь неотличима от лжи.
Убран мой собственный мусор из app: три файла-обрывка, порождённых стрелкой
внутри однострочника для tinker — оболочка прочла её как перенаправление.
Прогон обзвона 214 из 214, статанализ 0 ошибок.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Своя ошибка, найденная перечитыванием уже зелёного кода.
Приём записи сваливал в один отказ две разные беды: «в файле нет разговора»
и «хранилище не приняло». Задание понимает MaterialNePrinyat как «повторять
бессмысленно» — и правильно понимает, от пятой попытки пустышка целой не станет.
Цена ошибки: временный сбой хранилища — нет прав, отвалилось объектное
хранилище, кончилось место — портал объявил бы «запись испорчена», в повтор бы
не пошёл, и голос человека потерялся бы навсегда при живом исходнике на машине
робота.
Разведено:
• беда в самой записи — MaterialNePrinyat, повторов нет;
• беда в хранилище или в дороге — RuntimeException, задание уходит в повтор.
23-й сторож: «хранилище подвело — это ПОВТОР, а не запись испорчена». Показан
красным: вернул прежний MaterialNePrinyat — сторож сказал
Exception RuntimeException not thrown.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Владелец 06.08: "кошелёк убогий и неинформативный". Отметил пять слабых мест
из шести. Не отметил только прогноз "надолго ли хватит" - он пополняет по
факту, а не по остатку.
Что теперь на экране:
- слева липкая колонка денег: свободно крупно, и под ней РАСШИФРОВКА
заморозки - какая кампания, сколько заперто, когда вернётся. Раньше стояло
голое "Заморожено 2 507 руб" без единого слова, под что;
- период: сегодня / вчера / 7 дней / 30 дней / свои даты, живёт в адресе
страницы;
- столбики расхода по дням, цвет по каналу, своим CSS без библиотеки;
- таблица "Куда ушли" - строка на кампанию с суммой и долей, клик отбирает
ленту;
- закладки по каналам сохранены, но теперь считаются за выбранный период;
- лента разбита по дням с итогом за день и постраничной догрузкой.
Сервер: две новые сборки RaskryitieZamorozki и OtchyotKoshelka, новая ручка
/api/advertising/wallet/report, лента получила отбор по датам, каналу и
кампании и листание по ключу вместо смещения. Расчёт списаний, заморозки и
возврата по сроку НЕ тронут - правка только про показ.
Найдено по дороге и закрыто:
- чтение отчёта обёрнуто контекстом построчной защиты. Без этого на боевом
запрос вернул бы НОЛЬ строк молча: местная база под суперпользователем
защиту обходит, и мы бы увидели это только у клиента;
- сторож на число запросов мог зеленеть БЕЗ самой ручки - на любой неизвестный
путь портал отвечает страницей с кодом 200. Усилен и проверен вырезанием
маршрута;
- общий форматтер срезал хвостовой ноль: "833,50" превращалось в "833,5". На
это независимо наступили три правки подряд, каждая завела свою копию.
Сведено в formatExact.
Границы суток и группировка по дням считаются ПО МОСКВЕ, а не по Гринвичу.
Сторож взят с живого боевого: запись 69, 2026-08-05 21:00:24 UTC - для
человека это 6 августа. Наивные версии всех трёх мест были написаны нарочно
и увидены красными, прежде чем чинились.
Старые сторожа не выброшены, а перенесены: закладки по каналам переехали в
отчёт вместе с проверкой на 120 строк, отбор ленты по каналу - в проверку
ленты. Каждый переезд назван поимённо.
Проверено: 461 сторож сервера, 2135 сторожей экрана, контроль типов ноль,
статанализ ноль, форматтер чисто, сборка фронта проходит.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Портал давал человеку два обещания про запись его разговора — «живёт месяц и
стирается сама» и «потребуешь удалить свои данные — удалим и голос». Обе чистки
были построены и обе ходили в хранилище с именем obzvon_zapisi, которого в
настройках НЕ СУЩЕСТВОВАЛО. Врать они не врали — честно отвечали «не смог
удалить файл», — но удалять им было нечего. Оба обещания были бумажными.
Построено:
• config/filesystems.php — диск obzvon_zapisi. Драйвер переключается одной
переменной окружения OBZVON_ZAPISI_DRIVER: в день покупки объектного
хранилища меняется настройка, а не код;
• ObzvonZapisHranilishche — единственная дверь к звуку. Приём с проверкой,
открытие ПОТОКОМ со следом в журнале ПДн ДО чтения, ссылка со сроком в час
там, где хранилище её умеет, и NULL там, где не умеет;
• PereveztiZapisJob — переезд записи с сервера робота. Не воскрешает стёртый
голос, не создаёт вторую копию, не оставляет сироты при проигранной гонке;
• ZvukovoyFayl — одно место, где живёт знание «что такое запись разговора»:
расширения, подписи форматов, граница пустой записи в 44 байта.
Три замка изоляции, и все три нужны: RLS на строке, явная сверка клиента и
сверка САМОГО ПУТИ — recording_path заполняет чужая машина, и путь вида
7/../8/golos.wav увёл бы в папку другого клиента при законной сверке клиента.
Исправлена ложь в бумаге. В комментарии диска obzvon_materialy было написано,
что вызов url на закрытом диске бросит исключение и публичной ссылки не
появится. Замерено живым запуском: у местного драйвера url не бросает НИКОГДА,
а молча отдаёт /storage/путь. Ссылка ведёт в папку публичного диска, где записи
нет, — вреда нет, но и обещанной защиты нет. Настоящая защита в том, что url не
зовёт никто.
Ловушка круга: проверка З-2.2 утверждала, что хранилища нет, и покраснела бы от
самой постройки. Вычеркнута не была — переписана. Она стерегла живое: пометить
строку стёртой при живом файле значит выкинуть её из указателя под чистку
навсегда. Теперь «файл жив, а убрать не вышло» строится честно — подставлено
хранилище, у которого delete отвечает отказом.
22 новых сторожа, каждый показан красным в пять заходов. Убрана подмена
настроек в заготовке z22DiskZapisey — пять сторожей З-2.2 теперь охраняют и
наличие хранилища тоже.
Прогон обзвона 212 из 212, статанализ 0 ошибок.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Три правки по замечаниям владельца от 06.08.2026.
1. Кошелёк переехал из «Рекламных возможностей» в «Финансы», рядом с
«Биллингом» — это деньги, а не рекламный канал. Перенесён в обоих меню:
боковая панель и мобильное «Ещё».
2. Каждое списание теперь называет канал, номер и название кампании:
«Яндекс Аудитория · кампания №21 «Я4 приёмка — впритык…»». Раньше все до
одного писались немой фразой «Списание за рекламу (факт)». Подпись
собирается при показе, поэтому заговорили и уже записанные строки —
летопись задним числом не переписывается, это финансовый документ.
Попутно закрыта ловушка: номера кампаний у Яндекса и у СМС идут по разным
счётчикам и совпадают. В боевой летописи есть и «кампания №4» от СМС, и
рекламные — различить их было нечем. Теперь разводит канал в подписи.
Поправлен падеж: было «Заморозка снята — кампанию №13».
3. Закладки по каналам с суммой трат прямо на закладке: Всё 157,74,
Яндекс Аудитория 130,74, Рассылка СМС 27,00. Тратой считается только
списание — заморозку ещё можно вернуть, что и случилось с 724 руб.
Суммы считаются по ВСЕЙ летописи, а не по сотне отдаваемых строк: иначе у
давнего канала цифра тихо усохла бы при переполнении ленты. Отдельный
сторож создаёт 120 свежих движений и проверяет, что давние не пропали.
Сторожа: 6 на сервере, 5 на экране, каждый сначала увиден красным.
Рекламный блок 441 зелёный, фронт 2108 зелёных, статанализ ноль, типы чистые.
Приёмка нашла три дыры, все в сторожах, а не в коде. Код не тронут ни одной
строкой: сверено пустым git diff по всем моим файлам.
Дыра 1 — срок можно было зашить в код, и никто бы не заметил. Прежний сторож
проверял, что строки настроек ЗАВЕДЕНЫ, а не что их ЧИТАЮТ. Заведены два
сторожа поведения: настройка, отличная от умолчания, обязана менять судьбу
записи. У материалов клиента этой дыры нет — чистка там настроек не читает,
а читатель настроек накрыт чужим сторожем круга З-2.3, проверено тем же ножом.
Дыра 2 — повторный запуск. Два сторожа: второй заход не падает, строку не
переписывает и не дописывает в журнал ПДн лишнюю запись об уже стёртом.
Половина сторожа, сравнивавшая столбцы, обманывалась одной секундой —
добавлен ctid, прибор «трогали ли строку вообще».
Дыра 3 — объём. Мерка построена другая, чем просила приёмка, и в отчёте
объяснено почему: запросы обязаны расти с числом строк, иначе придётся
отказаться от правила «не стёрся файл — строку не трогаем». Ловим лишний
запрос в переборе: цена строки прибита к четырём и обязана совпадать на
двух объёмах. Плюс сторож на потолок за заход — его не было вовсе, а именно
он защищает ночь от полумиллиона просроченных строк.
Полный прогон: 4992 на входе, 4998 на выходе, красных ноль.
Записи разговоров и записи, которые клиент приносит для обучения робота,
умели приниматься и получать дату истечения — а стирать по этой дате не умел
никто. Любая попавшая к нам запись лежала бы вечно при обещанном сроке.
Построено:
- obzvon:chistka — три ступени по одной строке звонка: месяц звук, три месяца
расшифровка, дальше ничего. Строку не удаляет никогда: вместе с ней ушли бы
деньги, и клиент не смог бы спросить «за что вы с меня взяли».
- obzvon:chistka-materialov — чужие голоса: звук 30 дней, расшифровка полгода,
два срока врозь. Кроме этой команды их не убирает ничто.
- SrokiZvonka — правило срока в одном месте: им пользуется чистка и им же
обязана пользоваться звонилка волны 3, когда будет проставлять срок.
- Два ключа сроков звонков в system_settings. Новых таблиц нет.
- Обе команды в расписании и в списке сторожа пульса: команду, которой в
списке нет, scheduler:check-heartbeats не проверяет вовсе.
Отдельно закрыты два ложных отчёта, которых в задании не было:
- чистка не ставит след удаления там, где предмета чистки не было вовсе —
иначе карточка сказала бы человеку про текст, которого не существовало;
- строка с непроставленным сроком не пропускается молча: срок досчитывается
от начала разговора, в строку не пишется, число таких строк называется вслух.
Файл не стёрся — строку не помечаем стёртой вовсе: иначе запись выпадает из
указателя под чистку навсегда и её не найдёт уже ни один прогон.
19 сторожей, каждый показан красным. Полный прогон свой рукой.
Приёмка вскрыла, что первый заход воспроизвёл ту же беду на другом основании.
1. Невод по тексту не мог найти НИЧЕГО по построению.
`obzvon_materialy_klienta.transcript` не пишет никто: расшифровщик живёт в
З-4.2, волна 4. Значит сводка честно говорила "материалы клиента=0", пока
голос человека лежал на диске 30 дней. Теперь портал считает отдельным числом
записи, которые проверить НЕЧЕМ - звук жив, расшифровки нет и не было, - и
говорит это тревожной строкой.
🔴 Условие уточнено против предложенного: добавлено transcript_deleted_at IS
NULL. Без него строка, у которой расшифровку стёрли мы сами, а файл убрать не
смогли, попадала бы в оба числа сразу. Доказано надрезом.
2. Изоляция клиентов не сторожилась ничем. Соединение обходит защиту строк, значит
отбор по клиенту в коде - ЕДИНСТВЕННЫЙ замок. Сняв его, приёмщик оставил все
13 сторожей зелёными. Заведены сторожа на все три таблицы.
3. Своя находка того же класса: читатель флага сверял телефон точными написаниями
с колонкой, которую заполняет ЧЕЛОВЕК руками. На записи "8 (900) 123-45-67"
он молча отвечал "звонить можно" тому, кто потребовал прекратить обработку.
Сверка переведена на хвост из десяти цифр - и там, и в переходнике.
4. Экран админки показывал зелёную галочку "выполнено" и пустое поле
"Webhook-логов", а про обзвон и про нестёртые файлы молчал. Это видимая
половина той же неправды: тот, кто жмёт кнопку, и есть тот, кто обязан пойти
проверить руками. Экран называет обзвон поимённо, при нестёртых файлах и
непроверяемых записях галочки нет вовсе - вместо неё тревога. Мёртвое поле
убрано вместе с полем в типе ответа.
Сторожа: 13 -> 19 на сервере, плюс 5 экранных. Каждый показан красным семью
надрезами по коду.
Отчёт: docs/superpowers/priyomka/stroyka-2/z-2-5-otchyot-2026-08-05.md
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Портал ставил обращению отметку "выполнено", а голос человека оставался лежать:
наша запись месяц, наша расшифровка три месяца, чужая расшифровка полгода.
Это не недоделка, а машинный ложный отчёт - портал утверждал обратное сделанному.
Бьёт по самым беззащитным: люди на чужих учебных записях согласия нам не давали.
Построено:
- App\Services\Obzvon\ObzvonErasureAdapter - стирает звук, расшифровку и телефон
в obzvon_calls, телефон в obzvon_number_results, расшифровку и звук в
obzvon_materialy_klienta. Строку звонка НЕ удаляет: вместе с ней ушли бы деньги.
- App\Services\Obzvon\ObzvonZapretObrabotki - первый в портале читатель флага
processing_restricted. Отвечает "звонить можно/нельзя" и называет причину словами.
- tests/Feature/Obzvon/ObzvonStiraniePoTrebovaniyuTest.php - 13 сторожей,
каждый показан красным девятью надрезами по коду.
Правлено точечно: PdErasureService зовёт переходник внутри той же транзакции и
включает его числа в сводку обращения.
Сверх задания, названо в отчёте:
- стирается сам телефон в obzvon_calls, а не только звук с расшифровкой;
- стирается номер в obzvon_number_results - это список, ИЗ КОТОРОГО РОБОТ
НАБИРАЕТ. Оставить там номер значило бы позвонить человеку снова после того,
как портал отчитался "выполнено";
- поиск в чужих материалах идёт по нескольким написаниям телефона, а не по
одному точному: в портале номера лежат и с плюсом, и без.
Границы соблюдены: стоп-листы не тронуты, новых таблиц и миграций нет, боевого
сервера не касался.
Отчёт: docs/superpowers/priyomka/stroyka-2/z-2-5-otchyot-2026-08-05.md
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Клиент отдаёт нам записи СВОИХ холодных разговоров. Это голоса живых людей,
которые об этом не знают и согласия нам не давали. До этой правки принять их
было негде и нечем: положить так, чтобы клиент А не открыл запись клиента Б,
было некуда; срока жизни у записей не было; обращение к чужому голосу не
оставляло следа.
Что заведено:
- таблица obzvon_materialy_klienta — канон в db/schema_modules.sql раздел 24,
журнал схемы v9.70. Изоляция клиентов защитой по строкам: ENABLE + FORCE,
политика с USING и WITH CHECK. DELETE не выдан никому — строка остаётся
носителем надписи «удалены по сроку хранения»;
- ДВА срока, а не один — решение владельца Р82: звук 30 дней, расшифровка
шесть месяцев. Обе даты истечения NOT NULL: строка без даты означает голос,
которого не найдёт ни одна чистка. Сами сроки — в настройках портала,
не в коде;
- два следа удаления врозь: команда чистки из З-2.2 обязана уметь стереть
звук, не тронув текст. Два замка не дают строке врать «стёрто», пока файл жив;
- закрытое хранилище obzvon_materialy: без ключа url и с serve=false, корень
под storage/app/private. Публичной ссылки на чужой голос не появится даже
по ошибке;
- служба MaterialyKlientaService: приём с отклонением чужого формата и битого
файла, открытие с явной сверкой клиента поверх защиты по строкам, счёт
записей без нижней границы — три берём, двадцать берём, ноль пускаем
вторым путём;
- отказ MaterialNePrinyat отделяет наш сочинённый текст от дословного
показания системы видимой границей. Настоящая причина не выбрасывается;
- след в журнале ПДн pd_processing_log готовым сервисом PdAuditLogger —
на приём и на каждое открытие.
Стирает НЕ эта правка, а З-2.2, и она не построена. До её постройки материалы
не исчезнут — это известно и так задумано.
При накатке на боевой ОБЯЗАТЕЛЕН перезапуск db/03_service_bypass_policies.sql:
новой RLS-таблице мало политики и прав, иначе служебная роль молча правит
НОЛЬ строк.
Тронут один чужой сторож: SchemaDeltaTest, счётчик таблиц канона модулей
42 в 43. Так же его правили соседи на З-1.1 и З-1.3 — счётчик обязан расти
вместе с каноном, иначе прогон красный у всех.
Осталось открытым: от какой даты считать полгода — считаем от загрузки,
столбец recorded_at заведён под будущий ответ владельца.
24 сторожа, все 24 показаны красными двенадцатью ножами.
Полный прогон 4954 проверки, 4950 зелёных, 4 пропущено, красных 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Решение владельца Р90 (docs/grilling/2026-08-03-metodika-lena-pod-klienta.md):
цена звонка — два поля, за соединение и за минуту. Оба нуля разом означали
молча бесплатный обзвон: клиент не платит, а оператору за звонки платим мы.
Одна цифра в нуле — осмысленный случай, например не брать отдельно за
соединение.
AdminObzvonTariffController::update() теперь сравнивает обе цены ПОСЛЕ
перевода в копейки и отказывает с объяснением, если обе — ноль. Ловит «0»,
«0.00» и «0.0» одинаково, а не по виду строки. Прежние отказы (пустая,
отрицательная, нечисловая цена) не тронуты.
ObzvonTariffTest.php: +6 проверок «З-1.8». Сторож доказан красным дважды —
до фикса (естественный RED) и после снятия запрета (искусственный RED),
восстановление сверено побайтово через git hash-object.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Задача З-1.5. Обзвон берёт тот же рекламный кошелёк, что СМС, Телеграм и
Директ, — канал ai_call. Своих денежных таблиц не заводит: весь счёт идёт
через публичные методы AdWalletService, второго места движения денег нет.
Две заморозки, как велит план:
- обзвон базы — пачкой, минимальный чек умножить на число номеров;
- авто-обзвон — по поступлению, шаг на каждый пришедший лид.
До запуска клиенту показаны обе цифры — сколько уйдёт под заморозку и
сколько свободных останется. Свободных не остаётся — рядом встаёт названное
словами последствие про рекламу, и запуск требует отдельного подтверждения.
Запуск при этом не запрещается.
Три беды из пяти закрыты здесь.
Беда 1 — дорезерв на ходу не срабатывал ни разу: freeze не складывается, при
уже стоящей броне он выходит первой же проверкой. Бронь теперь растёт
перестановкой release плюс freeze внутри одной внешней транзакции. Общий файл
денег четырёх каналов не тронут ни одной строкой. Цена решения названа в
докблоке: каждый шаг роста кладёт в летопись две строки вместо одной.
Беда 3 — замкнутый круг: обзвон морозит, списание за рекламу тушит Директ,
тушение отпускает бронь рекламы, её доедает обзвон. Круг разорван карантином:
обзвон считает свободным не остаток минус замороженное, а остаток минус
замороженное минус суммы броней, которые сняло именно тушение. Читается по
двум приметам разом — бронь рекламы в состоянии released и кампания-источник
в состоянии остановлено-нет-денег. Ни одной новой таблицы. Деньги выходят из
карантина сами, когда кампания уходит из этого состояния.
Беда 4 — номер менеджера. Формат проверяется замком, принадлежность
телефонам портала — надписью: менеджер клиента вполне может не быть
пользователем, и запирать по этой примете значит запереть почти всех.
Граница замок-или-надпись объявлена владельцем и записана в докблоке.
Беда 5 — вилка стоимости разговора. План называл от 12 до 602 рублей. Это
неверно: предел шестидесяти минут отсчитывается от соединения с менеджером, а
время робота до перевода в него не входит. Настоящий потолок 1252 рубля.
Ни одно из этих чисел в коде не зашито — обе цифры считает тарификатор.
Найдено сверх задания: списание за звонок обязано звать charge с источником
campaign и номером кампании обзвона. С чужим источником бронь не растает,
деньги уйдут с баланса при живой заморозке, свободный остаток просядет вдвое
и выключатель рекламы потушит Директ раньше срока. Контракт для задачи З-1.2
назван в докблоке и охраняется двумя сторожами.
Перевода звонка на живого менеджера в портале нет вовсе, поэтому длинный
разговор и дорезерв на ходу прогнаны на придуманных данных. Доказанными на
живой работе они не называются.
Проверок 25, все зелёные. Сторож показан красным дважды — вырезанием release
из перестановки брони и подменой карантина на ноль.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Прогон Я6 приёмочного листа на боевом, с разрешения владельца трогать кошелёк
тенанта 2. Кошелёк только пополняли — из летописи ничего не вынимали.
Подгонка под «впритык»: свободных было 7 422,76 руб, долили 77,24 руб до
круглых 7 500,00 и завели кампанию на 15 000 показов по 500 руб за 1000 =
смета ровно 7 500,00 руб. Деньги отработали безупречно: запуск при точном
равенстве прошёл, заперли ровно смету, свободных осталось ровно ноль и в
минус не ушли; пауза вернула всё целиком, возобновление заперло столько же;
в летописи три честные строки, строка заморозки одна и та же.
А вот статус врал. На паузу можно поставить и кампанию, которую Яндекс ещё
проверяет — это задумано. Но «Возобновить» ставило running БЕЗУСЛОВНО, и
портал показывал зелёное «Крутится» рекламе, которой Яндекс показываться не
разрешал: показов нет и быть не может. Та же болезнь, что уже ловили на
кампании 6. Потери вердикта нет — часовой обход берёт и крутящиеся тоже.
Починка: «Крутится» ставим только когда вердикт вынесен, иначе возвращаем
«на модерации». Мерка «решено или нет» взята ТА ЖЕ, что у часового обхода:
баннер не решён, пока он не ACCEPTED и не REJECTED. Две разные мерки одного
и того же однажды разойдутся.
Сторож VozobnovlenieNeVryotProStatusTest — три проверки: не врёт на
непроверенной, не ломает одобренную, деньги ведут себя одинаково при любом
вердикте. Принят красным: до починки отдавал running.
Ловушка сторожа записана в журнал: без заглушки на отчёт о показах пауза
отвечает 200, но деньги намеренно не отпускает.
Счёт по комбинациям: закрыты 4 из 9 — Я6, Я7, Я8, Я9.
Прогоны: 430 тестов рекламы зелёные, pint чисто, статанализ 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Беда: правило цены звонка уже написано и работает, а самих двух цифр — за снятую
трубку и за начатую минуту — держать было негде и менять некому. Чтобы поправить
цену, нужен был программист и новая сборка.
Заведена таблица obzvon_tariffs: строка ровно одна на весь портал, замок
CHECK id = 1, деньги целыми копейками — те же единицы, что у строки звонка.
Ручка админки GET и PUT /api/admin/obzvon/tariff правит обе цифры разом:
половина новой пары с половиной старой давала бы тариф, которого никто не назначал.
🔴 Обе цифры назначены владельцем ВСЛЕПУЮ: сколько нам самим стоит звонок, ни разу
не измерено. Оговорка написана в четырёх местах вплотную к самим числам и уходит
в ответ ручки, чтобы её видел человек на экране, а не только программист в коде.
🔴 Смена тарифа НЕ пересчитывает вчерашние звонки: цена и снимок тарифа лежат в
самой строке звонка. Здесь только «сколько будет стоить следующий».
Мусор в цене не принимается: отрицательная, пустая, нечисловая и с третьим знаком
после точки — третий знак молча пропал бы копейкой. Правка оставляет след в
saas_admin_audit_log — кто, когда, с чего на что.
Канон схемы — db/schema_modules.sql раздел 23, журнал — запись v9.69.
Сторожа — app/tests/Feature/Obzvon/ObzvonTariffTest.php, все показаны красными.
План: docs/superpowers/plans/2026-08-04-obzvon-pod-klienta.md §З-1.3.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Беда 1. Сторож закрепил цифру, которой не существует.
В бумагах записано, что крайняя цена одного разговора — 602 руб, и эту цифру по
требованию показывают КЛИЕНТУ перед запуском. Замерил своим прибором на тарифе
2 руб / 10 руб:
робот 40 с + менеджер 60 мин = 612 руб
робот 59 мин + менеджер 60 мин = 1192 руб
крайний случай 3900 + 3600 с = 1252 руб
Причина не в расчёте: предел 60 минут отсчитывается от мига соединения с
менеджером, значит режет только ВТОРОЕ плечо. Первое плечо не ограничено ничем,
кроме тревоги, оба складываются, и настоящий потолок вдвое выше обещанного.
А на секунду дальше крайнего случая расчёт не дорожает, а падает с тревогой —
длинный звонок не выставит счёт, он уронит счёт.
Проверка "за 60 минут, а не за 61" была снята при роботе = 0 — на здоровом
случае, где второе плечо идёт в одиночку. Она верна для своего случая, но
потолка звонка не сторожит вовсе, и своим зелёным закрепляла неверные 602 руб.
Что сделано: проверка не удалена, переименована и снабжена оговоркой, что 602 —
цена этого случая, а не потолок. Добавлены проверки на больном случае — длинные
оба плеча разом: 612, 1192, 1252. Добавлена граница падения с обеих сторон:
ровно на краю считается, на секунду дальше по любому плечу — тревога. Крайний
случай выведен из констант класса, а не прибит числом: расширят допуск —
проверка покраснеет и потолок придётся назвать заново.
Сторож показан красным: заменил в счётчике сложение плеч на
min от суммы плеч и предела — 5 проверок из 25 покраснели, а старая проверка
про 602 руб осталась ЗЕЛЁНОЙ. Возврат сверен по git hash-object.
Правило цены НЕ менялось. Счётчик считает верно.
ВОПРОС ВЛАДЕЛЬЦУ: потолка 602 руб не существует, крайняя цена 1252 руб.
Решать одно из двух — либо исправить цифру в бумагах, либо завести предел на
весь звонок, а не только на второе плечо. Сегодня клиенту обещают одно число, а
списать могут вдвое большее.
Беда 2. Стык двух кругов: имена сошлись, смысл разъехался.
Счётчик писал, что tarificiruetsya: false значит "строки в счёте нет вовсе", и
велел класть это в столбец billable. А billable в базе — признак существующей
бесплатной строки: DEFAULT TRUE плюс CHECK billable OR price_kopecks = 0.
Прочитавший подсказку буквально строку не завёл бы и счёт закрытых номеров
потерял.
Смысл сведён по бумагам, не по догадке. З-3.7 проверка 4: в журнале клиента эти
попытки видны ОТДЕЛЬНОЙ СТРОКОЙ. Проверка 5: строк В СЧЁТЕ нет. Т86 требует
счётчик попаданий в пустоту по каждому человеку — считать нечего, если строк
нет. Значит строка звонка заводится всегда, а на закрытом номере она бесплатная:
billable = FALSE при price_kopecks = 0.
Что сделано: подсказки в счётчике исправлены в трёх местах, смысл billable
назван словами в модели строки звонка тремя случаями. Заведён тест стыка — он
берёт ответ schet, кладёт его в obzvon_calls и читает обратно; проверяет, что
закрытый номер даёт строку, что три дозвона в пустоту дают три строки и ноль
денег, что недозвон и закрытый номер в базе различимы, и что база не примет
бесплатную строку с деньгами.
Единицы: перевод копеек в рубли и правда завёлся вторым местом — свой bcdiv в
модели строки звонка. Схлопнуть его НЕ ВЫШЛО, и это отдельная находка: попытка
позвать ObzvonTarifikator::rubli из модели упёрлась в правило слоёв —
App\Models не вправе зависеть от App\Services, deptrac и ADR-005.
Предкоммитный сторож остановил меня, и правильно сделал.
Раз схлопнуть нельзя — два места привязаны друг к другу проверкой: они обязаны
отвечать одинаково на наборе сумм, включая крайнюю цену звонка. Плюс датчик на
класс беды: ТРЕТЬЕ место деления на 100 по коду обзвона запрещено, оба
известных названы поимённо, и отдельная проверка следит, что список
разрешённых не устарел вслепую.
ВОПРОС АРХИТЕКТУРЕ: правильно было бы вынести перевод копеек в рубли в общего
помощника, доступного обоим слоям. Это правка deptrac.yaml либо новый слой —
решение не моё, оставляю названным, а не сделанным втихую.
Сторожа показаны красными трижды. Первый заход: закрытый номер стал
тарифицируемым и в модель вернулся лишний bcdiv — 4 из 8 покраснели, датчик
назвал точную строку. Второй заход после переделки: у модели сбита точность
перевода — 3 из 9 покраснели. Третий: из списка разрешённых убран один файл —
датчик третьего места назвал ObzvonCall.php и номер строки. Каждый возврат
сверен по git hash-object.
Схема БД и миграции не тронуты.
Прогон Я7 приёмочного листа на боевом: черновик со сметой 15 000 руб при
свободных 9 122,76 руб. Запуск не состоялся правильно — в Директе не создано
ничего, деньги не тронуты, кампания осталась черновиком. А вот текст отказа
доезжал до клиента как есть:
Insufficient balance: price_kopecks=1500000, balance_rub=9122.76
Причина: InsufficientBalanceException наследует RuntimeException, и ручка
запуска ловила его общим уловителем, отдавая наружу getMessage. Кнопка
«Возобновить» этой болезнью не болела — там свой уловитель с человеческим
текстом, и он же показал, что сломан именно запуск.
Починка: отдельный уловитель ВЫШЕ общего. Наружу — только клиентские рубли:
сколько нужно и сколько свободно. Ни копеек, ни английского, ни нашей доли.
Сторож PonyatnyyOtkazPoDengamTest — три проверки: нет английского и копеек,
названы оба клиентских числа, нет цены рекламной площадки. Принят красным:
до починки отдавал ровно техническую строку.
Заодно: срок мобильного прокси продлён до 03.09.2026 — поправлена устаревшая
подсказка в config/services.php. На боевом дата уже заменена, плитка стала
зелёной: «жив, IP 92.36.112.83, до 03.09.2026».
В журнал приёмки записаны прогоны Я7, Я8, Я9 — все три закрыты живьём и
бесплатно — и пересчёт цены комбинаций: деньги уходят только за настоящие
показы, поэтому семь прогонов из девяти стоят ноль. Настоящая стена не
денежная, а временная: сегмент 1653 человека даёт около показа в час, и
«вся смета» это полтора месяца.
Прогоны: 427 тестов рекламы зелёные, pint чисто, статанализ 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Разговор обзвона, начатый при пустом счёте, теперь списывается целиком, счёт
уходит в минус, и долг остаётся за клиентом — решение владельца Р38, требование
Т61а. Числа из спеки: было 12 рублей, списали 102 — на счету минус 90, а в
летописи одна строка на 102.
Разрешение выдано поимённо, белым списком: только ai_call. У яндекса, СМС и
телеграма поведение не изменилось ни на копейку — списание по-прежнему упирается
в ноль. Доказано A/B на одном дереве: 424, 425 и 330 тестов трёх каналов дали
ровно те же числа и с правкой, и без неё.
Проверка платёжеспособности научилась называть причину остановки: долг по
обзвону и непокрытые заморозки — разные вещи. Сам вердикт isSolvent не изменён
ни байтом: по решению Р56 минус гасит клиенту всю рекламу, и письмо задачи З-1.7
обязано назвать обе остановки сразу. Различать нужно, чтобы назвать, а не чтобы
смягчить приговор.
Попутно, и это касается уже работающих денег рекламы: возврат refund был
единственным из пяти методов, кто не ставил контекст клиента. Замерено запуском,
а не чтением — под боевой ролью без контекста метод падал ModelNotFoundException,
а не молчал. Живым деньгам не грозило: единственный зовущий, PollClientSmsDelivery
Command, ставит контекст сам во внешней транзакции. Теперь ставит и сам метод.
Обрезка нулём для трёх старых каналов сохранена намеренно, но перестала быть
молчаливой: на ней пишется предупреждение ad_wallet.charge_clamped_to_zero. До
сих пор съеденная разница между летописью и остатком пропадала без следа.
Сторожа: tests/Feature/Obzvon/AdWalletMinusTest.php и AdWalletMinusRaceTest.php.
Все показаны красными пятью поломками. Гонка проверена параллельным прогоном
шести и четырёх настоящих процессов, а не рассуждением: без замка по строке
кошелька теряется пять списаний из шести.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Задача З-1.1 плана «Обзвон под клиента». До этой правки фичи в портале не было
вовсе, и звонок было некуда положить: нечего списывать, нечего показать клиенту и
нечем ответить через год на вопрос «за что вы взяли с меня деньги в августе».
Заведены две таблицы, обе с защитой по клиентам ENABLE + FORCE:
- obzvon_calls — одна попытка набора. По номеру их бывает до двенадцати, и каждая
своя строка со своим исходом. Держит два плеча врозь: робот с человеком и
человек с менеджером после перевода, у каждого своя длительность и свой признак
«состоялось». Держит два срока хранения и два следа удаления: звук месяц, текст
три месяца, дальше живут сухие итоги.
- obzvon_number_results — единственный итог на всю работу с номером. Именно он
стоит в карточке сделки, а не последняя попытка. Замок — UNIQUE по клиенту,
кампании и номеру: двенадцать недозвонов дают ОДИН итог, а не двенадцать.
Расшифровка лежит в той же строке звонка. Отдельного хранилища под неё нет
намеренно — решение Р50 сняло бессрочную обезличенную расшифровку.
Имя своё, а не call_recordings из закомментированного чертежа: чертёж был про
телефонию вообще и не знает ни статуса обзвона, ни сроков со следом удаления, ни
«почему звонили», ни двух плеч, ни попыток с итогом. Чертёж не тронут.
Цена звонка запоминается в строке вместе со снимком тарифа, а не вычисляется из
действующего тарифа при показе: иначе смена тарифа задним числом перепишет
вчерашние счета. Образец — lead_charges.tier_no + price_per_lead_kopecks. Деньги
целыми копейками.
Строку звонка никто не удаляет: право DELETE не выдано ни одной роли. Вместе со
строкой ушли бы деньги, а спросить «за что вы взяли» клиент вправе и через год.
Канон — db/schema_modules.sql, новый раздел 23. Тело db/schema.sql таблиц не
получает: оно исполняется первой миграцией, и таблицы дельта-миграций в него
класть нельзя — это прямо запрещает сторож канона. В db/schema.sql добавлена
только пометка над закомментированным чертежом call_recordings: чертёж не
использован и не будет, модуль живёт в obzvon_*, и перечислено, чего чертёж не
знает. Чтобы следующая смена не воскресила его по недоразумению.
Журнал схемы — запись v9.68, номер свободен, столкновения нет.
Проверено: 21 сторож, в том числе изоляция клиентов живым запросом из-под роли
crm_app_user, а не глазами по миграции; служебная роль после перезапуска
db/03_service_bypass_policies.sql правит именно ту строку, а не «успешно ноль»;
откат миграции хвостов не оставляет; squawk с конфигом проекта чист; статанализ
по новым файлам ноль. Каждый сторож показан красным.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Из 29 карточек приёмки Яндекса закрыты 27. Остались две, и обе ждут не работы,
а события: живого отказа всех объявлений разом и платных комбинаций Я1-Я9.
Нашлась вторая за день тихая беда. Слушатель остановки рекламы был подписан на
событие ДВАЖДЫ: Laravel 11 сам находит слушателей в app/Listeners по типу
параметра handle(), а в провайдере он вдобавок прописан руками. Остановка не
бесплатная: по каждой кампании клиента она идёт в Яндекс за числом показов,
платит за показанное и глушит кампанию, - и всё это делалось по два раза.
Деньги уцелели по случайности: списание идемпотентно по ключу показов, снятие
заморозки тоже, а второй заход не находил кампаний, первый уже увёл их из
работы. Ручная подписка убрана.
Найдено не глазами, а вырезанием: новый сквозной сторож Я-Б3 не покраснел,
когда подписку отключили. Если бы регистрация была одна, он обязан был.
Замерено на живой кампании #13, без единого рубля сверху:
- Я-Д3: показано 21, списано 10,50 руб = 21 x 500/1000; по смете было бы 850;
- Я-М2: кампания running, заморозка 839,50 = 850 - 10,50, показы идут;
- Я-Х4: задача из очереди под боевой ролью без контекста клиента видит
1 кампанию служебным путём и 0 обычным - тот самый молчаливый ноль,
ради которого служебный путь и заведён.
Новые сторожа, каждый принят красным:
- Я-Б2: денег ровно на смету - запуск проходит, вторая кампания уже нет;
- Я-Б3: деньги кончились - кампания РЕАЛЬНО встала и владельцу ушло письмо;
- Я-М4: пока вердикта нет, не двигаются ни заморозка, ни списание;
- Я-М5: после «Исправить» и повторного запуска заморозка ОДНА;
- Я-Х2: десять подделок отбиты, чужой номер клиента ничего не меняет;
- Я-Д1: заморозка равна смете копейка в копейку, включая некруглую;
- Я-Д2: сплошной обход всех семи читающих путей - наценка не видна нигде
(прежний сторож проверял один путь из семи);
- остановка рекламы подписана ровно один раз.
Прогоны: 424 теста рекламы зелёные, статанализ 0, формат чистый.
Журнал результатов приёмки дописан разделом на 05.08 - что закрыто, чем
закрыто и чего ждём.
Живой замер 05.08.2026: любой вошедший клиент, запросив несуществующий или чужой
номер, получал в ответ «No query results for model [App\Models\AdCampaign].»
Три беды в одной строке. Английский язык — клиенту он ничего не говорит. Названы
внутренние имена: и класс, и раскладка папок портала. И сообщение подтверждало,
что за номером стоит именно кампания, — а на чужой номер портал обязан отвечать
ровно так же, как на несуществующий, ничего не подтверждая.
Само имя класса не отмычка, но экономит время тому, кто ищет вход: видно, на чём
написан портал, как называются сущности и где их искать. Теперь на любой
ненайденный путь, просящий JSON, уходит «Не найдено.» — и на чужой номер ответ
байт в байт тот же, что на несуществующий. В журнал прежний текст пишется без
изменений: разработчику он нужен.
Место общее на весь портал: так отвечал КАЖДЫЙ firstOrFail() во всех разделах.
Ловушка: обработчик на ModelNotFoundException не срабатывает никогда — Laravel
успевает завернуть её в NotFoundHttpException раньше. Поймано тем, что сторож
остался красным с тем же самым английским текстом.
Второе: под слотом баннера есть красная подпись — единственное место, где клиент
узнаёт, почему картинка не загрузилась. Сторожа на неё не было ни одного. Пропади
она — заметить было бы некому: клиент жмёт «загрузить», ничего не появляется, и
никакого объяснения. Написаны три: подпись показывает слова сервера, а не общую
отговорку; пока отказа нет, подписи нет вовсе; сервер промолчал — портал всё равно
объясняет.
Ловушка: extractErrorMessage достаёт слова сервера только у настоящей ошибки axios.
Подделка без isAxiosError проваливается в общую отговорку, и сторож зеленеет, не
проверив того, ради чего написан.
Прогоны: 3909 тестов Feature зелёные, vue-tsc чисто.
Заодно промт смене 21 дописан утренними итогами: выкат сделан, восемь карточек
пройдены, снята ложная тревога про главную ветку, добавлен разбор чужого слияния.
Приёмка Яндекса, восемь бесплатных карточек. Две проверены на живых деньгах
боевой кампании #13, шесть — сторожами на тестовой базе.
Нашлась настоящая дыра. Если запуск оборвался на полпути, деньги остаются
заперты нарочно: в Директе кампания уже завелась и может крутить показы,
клиент дожмёт «Запустить». Но выглядит она обычным черновиком, а черновик
разрешено удалить. Кампании после этого нет, а запись о заморозке ссылается
на её номер — возврат ищут по кампании и не находят. Деньги клиента заперты
навсегда, молча, без единой ошибки в журнале. Теперь такой черновик удалить
нельзя, и портал объясняет, что сделать вместо этого.
Второе: портал отказывал на языке разработчика — «Поле file должно быть
файлом одного из типов», «не может быть больше 10240 Кбайт». Клиент не знает,
что такое поле file, и не считает мегабайты в килобайтах. Четыре отказа по
файлам переписаны по образцу, который в этом же модуле уже был: «Нужен ровно
300×250. Вы загрузили 100×100.»
Сторожа приёмки:
- чужая кампания отдаёт 404 на всех двадцати одном пути, ни одного 403;
- сколько раз ни жми «Запустить» — заморозка одна;
- отклонённую в обход «Исправить» не запустить, денег не трогает;
- черновик с запертыми деньгами не уносит их;
- списание посреди запуска другой кампании — касса сходится;
- отказы по файлам сказаны человеческим языком.
Каждый принят красным: изоляция проверена подстановкой хозяина вместо чужого,
защита запуска — вырезанием прямо в коде, гонка — снятием списания из середины.
Прогоны: 413 тестов рекламы зелёные, статанализ 0.
Заодно форматирование теста минимума площадки — форматтер перенёс длинное имя
класса в шапку уже после записи 80987996e.
Имя согласовывает ОПЕРАТОР, и у каждого оно своё. Клиентская рассылка несла одно
имя всем каналам, а умолчанием ему служило `services.sms.mts.naming` — то есть имя,
согласованное с МТС, подставлялось Теле2 и подставилось бы Мегафону и Билайну.
Живой отказ Теле2 `invalid_source_address` 04.08.2026 — ровно этот механизм. На бою
у клиентов заведено НОЛЬ своих имён, значит это касалось каждой отправки.
Появился ClientSmsSenderResolver: спрашивает имя у той сети, чей номер. Пустая
строка на выходе — не «имени нет», а «своего имени для этой сети нет, канал
подпишется собственным согласованным именем из настроек».
Переведены на него оба места отправки: рассылка (SendClientSmsCampaignJob) и
авто-СМС по сделке (SendAutoSmsForDealJob).
Универсальный СМС-центр передавал имя КАК ЕСТЬ — теперь тоже падает на своё
(SMSC_NAMING). Раньше пустое имя туда не приходило никогда, с этой правкой стало
бы приходить: ушли бы СМС без отправителя.
Заплатка внутри канала Теле2 (приведение написания к своему регистру) остаётся, но
больше не несущая: она чинила следствие в одном канале, а причина была общая.
Экранное имя (ResolvesClientSmsSenderName) НЕ трогали — это запись намерения, а не
то, чем подпишется сеть; в его шапке теперь написано почему, чтобы не «починили»
обратно.
🪤 Урок по дороге: авто-СМС ловит любую ошибку и молча выходит. Справочник имён был
передан не в ту функцию — вместо падения получилась ТИШИНА, не уходило ничего.
Поймали три чужих теста, до того зелёных.
Проверено: 4614 тестов, упал один чужой (ExampleTest — в этой папке не собран
фронтенд). Статанализ 0. Стиль правленых файлов чист.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Три починки, каждая сперва увидена красной.
1. Мастер обещал показы как факт: «~8 150». Живой замер 04.08 по кабинету:
чужая кампания с бюджетом 13 000 руб за четверо суток набрала 457 показов
и потратила 138 руб — деньги не кончились, кончились люди. Число из мастера
это потолок, до которого кампания почти наверняка не дотянет. Стало
«не больше 8 150» плюс объяснение: упрётся в список, а не в деньги;
за несостоявшиеся показы деньги вернутся. Поправлено на шаге частоты
и в сводке перед отправкой.
2. Остановка «нет денег» возвращала заморозку, НЕ заплатив за уже показанное.
Списание делает часовая задача, а она берёт только кампании со статусом
«крутится» — остановленную пропускала навсегда. Показы последнего часа
уходили клиенту даром, а Яндексу за них платили мы. Та же дыра, что чинили
в паузе, только через другую дверь. Теперь: сперва заплати, потом отпускай;
не узнал число показов — не отпускай вовсе.
3. Остановка ходит в Директ по два раза на каждую кампанию, и делала это
ВНУТРИ денежной транзакции — замок строки висел всё время сетевых запросов.
Вынесено наружу: денежная операция закрывается, и только потом остановка.
Плюс минимум площадки в мастере. Директ не берёт кампанию дешевле 300 руб
за каждый календарный день и отвечает по-английски на последнем шаге, когда
клиент уже пятнадцать часов собирал аудиторию. Теперь мастер предупреждает
заранее и по-русски. Формула вынесена в YandexMinimumSpend и одна на портал:
ею пользуются и запуск, и мастер — две копии однажды разошлись бы.
Сторожа: 7 новых на бэкенде и фронте, каждый принят красным.
Прогоны: реклама 458 тестов зелёные, Larastan 0, vue-tsc чисто.
Приёмка на бою вскрыла, что хвост был закрыт только на вид: строку писали
уровнем «к сведению», а на боевом LOG_LEVEL=warning — всё, что ниже, молча
отбрасывается. Запись не появилась бы в журнале НИКОГДА, и круг «список нельзя
заполнить, потому что адреса неоткуда взять» остался бы замкнут — только теперь
незаметно, с виду починенный.
Замерено живым вызовом приёмника на бою 04.08: соседнее предупреждение про
незнакомое сообщение в журнал легло, а строка «к сведению» — нет.
Уровень поднят до «предупреждение». Тест теперь стережёт именно уровень, и в нём
записано, почему: не «так решили», а «иначе бой выбросит».
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Полная проверка истории находила 5 «незамаскированных телефонов» в
docs/superpowers/2026-07-27-PRIEMKA-client-sms-fixes.md и не пускала push НИКОГО.
Замерено: все пять с префиксом 7999 — синтетические по правилу проекта
(«реальные НИКОГДА»). Настоящих персональных данных нет.
Причина: список исключений покрывает подпапки docs/superpowers/{plans,runbooks,
specs,audits,findings,prototypes}, а этот документ лежит ПРЯМО в docs/superpowers/
и под них не подпадал. Файл из истории не убрать, не переписав общую ветку, —
значит, чинить надо список.
Разрешение узкое: только docs/superpowers/ГГГГ-ММ-ДД-PRIEMKA-*.md. Прочие файлы
корня docs/superpowers/ проверяются по-прежнему.
Проверено: до правки — 5 находок, после — «no leaks found» на 7589 записях.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Наша главная и серверная разошлись 02.08. Свелось само везде, кроме
docs/observer/STATUS.md — он авто-генерируемый, расхождение только в отметке
времени и списке процессов; взят наш, он свежее.
Правки соседей не тронуты: routes/web.php и остальные файлы сведены построчно.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Пока SMS_T2_WEBHOOK_IPS пуст, приёмник никому не отказывает — а значит, и записи
«звонили с такого-то адреса» в журнале не появляется. Круг замкнут: список нельзя
заполнить, потому что адреса неоткуда взять, а взять неоткуда, потому что список пуст.
Теперь при пустом списке каждый принятый звонок пишет строку
client_sms.delivery_hook_open_gate с адресом. Как только список задан — смолкает,
шуметь в журнале постоянно не будет.
Памятка: откуда забрать адреса одной командой; в «Открытом» отмечено, что круг
разорван, и отдельным пунктом записан незакрытый долг про имена отправителей по
каналам.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Третий и последний слой той же поломки, вскрыт живым замером на боевом.
После прошлых починок отчёт наконец стал приходить, и в теле оказалось не
только число: у кампании #11 — «1» и следом строка-итог, у #6 — «Total rows: 0».
Причина: заголовок отключения итоговой строки у нас назывался
skipReportSummaryRow, а Директ понимает skipReportSummary. Наш заголовок он
просто не знал и приписывал итог.
Заодно уточнено, что считать ошибкой. Пустой отчёт при успешном ответе — это
законный НОЛЬ показов: у кампании нет ни одной строки. «Отчёт ещё строится»
отличается кодом ответа 201, а не пустотой. Прежняя строгая проверка «пусто —
значит ошибка» ломала бы списание по каждой новой кампании.
Число читаем из первой строки, даже если итог всё же пришёл — на случай, если
Яндекс снова поменяет правила.
Три новых сторожа: заголовок skipReportSummary в запросе и число из первой
строки; пустой отчёт при коде 200 — ноль; неготовый отчёт при коде 201 — ошибка.
Прогон: 397 сторожей блока рекламы, статанализ 0, стиль чист.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Продолжение прошлой починки. После неё ошибка стала слышна и назвала себя
точно: «пустой ответ — отчёт ещё строится». Живая проверка на боевом: Директ
на отчёт за всё время отвечает кодом 201 «принял, строю» даже с режимом
ожидания, а готовый надо забрать повторным запросом.
Ключевое: забирать надо ПОД ТЕМ ЖЕ ИМЕНЕМ — имя отчёта это ссылка на заказ, а
не подпись. Новое имя на каждой попытке заказывало бы новый отчёт, и портал не
дождался бы никогда. Между разными вызовами имя, наоборот, обязано меняться,
иначе Яндекс отдаст готовый отчёт из кэша со вчерашними показами.
Шесть попыток с паузой в секунду. Больше ждать нельзя: этим же путём ходит
«Пауза», а там за кнопкой стоит человек. Не дождались — по-прежнему громкая
ошибка, а не ноль.
Два новых сторожа: отчёт строится и забирается тем же именем, и отчёт так и не
построился — это ошибка.
Прогон: 395 сторожей блока рекламы, статанализ 0, стиль чист.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Самая тяжёлая находка смены, вылезла из вопроса владельца «показано 0, почему?
там один же есть» — из сверки карточки с кабинетом глазами.
Замер на боевом: запрос портала к Директу отвергается с кодом 400 — «В params
отсутствует обязательное поле IncludeVAT». А код ответ не проверял: брал тело,
переводил в число и получал ноль. То есть «показов не было» — не факт, а
проглоченная ошибка.
Чем накрывается. Это единственный источник числа показов, и у него две работы:
число «Показано» на карточке и основание списать деньги за открученную рекламу.
Значит ни за один показ клиенту не выставлялся счёт — реклама шла за наш счёт.
И тихий ноль опасен сам по себе: затирает настоящее число на карточке, а при
истёкшем сроке закрывает кампанию с возвратом всей заморозки.
Почему не поймали раньше: ноль выглядит как «показов ещё не было» — законное
состояние новой кампании. Отличить «не было» от «не смогли спросить» портал не
умел. На этом месте с самого начала висел TODO про сверку формата перед боем.
Три правила и три сторожа:
- IncludeVAT в запросе — без него Директ отвергает запрос целиком;
- processingMode auto — иначе отчёт строится в фоне и приходит пустое тело;
- громкая ошибка вместо тихого нуля при не-2xx и нечисловом ответе.
Наверху ошибка уже обработана правильно: обход пишет предупреждение и идёт
дальше, а «Пауза» при такой ошибке не отпускает деньги.
Четвёртая мина, вскрытая сторожем уникальности: имя отчёта строилось из секунд,
два вызова в одну секунду получали одно имя и второй читал кэш первого. Добавлен
случайный хвост.
Настоящий ноль из Яндекса остаётся нулём — отдельный сторож держит и это.
Прогон: 393 сторожа блока рекламы, статанализ 0, стиль чист. Схема не менялась.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Раньше «Ниша» стояла только на «Воронке отдела». Теперь тот же фильтр есть
и на личном экране менеджера — он обзванивает подряд одну нишу, ему нужнее всех.
Список ниш у менеджера считается по ЕГО карточкам: сужение «только свои»
наложено раньше, поэтому чужая ниша в список не попадает, а ?rubric= не может
стать лазейкой к чужой воронке — на это есть отдельная проверка.
Поведение обоих экранов задаёт один кусок кода, composables/prospectRubricFilter.ts.
Двумя копиями «Без ниши последним» и самосброс исчезнувшей ниши разъехались бы
на первой же правке.
Проверено: Pest по продажам 525 зелёных, Vitest по фронту 2077 зелёных,
vue-tsc и Larastan чисто.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Владелец сверил карточку с кабинетом Яндекса: в отчёте «Показано 0», а в
Директе за этот день один показ.
Причина наша. delivered_impressions — единственное число, из которого карточка
берёт «Показано», — обновляется только внутри ChargeCampaignSpendJob, а он ходил
раз в сутки в 04:20. Кампанию #11 запустили в 04:39, на двадцать минут позже
обхода, и до следующего утра клиент видел ноль при идущих показах.
Обход стал ежечасным. Чаще списывать безопасно по устройству счётчика: он
идемпотентен по ключу «yandex-imp:кампания:показы» и монотонен — уходит только
прирост, повторный прогон с тем же числом показов денег не трогает. Сторож
держит расписание 0 * * * *, принят красным на «20 4 * * *».
Побочная польза: нехватку денег AdStopAll заметит через час, а не через сутки.
Полностью в ноль это не выводит — у самого Директа статистика приходит с
задержкой, и «Показано» всегда чуть отстаёт от кабинета.
В отчёте приёмки записан замер рынка: цена в этом кабинете около 250-300 рублей
за тысячу показов при нашей прежней ставке 72, и потолок показов упирается не в
деньги, а в размер списка — кампания с бюджетом 13000 рублей за четыре дня
набрала 457 показов и потратила 138.
Прогон: 388 сторожей блока рекламы, статанализ 0, стиль чист. Схема не менялась.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Вопрос владельца вскрыл дыру в деньгах. Списание за показы делает суточная
задача в 04:20 и берёт только кампании со статусом «крутится». Кампанию,
оставленную на паузе, она не видит никогда — значит показы, сделанные с
последнего списания, не оплачивались вовсе. До суток рекламы клиент получал
бесплатно, а Яндексу за неё платили мы.
Дыра открывалась только если после паузы не возобновить: при возобновлении
пропущенное списывается разницей.
Теперь «Пауза» после остановки рекламы спрашивает у Директа накопительное
число показов и списывает за них, и только затем возвращает остаток
заморозки. Лишнего обращения это не стоит — в Директ мы в этот момент и так
ходим останавливать рекламу.
Три случая разведены:
- обычный: списали, поставили паузу, вернули остаток;
- списание само закрыло кампанию по смете или сроку — уходит в «Показы
откручены» по ВЫХОДУ 1, паузу поверх не ставим, пятого выхода снятия
заморозки не появляется;
- не узнали число показов — рекламу остановили, но деньги НЕ отпускаем и
говорим об этом клиенту.
Сторож принят красным: до починки списание давало 0.00 вместо 300.00 рублей
за 2500 показов.
Найдена соседняя дыра того же рода при «Остановлено, нет денег» — там
слушатель работает изнутри денежной транзакции, и обращение к Яндексу внутри
неё держало бы замок строки на время сетевого запроса. Разобрано в отчёте,
чинить отдельным заходом.
Прогон: 387 сторожей блока рекламы, 177 денежных, статанализ 0, стиль чист.
Схема не менялась.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Канал «SMS-Таргет» (target.t2.ru, HTTP API v2, Basic Auth) обеими половинами:
- отправка через POST /send_message (msisdn числом, shortcode, text);
- опрос судьбы GET /send_message/message-id-{id} — по одному сообщению,
пакетного запроса у t2 нет;
- приёмник обратных звонков GET|POST /api/webhook/t2-delivery/{secret}
со строками ?id=&status=&parts= — форма другая, чем у МТС.
Ловушки, закрытые тестами:
- слово status в ответе встречается дважды: снаружи «запрос удался»,
внутри судьба сообщения. Спутать = объявить доставленным всё подряд;
- отказ приезжает с HTTP 200 — судим по телу, не по коду;
- незнакомое слово статуса судьбу НЕ меняет, сохраняется в delivery_raw.
За «не доставлено» клиенту возвращаются деньги (В-198) — выдумка двигала
бы рубли;
- t2 наш ответ не проверяет и повторов не делает: пропущенный звонок потерян,
поэтому опрос client-sms:poll-delivery остаётся обязательным.
Деньги приёмник не двигает — возврат живёт в команде опроса (В-146).
Защита приёмника как у МТС: секрет не короче 32 знаков (пустой = закрыт
наглухо), список адресов отправителя, счётчик обращений; чужому 404, не 403.
Канал ВЫКЛЮЧЕН и живьём не пробован: имя отправителя Liderra.ru (заявка
№ 31121) на согласовании, без него t2 не выдаёт логин с паролём.
Порядок включения — docs/superpowers/runbooks/2026-08-04-vklyuchenie-kanala-t2.md
Миграций нет: графы судьбы появились ещё при МТС. Теле2 уже был в списке
разрешённых операторов и в тарифах админки — новых экранов не нужно.
Проверено: 78 тестов зелёные, Pint чист, статанализ по ВСЕМУ проекту 0 ошибок,
маршрут зарегистрирован. Против main — только вставки, удалений нет.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Владелец, глядя на две живые карточки: «статус на модерации — не понятно,
идут показы или ожидает! и принято, когда реально отклонили».
Корень: ярлык один на всю кампанию, а объявления внутри в разных состояниях,
и у объявления два независимых признака — прошло проверку и показывается.
Карточка сваливала их в одно слово.
Три починки:
1. Портал спрашивает Яндекс о модерации каждые 15 минут вместо двух часов.
У кампании #11 проверка кончилась между обходами, и клиент полтора часа
видел бы «На модерации» на уже работающей кампании. Сторож держит
расписание */15.
2. Портал считает придержанные объявления — одобренные, показ которых
Яндекс не включил. Признак уже ставился на объявление, но наверх не
поднимался; теперь в списке есть banners_held. Два сторожа: слепок
боевой #6 даёт 13, слепок #11 даёт 0.
3. Ярлык называет положение дел словами, приписка всегда даёт числа:
«Яндекс проверяет объявления» + «Одобрено 9 из 15, остальные ещё
проверяются»; «Одобрено, но показ не включён» + «Одобрено 13 из 15,
отклонено 2. Яндекс их пока не показывает — причина в сообщениях
кампании»; «Крутится» + «Одобрено 15 из 15». Числа показываются всегда,
а не только при частичном отказе.
Живой замер, ради которого всё это: у боевой кампании #6 все 15 объявлений
выключены при 13 одобренных — Яндекс просит документы, поэтому показов нет
с 30.07. Карточка про это молчала.
Прогон: 386 сторожей блока рекламы, 1974 фронта, статанализ 0, стиль чист.
Схема не менялась. Тесты гонялись на отдельной базе liderra_testing_s19 —
общую соседняя смена сносила трижды за прогон.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>