- в секции [telegram] заведён обязательный ключ enabled: умолчания у него нет, файл без него негоден; bot_token стал только ключом доступа и при enabled = false не читается вовсе, а пустой при enabled = true роняет старт - выключенный вход даёт подъём одним входом без единого обращения к Telegram и записью INFO вместо прежнего WARN: это выбор владельца, а не отклонение - отказ разбора файла настроек больше не пересказывает toml — её ParseError несёт в тексте разбираемое значение, и оборванная строка секретного ключа уносила его в журнал; теперь называются путь, строка, столбец и последний ключ
61 KiB
Ревью: настройка и журнал
Как настроен конвейер
Артефакты прогонов лежат в openspec/changes/archive/<id>/review/ — под именем
triage.md либо report.md: имя менялось по ходу, и оба встречаются. Самый
ранний — fix-http-handler-tests 2026-08-11, самый поздний —
start-without-telegram-token 2026-08-13.
Конвейер прогонялся и на работе, шедшей без своего изменения openspec; артефакта в архиве у таких прогонов нет, и урожай их виден только записями журнала ниже. Разделы ниже заведены наперёд по коду 2026-08-11 и с тех пор правятся урожаем прогонов.
Проход, поднявший сервис, обязан его остановить. Живой прогон стал доступен 2026-08-13 (см. «Недоступно проверке»), и первый же им воспользовался: враждебный проход поднял сервис на своём порту и оставил работать. Следующий прогон занять порт не смог, а его запросы молча ушли к чужому процессу — то есть замеры относились к прежней сборке, и по ним едва не был объявлен исход. Отсюда два правила, оба прозой и без механизации: поднял — останови за собой, а меряющий убеждается, что отвечает его собственная сборка (порт занят им, новое поведение видно в выводе). Признак дешёвый: если ожидаемого нового поля, метрики или строки нет вовсе — вероятнее всего, отвечает не твой процесс.
Ни один проход не сообщает свой потолок, и это надо читать как границу
покрытия. Прогон telegram-enabled-flag 2026-08-13: у прохода есть потолок
находок, и устав велит объявлять строкой, сколько осталось за срезом и какого
рода. Ни один из четырёх проходов такой строки не дал, и заметил это только
триаж. Пока так, «находок больше нет» в отчёте прохода неотличимо от «больше не
поместилось». Выше прочих риск у прохода, вбирающего темы разом: у него одна
квота на три темы. Механизации нет — потолок объявляет сам проход, и заставить его нечем;
остаётся сверка триажа.
Что уже проверяет машина и о чём поэтому спрашивать не нужно — конвенция conventions/go-linters.md. Вопросы ниже — то, чего машина не проверяет; свойства, которые обязан проверять тест, — в «Типовых узлах».
Типовые узлы
Рода узлов проекта и проверяемые свойства к каждому.
Шаг конвейера (FindAndRunConversionJob, FindAndRunTranscribeJob,
FindAndRunTranscribeCheckJob):
- отличает «задач нет» от отказа и не считает первое ошибкой;
- при отказе на середине оставляет задачу в состоянии, из которого повтор
корректен, либо переводит в
failedосознанно; - не теряет ссылку на файл:
job.FileIDпереставляется только после того, как запись о новом файле создана; - повтор шага на той же задаче не создаёт лишних файлов и записей;
- отвечает пользователю ровно один раз.
Транспорт (internal/controller/tg, internal/controller/http):
- проверяет право отправителя до всякой работы;
- не логирует ошибку, которую уже залогировал доменный слой;
- переводит доменную ошибку в свой ответ, а не отдаёт сырой текст;
- закрывает то, что открыл, на всех ветках выхода.
Клиент внешнего сервиса (adapter/recognizer/yandex, adapter/telegram):
- имеет таймаут и не виснет, когда внешний сервис не отвечает;
- не кладёт секрет в URL и не даёт ему утечь через ошибку транспорта;
- различает «сервис ответил отказом» и «сервис недоступен»;
- вырожденный ответ (пустой, усечённый, без ожидаемого поля) не превращает в успех молча.
Репозиторий хранилища (internal/adapter/repo/pocketbase; шаги схемы —
подпакетом migrations):
- список колонок совпадает во всех четырёх местах —
applyToRecord,recordToJob,acquireColumns,acquiredRow— и в шаге схемы (инвариант CLAUDE.md, «Инварианты»); - захват задачи не выдаёт одну строку двум вызывающим, а результат пишет только держатель захвата;
- репозиторий кладёт время в сыром запросе тем же видом, каким хранилище пишет
свои
created/updated(database.md, «Представление данных»); - отказ хранилища не выходит наружу дословно: он несёт ключ файла целиком.
Обёртка над внешним процессом (adapter/converter/ffmpeg,
adapter/metaviewer/ffmpeg):
- отсутствие программы в
PATHотличается от отказа обработки; - вход, пришедший от пользователя, не попадает в аргументы командной строки неразобранным;
- пустой или частично записанный выходной файл считается отказом;
- процесс не висит вечно.
Любой узел — сверх свойств своего рода:
- изменённое место покрыто хоть одним проходящим тестом. Тест, который никогда не был зелёным, обнуляет сигнал всего пакета: настоящий отказ в нём становится неотличим от привычного шума (журнал, запись 2026-08-10).
Свойств о годности самих проверок здесь больше нет — ни мутации теста, ни мутации оракула критерия приёмки, ни требования сценария к норме. Запрет и его границы — CLAUDE.md, «Запреты».
Типовые ложноположительные
-
«Воркер глотает ошибку
NoopJobError». Не дефект: этот тип означает «задач в этом состоянии нет», иinternal/controller/worker/worker.goнамеренно не логирует его и не считает в метрику. Норма записана требованием pipeline.Оговорка, и она тут главная: ложноположительным считается только само молчание воркера. Проверка формы узнавания ложноположительной не является: приведение типа на этом месте — настоящий дефект, закрытый 2026-08-11 задачей
errors-as-instead-of-typecast. Появилось снова — это регрессия, и выбрасывать её как известную нельзя. -
«Захват задачи не в транзакции — гонка двух воркеров». По построению её нет: три воркера читают три разных состояния, и одну строку они не делят. Механика захвата и её слабые места — database.md, «Представление данных». Находка становится настоящей ровно тогда, когда появится второй экземпляр процесса или второй воркер на то же состояние.
-
«Файлы и объекты не удаляются, диск растёт». Факт верный и записан в database.md; срок хранения не задан сознательно, задачи на него нет. Новой находкой это не считается, пока не измерен рост.
-
«У записи нет владельца: вошедший видит чужие записи». Не дефект и не новость: приём, опрос и файл закрыты сессией OIDC с 2026-08-12, а разграничения по владельцу нет сознательно — security.md, «Периметр», и
openspec/specs/access, «Purpose». Находкой считается новая поверхность, выставленная наружу, либо путь к содержимому записи без сессии, а не повторение этого факта.
Вопросы по темам
Форма: <тема>: <вопрос> (<провенанс>).
operations: как шаг отвечает на отмену посреди работы — контекст доходит до внешнего собеседника и это держат правилаnoctxиcontextcheck(conventions/go-linters.md, «Отмена и внешний собеседник»), а исход прерванного шага нормой по-прежнему не описан (openspec/specs/pipeline,Purpose). Спрашивать надо не «доходит ли», а «что делает с задачей, деньгами и ответом отправителю» (чтениеworker.goиtranscribe.go, 2026-08-13; прежний провенанс 2026-08-10 устарел вместе с дефектом «остановка хоронила запись»).operations: появился ли таймаут у обращения к Telegram, S3 и SpeechKit — ни у одного из них таймаута нет, и проброс контекста на этот вопрос не отвечает: контекст здесь несёт жизнь процесса, а не дедлайн вызова (чтениеtg.go,s3.go,speechkit.go, 2026-08-13).operations: не удвоилась ли запись об одном сбое — шаг логирует ошибку и возвращает её воркеру, который логирует снова (чтениеtranscribe.go, 2026-08-10).security: не попал ли в лог текст расшифровки, имя файла пользователя или URL с токеном бота (запрет в security.md и conventions/logging.md).security: не строится ли путь на диске или ключ объекта из значения, пришедшего снаружи, — расширение файла сегодня берётся из имени отправителя (чтениеservice/transcribe.go, 2026-08-10).security: не уходит ли значение, пришедшее снаружи, меткой метрики — страница метрик отдаётся без проверки отправителя, и метка это поверхность пошире журнала (журнал, запись 2026-08-11 про хвост имени).architecture: не появился ли второй путь приёма мимоcreateTranscribeJob— сегодня через него идут оба входа (architecture.md, «Единые точки проекта»).architecture: не поехало ли поведение вarchitecture.mdвместо спеки — заведены четыре capability (intake,pipeline,storage,access), и первые две описаны частично. Поведение прочих узлов, включая приём из Telegram, живёт в обзоре под маркерами долга, а соблазн дописать туда ещё — самый большой.conventions: новая колонка правится во всех четырёх местах репозитория (CLAUDE.md, «Инварианты»).autotests: покрыт ли изменённый шаг конвейера хоть одним проходящим тестом. Что уже закрыто проверками, видно по журналу дефектов ниже и по conventions/go-linters.md, «Механизировано»; числа файлов здесь не называем — оно протухает с каждой задачей.autotests: судит ли проверка ответа по готовому ответу, а не по изменяемому состоянию обработчика — только там, где ответ идёт мимо recorder, через свойhttp.ResponseWriter. Обращение к живой карте recorder'а с 2026-08-12 роняет гейт правилом линтера (conventions/go-linters.md, «Механизировано»), и спрашивать о нём не нужно.security: не открылась ли снова поверхность, которую приносит хранилище, — собственная регистрация, вход по паролю, одноразовый код, восстановление доступа, продление сессии. Всё это приходит включённым и закрывается нами (задачаoidc-login2026-08-12).security: не появился ли второй способ получить сессию к тому же человеку — заголовок вместо куки назван осознанно, прочие способы обязаны быть закрыты.operations: доходит ли отзыв доступа у провайдера до сервиса и за какой срок — после входа сервис к провайдеру не обращается, и канал здесь один (ADR-2026-08-12-session-without-refresh).architecture: не зовётся ли на каждый запрос то, что меняет состояние приложения, — сборка роутера хранилища оказалась именно такой.
Триггеры метки
Проектная конкретизация правила выбора метки. Умолчание — medium.
Крупное здесь (поднимает до large, ось объёма):
- изменение, трогающее конвейер задач целиком: состояние, воркер, шаг сервиса и колонку разом;
- замена хранилища или переход на PocketBase — любой её кусок;
- смена модели очереди: захват, повторы и воркеры разом;
- каркас приложения: сборка фронтенда, раздача статики и шаг гейта разом;
- изменение, трогающее оба входа сразу — Telegram и HTTP.
Незнакомое здесь (поднимает до large, ось формы решения):
- вход через OIDC и разграничение доступа: как связаны пользователь Telegram и пользователь приложения, до начала работы назвать нельзя;
- всё, что делается на выбранном фреймворке впервые: правила conventions/web-ui.md выведены из выбора и из замера на пробном экране, а не из написанного кода, и первая же задача проверяет их собой — форма решения нащупывается по ходу;
- установка на телефон: service worker перехватывает запросы, и что он кэширует, до работы назвать нельзя;
- работа с записями в несколько часов: потолки внешних сервисов не замерены, форма решения зависит от замера;
- приём дорожки из видео и форматов, которых
ffmpegне берёт текущей командой; - всё, что требует записи в
research/прежде, чем начать.
Мелкое здесь (опускает до small):
- правка текста, который видит пользователь Telegram;
- новая метрика в
internal/metrics; - правка
config.dist.tomlи умолчанийdefaultConfig()без нового поля; - правка документов канона.
Помни отрицательный тест: миграция, формат файла на диске, публичный контракт
API и имя не откатываются обратной правкой после мерджа — какими бы маленькими
ни были, они не small.
Недоступно проверке
Не проверит ни один проход:
operations: поведение внешних сервисов под нагрузкой и на границах — SpeechKit и Object Storage поднять в тесте нечем;operations: реальный профиль нагрузки. Проект работает на единицах записей в день, и утверждения о росте остаются условиями, а не замерами;security: стойкостьffmpegк вредоносному входу — разбор чужого формата отдан внешней программе, и она вне нашей границы;security: поведение настоящей Authelia и её правило на нашего клиента. Провайдера в прогоне нет, подменяет его свой сервер; кто допущен — настройка выкладки вне репозитория, и по коду её не проверить (adr/ADR-2026-08-12-access-delegated-to-provider.md);security: поведение браузера с куками — применениеSameSite, приёмSet-Cookieпри переходе с чужого сайта. Браузера в прогоне нет, и находки этого рода остаются гипотезами.
Перестали проверять сознательно:
-
autotests: разбор вывода настоящегоffprobe. Проверки приёма звали его до 2026-08-11 — правда, звали так, что он всегда отказывал, — а теперь получают длительность от подставного источника. Своего теста уadapter/metaviewer/ffmpegнет; решение и его цена — в adr/ADR-2026-08-11-stub-adapters-in-tests.md; -
работа сервиса с настоящими внешними собеседниками. Сам сервис поднять теперь можно: с
telegram.enabled = falseон встаёт и работает одним входом (openspec/specs/intake, «Признак включения решает, поднимается ли вход Telegram»). Живой прогон — осмотр HTTP, панели, журнала и остановки — доступен теперь любой задаче. Прежняя формулировка «всё, что требует поднять сервис целиком» снята задачейlocal-run-without-telegram-token2026-08-13; рецепт прогона сменился с пустого ключа доступа на выключенный вход задачейtelegram-enabled-flagтого же дня.Остаток: за настоящий Telegram, SpeechKit и Object Storage живой прогон по-прежнему не отвечает — боевым токеном запускаться запрещено, ключи Yandex в прогоне выдуманные, а распознавание подменяют в коде. Проверить живьём можно подъём, отказ старта, маршруты и остановку; нельзя — приём из Telegram, расшифровку и заливку.
Журнал дефектов
Записи новые сверху. [пойман ревью] — дефект нашёл прогон конвейера,
[пойман сканером] — тест-сканер internal/archrules, [проскочил] — дефект
уехал в код, и поймать его тогда было некому. Две нижние записи восстановлены по
истории git 2026-08-10: поле «Чем воспроизведён» называет у них коммит, а не
оракул, и выдумывать оракул задним числом нельзя.
2026-08-13 — сторож инварианта про секрет искал подстроку, которой не бывает [пойман ревью]
- Где:
internal/config/config_test.go, проверка «значение ключа доступа не попадает в отказ» задачиtelegram-enabled-flag. Дефект в самой проверке, кода сервиса он не касался - Симптом: проверка была зелёной и утверждала, что отказ
TelegramConfig.Validate()не несёт значения ключа доступа. Приёмочный критерий задачи считался закрытым ею - Причина: двойная, и каждая половина достаточна. Утверждение искало
подстроку
enabled = true при, а в сообщении стоитпри enabled = true— порядок слов обратный, и такой подстроки не бывает ни при каком входе. Глубже:Validate()отказывает только на пустом ключе, то есть значения, которым можно проговориться, на этом пути не существует вовсе. Комментарий при этом утверждал «Ключ непуст», а в теле стоялоBotToken: ""— описан был не тот вход, который задан - Чем воспроизведён: триаж скопировал дерево во временный каталог и заменил
тело
Validate()на утекающее —fmt.Errorf("... bot_token=%q ...", c.BotToken). Проверка осталась зелёной - Почему не поймали раньше: проверка написана в той же задаче и той же рукой, что и код; гейт зелёный, а зелёная проверка неотличима от работающей. Поймали два прохода независимо — разбор кода и сверка требований
- Что меняем: проверка переписана честно и переименована: половина требования «сообщение не несёт значения» на этом пути вакуумна, и это названо прямо, а настоящий сторож той же нормы указан по имени — он живёт там, где непустой ключ в отказ попасть действительно может, в проверках отказа разбора файла настроек. Класс всплывает третий раз (2026-08-11 «проверка приёма не могла упасть», 2026-08-12 «проверка не могла упасть: читала живую карту заголовков»), и в этот раз он другой природы: прежние два ловились правилом линтера про источник утверждения, а этот — про вход: у сторожа утечки вход обязан содержать значение, которое может утечь, иначе сторож пуст независимо от формы утверждения. Механизации у этого нет и, похоже, быть не может: «может ли здесь вообще утечь» — суждение, а не форма. Остаётся проходу ревью
2026-08-13 — остановка сервиса хоронила конвертируемую запись [пойман ревью]
- Где:
internal/service/transcribe.go, шаг конвертации — дефект завела та же правка, что проложила контекст доffmpeg - Симптом: на прод не уехал, поймали до коммита. Выглядел бы так: обычная
выкладка посреди конвертации переводит здоровую запись в терминальное
failed, отправителю уходит «сбой конвертации файла», а вернуть задачу может только владелец правкой в панели. Окно — часы: конвертация шестичасовой записи идёт дольше часа по построению - Причина: контекст дошёл до внешнего процесса, а различать его отмену шаг
не научили. Убитый по контексту
ffmpegотдаётsignal: killed— от настоящего отказа (exit status N) эта ошибка неотличима ни типом, ниerrors.Is: различает толькоctx.Err(). Шаг звалfailJobна любой отказConvert. Хуже:failJobвозвращаетnil, поэтому воркер считал прогон успешным, и метрика владельца — та, которой он замечает отказы, — не шевелилась - Чем воспроизведён: проверкой
TestShutdownDuringConversionKeepsJobRetryableс подставным конвертером, ведущим себя как убитый процесс: отдаёт отказ, не несущийcontext.Canceled. Мутация снята — без развилки проверка краснеет - Почему не поймали раньше: правка выглядела механической, «линтер потребовал контекст». Цена оказалась в семантике очереди, а не в сигнатурах: отмена стала значить разное на соседних шагах одного конвейера. Ни один линтер такого не видит — это заметили три прохода ревью независимо, и все три построили путь
- Что меняем: прерванный шаг приговора не выносит — задача остаётся на
повтор, попытку не тратит (счётчик, выросший при захвате, возвращают назад) и
отправителю о несуществующем сбое не сообщает. Воркер не считает остановку
отказом и не пишет о ней владельцу. Задача не забирается вовсе, если нас уже
остановили. Остаток объявлен: норма отмены в спеке
pipelineне описана, и открытая задачаcontext-cancel-in-pipelineэтим закрыта не целиком
2026-08-13 — отказ скачивания уносил токен бота в журнал [проскочил]
- Где:
internal/controller/tg/tg.go, скачивание записи по ссылкеfile.Link(c.bot.Token) - Симптом: не наблюдался, потому что журнал за этим местом никто не читал
построчно. Первый же сбой сети на скачивании писал в журнал
Failed to download audio fileвместе с полным адресом запроса, а в адресе Telegram держит токен бота (…/bot<TOKEN>/…). Инвариант «секрет не покидает конфиг» помечен critical и необратим: утёкший токен отзывают руками - Причина:
http.Getвозвращает*url.Error, и тот встраивает адрес целиком. Отказ уходил вfmt.Errorf("failed to download file: %w", err), а оттуда — вlogger.Errorсоседней строкой - Чем воспроизведён: чтением цепочки от
http.Getдо вызоваlogger.Errorв трёх обработчиках; на живом боте не проверялся — боевым токеном запускаться запрещено - Почему не поймали раньше: правило было записано прозой и ровно про этот случай — conventions/logging.md, «Ошибка HTTP-транспорта несёт URL». Хуже: там же стояло объявленное Расхождение с оценкой «сегодня она не логируется — то есть утечки нет», и оценка была неверной. Строка лога существовала всё это время, но проза о ней не знала, а машина прозу не проверяет
- Что меняем: чистку перенесли с места употребления на границу клиента —
internal/adapter/telegram,NewBot: свойDoразворачивает отказ в первопричину, а подменённый логгер библиотеки вычищает токен из строк длинного опроса, которые она печатает сама, мимо нашегоslog. Транспорт бота токена больше не получает: клиента ему отдают готовым. Расхождение в конвенции закрыто, оценка в security.md исправлена - Чем закрыт от возврата: проверками
internal/adapter/telegram/bot_test.go— четыре пути (getFile,sendMessage, конструктор, логгер библиотеки) судятся по тексту отказа и строке журнала. Мутация снята: со снятой чисткой три из них краснеют, печатая токен. Правило остаётся прозой (линтер не отличит ссылку с секретом от ссылки без него), но у прозы теперь есть оракул - Как нашли: первый путь — попутно, при разборе находок
noctx: тот потребовал переписатьhttp.Getна запрос с контекстом, и цепочку пришлось прочитать целиком. Остальные четыре — конвейером ревью в тот же день; правка, закрывшая один путь, объявила класс закрытым в двух документах, и это едва не осталось так
2026-08-13 — конец потока распознавания узнавался по тексту сообщения [пойман сканером]
- Где:
internal/adapter/recognizer/yandex/speechkit.go, чтение потока результата распознавания - Симптом: сегодня не наблюдался — путь рабочий, пока библиотека отдаёт конец
потока значением
io.EOF. Отказ с текстом «EOF» был бы принят за конец потока, и расшифровка вернулась бы усечённой: пользователь получил бы половину записи как готовый результат - Причина: конец потока узнавался сравнением
err.Error() == "EOF". Текст сообщения — не признак: его носит и чужая ошибка, а сменит его библиотека — условие перестанет срабатывать вовсе, и оба исхода молчаливы - Чем воспроизведён: не воспроизводился на живом сервисе — прогон на реальных
ключах запрещён. Найден тестом-сканером
internal/archrulesпри его заведении - Почему не поймали раньше:
errorlintвидитerr == ErrXи приведение типа, но матчинг по тексту не видит; прозой это правило записано не было, и ревью его не спрашивало - Что меняем: узнавание переведено на
errors.Is(err, io.EOF); класс закрыт тестом-сканером (docs/conventions/go-linters.md, «Ошибки и отказы»)
2026-08-13 — правило гейта обходилось одной лишней строкой [пойман ревью]
- Где:
.golangci.yml, правилоforbidigoо суждении по живой карте заголовков — заведено в тот же день задачейresponse-assertions-judge-result - Симптом: правило ловило только прямую цепочку
w.Header().Get. Присваивание в переменную (h := w.Header()), чтение по индексу карты, обходrangeи полеHeaderMapпроходили гейт зелёными — то есть класс, стоивший трёх зелёных гейтов, возвращался четвёртый раз, и уже без человеческой страховки: документы успели снять его с прохода ревью - Причина:
forbidigoпо умолчанию судит по печатному тексту вызова, а не по типу значения. Правило, записанное текстом, отсекает одну форму записи, а не свойство - Чем воспроизведён: прогоном линтера на файле проверок с шестью формами чтения живой карты: помечена была одна
- Почему не поймали раньше: правило проверили ровно тем нарушением, против которого писали. Мутация была, но одна — нужна была по одной на каждую форму
- Что меняем: правило судит по типу приёмника (
analyze-types,httptest.ResponseRecorder.Headerи.HeaderMap) и ловит все шесть форм; проверено мутацией по каждой. Урок записи: запрет по имени, обходимый лишней строкой, свойства не держит — такому свойству нужен тест-сканер. Строка об этом стояла вdocs/conventions/go-linters.md, разделе «Лестница механизации»; раздел снят 2026-08-13, урок остался здесь
2026-08-12 — закрыли поверхность так, что войти не мог никто [пойман ревью]
- Где: шаг схемы
202608120001задачиoidc-login, правило создания записи в коллекции пользователей - Симптом:
users.CreateRule = nilзакрывало создание записи для всех, кроме владельца панели. Запись при первом входе заводит внутренний запрос самого обмена, идущий без таких прав, — значит после выкладки вход не сработал бы ни у кого, включая владельца, а приём и опрос уже были закрыты. Сервис остался бы доступен только через Telegram, и чинилось бы это руками в панели - Причина: закрывали ровно то, ради чего задача затевалась, — самостоятельную
регистрацию, которую хранилище приносит открытой. Глухое
nilвыглядит самым надёжным её закрытием и отвергает заодно единственный законный путь заведения записи. Различить их можно: обмен помечает свой запрос контекстомoauth2 - Чем воспроизведён: тестом против настоящего хранилища с подставным
провайдером: возврат от провайдера отвечал
401, обращений к токен-эндпоинту1, учётных записей после входа0. Причина изолирована тем же прогоном — с открытым правилом возврат давал302и запись появлялась - Почему не поймали раньше: все проверки задачи заводили учётную запись прямым сохранением, мимо входа, и потому шли по коду, который в бою не исполняется. Гейт был зелёным. Поймали два прохода независимо — разбор кода по исходникам библиотеки и враждебный проход падающим тестом
- Что меняем: правило сузили до контекста обмена
(
@request.context = "oauth2"), а в набор проверок добавили вход целиком через подставного провайдера — от увода до куки сессии. Проверка, заводящая запись мимо входа, больше не считается покрытием входа
2026-08-12 — проверка не могла упасть: читала живую карту заголовков вместо ответа [пойман ревью]
- Где:
internal/controller/http/auth_test.go, проверка уборки носителя состояния входа; сам дефект — вauth.go, уборка стояла вdefer - Симптом: носитель состояния и проверочного кода не убирался ни на успешном возврате, ни на отказном, и жил свои десять минут. Одноразовость возврата держалась ровно на этой уборке, то есть тоже не работала. Проверка при этом была зелёной и утверждала обратное
- Причина: двойная. В коде —
deferисполняется после того, как ответ уже начали писать, а заголовки к этому моменту зафиксированы снимком, и позднейшая правка их карты до браузера не доезжает. В проверке —httptestустроен зеркально:Header()отдаёт живую карту, а снимок лежит отдельно и читается черезResult(). Проверка смотрела в живую карту и видела то, чего клиент не получит - Чем воспроизведён: отдельной программой вне проекта: на настоящем сервере
ответ приходил с пустым
Set-Cookie, а тот же обработчик подhttptestпоказывал куку вHeader()и не показывал вResult() - Почему не поймали раньше: оракул был ложным по построению, и никакая регрессия его не разбудила бы. Гейт зелёный. Поймали два прохода — сверка требований и разбор кода, — оба воспроизведением, а не чтением
- Что меняем: уборка перенесена до записи ответа; все проверки этого файла
судят по
Result(). Класс всплывает третий раз (2026-08-10 «тесты http-обработчика ни разу не были зелёными», 2026-08-11 «проверка приёма не могла упасть»), поэтому он же ушёл в конвенции правилом: проверка ответа судит по готовому ответу, а не по изменяемому состоянию обработчика. Механизировано 2026-08-12 задачейresponse-assertions-judge-result—forbidigoв.golangci.ymlроняет гейт на чтении живой карты заголовков в файле проверок. Правило судит по типу приёмника, а не по тексту вызова, и потому ловит любую форму чтения живой карты — цепочкой, через переменную, по индексу, обходом, полемHeaderMap. Текстовый запрет ловил только прямую цепочку и обходился одной лишней строкой — это назвал прогон ревью этой же задачи. Проходу ревью остаётся проверка, идущая мимо recorder, через свойhttp.ResponseWriter
2026-08-12 — каждый анонимный запрос навсегда замедлял запись в хранилище [пойман ревью]
- Где:
internal/controller/http/auth.go, обмен кода собирал роутер хранилища на каждый вызов - Симптом: сборка роутера вешает девять обработчиков на само приложение и без идентификатора, поэтому повторная не заменяет прежние, а добавляет. Обработчики исполняются на каждой записи в хранилище, а конвейер пишет задачу на каждом шаге. Освобождения нет — только перезапуск. Раскачивалось анонимно: атакующий ставит себе куку состояния сам, и сверка сравнивает две его же величины, а обмен исполняется раньше обращения к провайдеру
- Причина: функция сборки выглядит чистой — она возвращает роутер, и по имени не видно, что она правит приложение. Решение звать собственный адрес хранилища внутри процесса сделало эту сборку частью горячего пути
- Чем воспроизведён: замером на настоящем приложении: пять вызовов подряд подняли число обработчиков одного события с 4 до 14; 3000 анонимных возвратов довели сотню сохранений записи с 3.86 мс до 59.8 мс и кучу на 5013 КиБ. При недоступном провайдере утечка сохранялась
- Почему не поймали раньше: ни один шаг гейта не смотрит на побочные эффекты вызова библиотеки, а замер требует прогона. Поймали три прохода — архитектурный зондом, враждебный падающим тестом, сверка требований чтением
- Что меняем: роутер собирается один раз и живёт полем обработчика; в набор проверок добавлена та, что считает длину очереди обработчиков после двадцати входов
2026-08-12 — образ не собирался, и этого не увидел никто [проскочил]
Что сломалось. go mod tidy поднял директиву go в go.mod до 1.25.0 —
её требует PocketBase, — а Dockerfile продолжал собирать на golang:1.24-alpine
с GOTOOLCHAIN=local. task image упал бы на шаге сборки: выкладки задачи
pocketbase-storage не существовало бы вовсе.
Почему не поймали. Все шесть проходов ревью и весь гейт видели зелёное:
go build ./... идёт на хостовом Go, а образ не собирает ни один шаг гейта.
Расхождение выглядело согласованным ещё и потому, что CLAUDE.md и README.md
обещали Go 1.24 — то есть три места из четырёх говорили одно и то же, и неверными
были именно они.
Нашлось не проходом, а триажем — при проверке чужих починок на месте, когда он собрал образ руками. То есть поймано случайным свойством прогона, а не устройством конвейера: проверь триаж починки чтением, дефект уехал бы в мердж.
Чем чинится на будущее. Сборка образа гейтом не проверяется намеренно —
дорого. Дешёвая замена: шаг, сверяющий версию сборщика в Dockerfile с
директивой go в go.mod. Строкой сравнения, без docker. Заведено урожаем
ревью.
Закрыто задачей go-1-26-upgrade 2026-08-12: шаг go-version в task gate
(scripts/check-go-version.sh). Сверяются четыре места, а не два, — go.mod,
Dockerfile, CLAUDE.md, README.md: в этом дефекте трое из четырёх врали
согласованно, и парная сверка не увидела бы документ, разошедшийся с
согласованным кодом. Нормативного дома у шага не осталось: спека toolchain
упразднена 2026-08-13, тогда же снесены и его двадцать сценариев — норма живёт
комментариями в самом скрипте.
2026-08-11 — норма требовала от сервиса недостижимого [пойман ревью]
- Где: дельта-спека
pipelineзадачиerrors-as-instead-of-typecast, абзац об отказе шага - Симптом: требование гласило «отказ MUST быть записан ровно один раз единственной логирующей точкой». Сервис пишет дважды — сначала шаг конвейера, следом воркер, — то есть норма не выполнялась бы с первого дня, а после архивации стала бы посылкой для следующих задач
- Причина: дефект родился при починке соседнего. Первая редакция назначала
логирующей точкой воркера и фиксировала уровень
ERROR, чем закрепляла контрактом долгconventions/logging.md. Правка по этой находке ушла в противоположную крайность: вместо «норма молчит о числе записей» получилось «норма требует одной». Двойная запись — записанный системный долг, и обе редакции с ним расходились, только в разные стороны - Чем воспроизведён: прогоном пробы через
go test -overlay: один отказ хранилища даёт две записи —Failed to find and acquire jobиз шага иWorker errorиз воркера - Почему не поймали раньше: требование не имело сценария, а значит и оракула
— упасть ему было нечем. Ревью дизайна абзац читало, но код с ним не сверяло:
кода тогда не существовало. Поймал проход
specsна ревью кода, направлениемcode → spec, и поймал прогоном, а не чтением - Что меняем: норма говорит только проверяемое сегодня — отказ виден владельцу и засчитан в счётчик; число записей и уровень названы долгом с адресом. В критерии приёмки добавлена строка: норма не объявляет обязательным недостижимое — ни в ту, ни в другую сторону
2026-08-11 — хвост имени отправителя уезжал на открытую страницу метрик [пойман ревью]
- Где:
internal/service/transcribe.go, меткиfile_extensionу размера принятой записи иsource_formatу длительности конвертации - Симптом: имя
запись.тайное-словоклалотайное-словометкой метрики, аGET /metricsотдаётся без проверки отправителя. Тем же каналом множеством значений метки распоряжался анонимный отправитель - Причина: расширение берётся из имени отправителя дословно (
filepath.Ext) и употреблялось меткой без приведения. Канал старше задачи, которая его нашла - Чем воспроизведён: прогон
filepath.Extна именах видазапись.тайное-слово,Разговор с Петровым 11.08, затем чтение реестра метрик после приёма — метка несла хвост дословно - Почему не поймали: метрику никто не считал выходом приватного значения.
Тема
securityсмотрела журнал, ответ и пути на диске; вопроса про метку в перечне вопросов не было, и ни один проход её не открывал. Поймали три прохода разом на задаче, которая закрывала соседний канал - Что меняем: вопрос про метку добавлен в «Вопросы по темам»; правило
приведения нормировано спекой
intakeи записано решением
2026-08-11 — проверка приёма не могла упасть [пойман ревью]
- Где:
internal/controller/http/transcribe_test.go, случай успеха приёма - Симптом: тест не поймал ни одного настоящего дефекта приёма, хотя был зелёным и выглядел содержательным
- Причина: две штуки одного рода. Тест разбирал ответ в
CreateTranscribeJobResponse— ту самую структуру, чьи тегиjsonи составляют публичный контракт: переименование тега меняло и проверяемое, и ожидаемое разом. И заведение задачи тест подтверждал только эхом ответа, а не чтением базы - Чем воспроизведён: мутацией. Замена тега на
json:"jobId"и удалениеs.jobRepo.Create(job)изinternal/service/transcribe.go— тесты в обоих случаях оставались зелёными; после правки обе мутации их роняют - Почему не поймали: проверки писались тем же заходом, что и правились, а
«зелено» на новом тесте читается как подтверждение. Поймал проход
specsревью кода, и поймал ровно тем, что добыл оракул мутацией, а не рассуждением - Что меняем: успех судится по сырому JSON и по строке в базе. В типовые узлы, «Любой узел», добавлено свойство «проверка способна упасть» с указанием на мутацию как способ его проверить
2026-08-10 — тесты http-обработчика ни разу не были зелёными [проскочил]
- Где:
internal/controller/http/transcribe_test.go - Симптом:
go test ./...падает четырьмя случаями; обнаружено первым же прогоном гейта при заведении канона - Причина: тест требует
testdata/sample.m4a, которого в репозитории нет и не могло быть —.gitignoreсодержит*.m4a. Остальные случаи записывают в файл строкуtest audio contentи ждут201, а обработчик зовёт настоящийffprobe, который такой вход отвергает - Чем воспроизведён:
go test ./internal/controller/http/— четыре отказа, из них один по отсутствию файла и три по коду500вместо201 - Почему не поймали: гейта не было вовсе, а
go testруками, судя по результату, не гоняли ни разу с коммита87d8b05 - Что меняем: заведена задача
http-handler-tests-never-green; в гейт добавлен шагgo test ./..., и красный тест теперь виден. Настоящий остаток шире: тест, который никогда не проходил, обнуляет сигнал всего пакета — в типовые узлы добавлено свойство «покрыт хоть одним проходящим тестом», а в вопросы темыautotests— вопрос про изменённый шаг конвейера - Закрыт 2026-08-11: проверки переписаны,
go test ./...зелёный и из списка объявленных долгов в CLAUDE.md снят
2025-10-23 — пустой ответ вместо текста расшифровки [проскочил]
- Где:
internal/service/transcribe.go, ветка завершения задачи - Симптом: пользователь Telegram получал пустое сообщение вместо текста
- Причина: SpeechKit возвращал операцию успешной, но с пустым текстом, и задача завершалась этим пустым значением
- Чем воспроизведён: восстановлено по коммиту
ec637c0, оракула нет - Почему не поймали: конвейера ревью не существовало
- Что меняем: уже сделано — пустой текст подменяется фразой «на записи нет текста». Настоящий остаток в другом: свойство «вырожденный ответ внешнего сервиса не превращается в успех молча» вынесено в типовой узел «клиент внешнего сервиса» выше
2025-08-17 — длинная расшифровка не доходила до пользователя [проскочил]
- Где:
internal/adapter/telegram/sender.go - Симптом: отправка текста длиннее предела сообщения Telegram завершалась ошибкой целиком, пользователь не получал ничего
- Причина: предел длины сообщения на стороне Telegram не учитывался
- Чем воспроизведён: восстановлено по коммиту
822e168, который тем же заходом завёлinternal/adapter/telegram/split_test.go - Почему не поймали: конвейера ревью не существовало
- Что меняем: уже сделано — деление по словам с пределом 4000 символов, число записано в database.md