Files
transcriber/docs/review.md
T
av 1385dd3f5d раскладка av-dev поднята до версии 5: метка прогона ушла из процесса
- docs/review.md: подраздел «Триггеры метки» заменён на «Когда звать глубокое
  ревью» — областями кода и перечнем необратимых мест проекта
- дубли инвариантов и перечня необратимого сведены к ссылкам на CLAUDE.md, а в
  database.md убрано разошедшееся число мест правки колонки
- шапка раздела перестала называть самый поздний артефакт ревью: строка
  протухала каждым прогоном
2026-08-23 20:11:48 +03:00

91 KiB
Raw Blame History

Ревью: настройка и журнал

Как настроен конвейер

Артефакты прогонов лежат в openspec/changes/archive/<id>/review/; имя файла менялось по ходу — triage.md, report.md, design-review.md, code-review.md, triage-<дата>.md. Самый ранний — fix-http-handler-tests 2026-08-11; самый поздний здесь не называется: строка протухала бы с каждым прогоном, и смотреть его надо в самом архиве.

Конвейер прогонялся и на работе, шедшей без своего изменения openspec; артефакта в архиве у таких прогонов нет, и урожай их виден только записями журнала ниже. Разделы ниже заведены наперёд по коду 2026-08-11 и с тех пор правятся урожаем прогонов.

Проход, поднявший сервис, обязан его остановить. Живой прогон стал доступен 2026-08-13 (см. «Недоступно проверке»), и первый же им воспользовался: враждебный проход поднял сервис на своём порту и оставил работать. Следующий прогон занять порт не смог, а его запросы молча ушли к чужому процессу — то есть замеры относились к прежней сборке, и по ним едва не был объявлен исход. Отсюда два правила, оба прозой и без механизации: поднял — останови за собой, а меряющий убеждается, что отвечает его собственная сборка (порт занят им, новое поведение видно в выводе). Признак дешёвый: если ожидаемого нового поля, метрики или строки нет вовсе — вероятнее всего, отвечает не твой процесс.

Ни один проход не сообщает свой потолок, и это надо читать как границу покрытия. Прогон telegram-enabled-flag 2026-08-13: у прохода есть потолок находок, и устав велит объявлять строкой, сколько осталось за срезом и какого рода. Ни один из четырёх проходов такой строки не дал, и заметил это только триаж. Пока так, «находок больше нет» в отчёте прохода неотличимо от «больше не поместилось». Выше прочих риск у прохода, вбирающего темы разом: у него одна квота на три темы. Механизации нет — потолок объявляет сам проход, и заставить его нечем; остаётся сверка триажа.

Пробел повторился на прогоне config-test-headers-login 2026-08-23, и это уже не единичный случай: строки о потолке не дал ни один из шести проходов, а заметил это снова только триаж. Состав прогона при этом был самым широким из тогда доступных, — и разница с прошлым разом ровно в числе проходов, промолчавших одинаково. Читать пробел надо как границу покрытия каждого прогона, а не как свойство одного из них.

Что уже проверяет машина и о чём поэтому спрашивать не нужно — конвенция conventions/go-linters.md. Вопросы ниже — то, чего машина не проверяет; свойства, которые обязан проверять тест, — в «Типовых узлах».

Типовые узлы

Рода узлов проекта и проверяемые свойства к каждому.

Шаг конвейера (FindAndRunConversionJob, FindAndRunTranscribeJob, FindAndRunTranscribeCheckJob):

  • отличает «задач нет» от отказа и не считает первое ошибкой;
  • при отказе на середине оставляет задачу в состоянии, из которого повтор корректен, либо переводит в failed осознанно;
  • не теряет ссылку на файл: job.FileID переставляется только после того, как запись о новом файле создана;
  • повтор шага на той же задаче не создаёт лишних файлов и записей;
  • отвечает пользователю ровно один раз.

Транспорт (internal/controller/http):

  • проверяет право отправителя до всякой работы;
  • не логирует ошибку, которую уже залогировал доменный слой;
  • переводит доменную ошибку в свой ответ, а не отдаёт сырой текст;
  • закрывает то, что открыл, на всех ветках выхода.

Раздача собранного приложения и шаг его сборки (controller/http/webapp.go, шаг front):

  • путь, принадлежащий корню сервиса, разметку не отдаёт никогда, а перечень корней порождает регистрацию маршрутов, а не описывает её;
  • несовпавший ресурс под каталогом сборщика отвечает 404, а не разметкой с кодом 200;
  • раздача ставит долгий неотзываемый срок хранения только файлу из каталога сборщика: отозвать его у браузера сервису нечем;
  • отсутствие сборки громкое — код ответа, страница и строка журнала; «сборки нет» отличается от «файла нет»;
  • вшито то, что собрано этим прогоном, а не то, что осталось от прошлого;
  • шаг следует словарю кодов: отказ сети и реестра — 3, красная сборка — 1, и он отказывает, а не висит;
  • путь, выбранный анонимом, не уходит ни меткой метрики, ни строкой журнала. Журнал у сервиса с 2026-08-22 один — свой, в вывод контейнера: второй ушёл вместе со встроенным хранилищем, которое клало путь целиком вместе с адресом отправителя. Правило при этом расширилось, а не сузилось: путь не пишется дословно ни под каким корнем, включая корень приложения.

Клиент внешнего сервиса (adapter/recognizer/yandex):

  • имеет таймаут и не виснет, когда внешний сервис не отвечает;
  • не кладёт секрет в URL и не даёт ему утечь через ошибку транспорта;
  • различает «сервис ответил отказом» и «сервис недоступен»;
  • вырожденный ответ (пустой, усечённый, без ожидаемого поля) не превращает в успех молча.

Репозиторий хранилища (internal/adapter/repo/sqlite; шаги схемы — подпакетом migrations):

  • список колонок совпадает во всех трёх местах — writeOwnedByPipeline вместе с writeRecord, readRecordColumns и rowToAudioRecord — и в шаге схемы (инвариант CLAUDE.md, «Инварианты»). Колонки называются именами: именованный параметр запроса и место назначения по имени, а не позиция в списке;
  • захват задачи не выдаёт одну строку двум вызывающим, а результат пишет только держатель захвата, и держатель узнаётся значением признака;
  • репозиторий кладёт время тем же видом, каким его кладут остальные, и берёт его из единой точки (database.md, «Представление данных»);
  • отказ хранилища не выходит наружу дословно: он несёт ключ файла и путь к нему целиком.

Обёртка над внешним процессом (adapter/converter/ffmpeg, adapter/metaviewer/ffmpeg):

  • отсутствие программы в PATH отличается от отказа обработки;
  • вход, пришедший от пользователя, не попадает в аргументы командной строки неразобранным;
  • пустой или частично записанный выходной файл считается отказом;
  • процесс не висит вечно.

Любой узел — сверх свойств своего рода:

  • изменённое место покрыто хоть одним проходящим тестом. Тест, который никогда не был зелёным, обнуляет сигнал всего пакета: настоящий отказ в нём становится неотличим от привычного шума (журнал, запись 2026-08-10).

Свойств о годности самих проверок здесь больше нет — ни мутации теста, ни мутации оракула критерия приёмки, ни требования сценария к норме. Запрет и его границы — CLAUDE.md, «Запреты».

Типовые ложноположительные

  • «Хранилище молча сливает две учётные записи с одной почтой в одного владельца». Для версии v0.39.10 неверно, и неверна именно развязка. Первая половина цепочки настоящая: обмен ищет запись по признаку провайдера, а не найдя — по адресу почты, и приходит к чужой записи. Но повесить на неё второй признак он не может — уникальный индекс idx_externalAuths_record_provider (collectionRef, recordRef, provider) связь отвергает, обмен отдаёт 400, а сервис — 401 со строкой Failed to exchange provider code. Отказ громкий, тихого слияния владельцев не происходит, и ложно-зелёной проверки разграничения такой дефект не даёт. Проверено прогоном 2026-08-15 (задача про заглушку OIDC); найдено чтением исходников библиотеки, опровергнуто запуском — то есть цена гипотезы, добытой без прогона, здесь и измерена.

  • «Воркер глотает ошибку NoopJobError». Не дефект: этот тип означает «задач в этом состоянии нет», и internal/controller/worker/worker.go намеренно не логирует его и не считает в метрику. Норма записана требованием pipeline.

    Оговорка, и она тут главная: ложноположительным считается только само молчание воркера. Проверка формы узнавания ложноположительной не является: приведение типа на этом месте — настоящий дефект, закрытый 2026-08-11 задачей errors-as-instead-of-typecast. Появилось снова — это регрессия, и выбрасывать её как известную нельзя.

  • «Захват записи не в транзакции — гонка двух воркеров». По построению её нет: три воркера читают три разных состояния, и одну строку они не делят. Отменено 2026-08-14 задачей record-centric-model: построение снято. Пул одинаковых воркеров конкурирует за один и тот же набор записей, и второй воркер на тот же рубеж теперь есть всегда, когда их больше одного. Находка о гонке захвата стала настоящей и выбрасывается только по существу — механика захвата и её слабые места в database.md, «Представление данных». Строка оставлена отменённой, а не удалена: прогон, помнящий прежнюю редакцию, иначе выбросил бы настоящую находку как известную.

  • «Файлы и объекты не удаляются, диск растёт». Факт верный и записан в database.md; срок хранения не задан сознательно, задачи на него нет. Новой находкой это не считается, пока не измерен рост.

  • «Запись без владельца не достаётся никому». Строка отменена дважды, и обе отмены оставлены намеренно: прогон, помнящий любую из прежних редакций, иначе выбросил бы настоящую находку как известную.

    До задачи record-ownership здесь стояло «вошедший видит чужие записи — не дефект и не новость»: разграничения не было сознательно. Первая отмена 2026-08-14 завела разграничение и объявила не дефектом уже другое — запись без владельца, принятую ботом.

    Вторая отмена того же дня, задачей remove-telegram-intake, сняла и это: колонка владельца пустого значения больше не принимает, ничьих записей у сервиса не бывает вовсе. Запись без владельца сегодня — настоящая находка, а не известное исключение.

Вопросы по темам

Форма: <тема>: <вопрос> (<откуда>).

  • operations: не завёл ли инструмент разработчика второй дом тому, что уже есть в проверках. Прецедент: подставных провайдера OIDC в репозитории было два — cmd/oidcstub и fakeProvider в проверках входа, — с теми же адресами и той же посылкой, и они уже разошлись в мелочи (token_type «bearer» против «Bearer»). Оба ушли 2026-08-22 вместе с протоколом; на их месте встал cmd/devtools proxy, а 2026-08-23 задачей config-test-headers-login убран и он: заголовки входа подставляет сам сервис под предохранителем [server] debug. Проверки ставят заголовок сами и подставного собеседника не держат вовсе. Тем же вопросом судится подставной распознаватель. Пробел закрыт той же задачей: норма о подставных собеседниках записана в architecture.md, «Принципы» — пункт «Подставной собеседник в боевом бинарнике объявлен своим ключом»; здесь она не пересказывается. Вопрос при этом остаётся вопросом: норма называет, где собеседнику жить, а не сколько домов у него уже завелось (ревью задачи про заглушку OIDC, 2026-08-15).
  • operations: как шаг отвечает на отмену посреди работы — контекст доходит до внешнего собеседника и это держат правила noctx и contextcheck (conventions/go-linters.md, «Отмена и внешний собеседник»), а исход прерванного шага нормой по-прежнему не описан (openspec/specs/pipeline, Purpose). Спрашивать надо не «доходит ли», а «что делает с задачей, деньгами и ответом отправителю» (чтение worker.go и transcribe.go, 2026-08-13; прежняя запись от 2026-08-10 устарела вместе с дефектом «остановка хоронила запись»).
  • operations: появился ли таймаут у обращения к S3 и SpeechKit — ни у одного из них таймаута нет, и проброс контекста на этот вопрос не отвечает: контекст здесь несёт жизнь процесса, а не дедлайн вызова (чтение 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: не появился ли второй путь приёма мимо createRecord — сегодня он единственный, которым запись попадает в хранилище (architecture.md, «Единые точки проекта»).
  • architecture: не поехало ли поведение в architecture.md вместо спеки — заведённые capability описывают поведение не целиком, и остаток живёт в обзоре под маркерами долга, а соблазн дописать туда ещё — самый большой.
  • conventions: новая колонка правится в обоих местах репозитория, а новый рубеж — одним дескриптором (CLAUDE.md, «Инварианты»).
  • autotests: судит ли проверка формы ответа по настоящему запросу, а не по прямому вызову отображателя ошибки. Вызов напрямую формой ответа не является и остаётся зелёным, когда отказ рождается слоем ниже обработчика (запись журнала 2026-08-15 про единую форму отказа).
  • operations: есть ли у новой выборки свой индекс. Единственный индекс записи заведён под захват воркера — по рубежу и признаку остановки, — и выборке, сужаемой владельцем, он не помогает ничем: замер 2026-08-15 показал полное сканирование таблицы и рост времени страницы вместе с чужими записями.
  • security: не схлопнулись ли внутрипроцессные запросы в один счётчик ограничителя частоты. Запрос, собранный руками, приходит без адреса, а вырожденное значение библиотека отдаёт не пустой строкой, и её собственный страж «пустой ключ пропускаем» такое значение не ловит (запись 2026-08-15).
  • autotests: покрыт ли изменённый шаг конвейера хоть одним проходящим тестом. Что уже закрыто проверками, видно по журналу дефектов ниже и по conventions/go-linters.md, «Механизировано»; числа файлов здесь не называем — оно протухает с каждой задачей.
  • autotests: судит ли проверка ответа по готовому ответу, а не по изменяемому состоянию обработчика — только там, где ответ идёт мимо recorder, через свой http.ResponseWriter. Обращение к живой карте recorder'а с 2026-08-12 роняет гейт правилом линтера (conventions/go-linters.md, «Механизировано»), и спрашивать о нём не нужно.
  • security: не открылась ли снова поверхность, которую приносит хранилище, — собственная регистрация, вход по паролю, одноразовый код, восстановление доступа, продление сессии. Всё это приходит включённым и закрывается нами (задача oidc-login 2026-08-12).
  • security: не появился ли второй способ получить сессию к тому же человеку — заголовок вместо куки назван осознанно, прочие способы обязаны быть закрыты.
  • operations: доходит ли отзыв доступа у провайдера до сервиса и за какой срок — после входа сервис к провайдеру не обращается, и канал здесь один (ADR-2026-08-12-session-without-refresh).
  • architecture: не зовётся ли на каждый запрос то, что меняет состояние приложения, — сборка роутера хранилища оказалась именно такой.

Когда звать глубокое ревью

Проектная конкретизация признаков, по которым зовут av-dev:code-deep-review. Что за признаками следует и каким составом идёт прогон, решает сам скилл ревью; здесь только места этого проекта.

Смотрим целиком (область кода, а не дифф задачи):

  • вход и разграничение доступа — звенья internal/controller/http (узнавание, требование учётной записи, ограничитель частоты, подстановка заголовков отладочного запуска) вместе со спекой access. Возвращаются сюда чаще, чем куда бы то ни было ещё, и журнал дефектов ниже это показывает: закрытая поверхность, в которую не мог войти никто; проверка, читавшая живую карту заголовков вместо ответа; анонимный запрос, навсегда замедлявший запись; путь анонима, уехавший в журнал; ключ бюджета ограничителя, который выбирал тот, кого ограничивают. С 2026-08-22 барьер держит заголовок от прокси, изъятие у него одно — отладочный запуск (security.md, «Периметр»), — а настоящей Authelia в прогоне нет (см. «Недоступно проверке»);
  • пакет хранилища internal/adapter/repo/sqlite — здесь живёт инвариант «Колонки записи правятся в трёх местах» (CLAUDE.md, «Инварианты», major): места, цена забытого и то, чем держится сверка, названы там. Смотрится целиком потому, что компилятор не видит ни одного из мест;
  • конвейер расшифровки internal/service вместе с дескриптором рубежа internal/entity/stage.go — здесь живёт инвариант «Рубеж объявляется одним дескриптором» (CLAUDE.md, «Инварианты», major), и цена забытого рубежа названа там. Сюда же дефекты о потере уже полученного: пустой второй ответ распознавателя, стиравший сохранённую расшифровку, и остановка сервиса, хоронившая конвертируемую запись;
  • распознаватель internal/adapter/recognizer/yandex — единственное место, чья ошибка стоит денег (CLAUDE.md, «Запреты», «Yandex Cloud за деньги»). Живым прогоном оно не проверяется вовсе (см. «Недоступно проверке»), и разбор остаётся единственным способом судить о нём.

Необратимое здесь. Перечень необратимого один и лежит в CLAUDE.md, «Работа»; здесь — только места кода, которых его пункты касаются, и правило прохода: находка в таком месте уходит человеку развилкой, а не чинится молча.

  • применённый шаг схемы — internal/adapter/repo/sqlite/migrations;
  • формат файла на диске и раскладка каталога данных — internal/adapter/repo/sqlite, store.go;
  • публичный контракт HTTP API — internal/controller/http;
  • имя ключа конфига — internal/config;
  • действие с боевыми данными и с Yandex Cloud, ротация секрета — internal/adapter/recognizer/yandex.

Строка, уже ушедшая в журнал контейнера или меткой метрики, из перечня не берётся: её необратимость записана инвариантами о секрете и о содержимом записи (CLAUDE.md, «Инварианты», оба critical), и правка кода помогает там только следующей записи.

Недоступно проверке

Не проверит ни один проход:

  • operations: поведение внешних сервисов под нагрузкой и на границах — SpeechKit и Object Storage поднять в тесте нечем;
  • operations: реальный профиль нагрузки. Проект работает на единицах записей в день, и утверждения о росте остаются условиями, а не замерами;
  • security: стойкость ffmpeg к вредоносному входу — разбор чужого формата отдан внешней программе, и она вне нашей границы;
  • security: поведение настоящей Authelia и правило обратного прокси на домен сервиса. Ни того ни другого в прогоне нет, а с 2026-08-22 от прокси зависит весь барьер: он обязан заголовки Remote-* перезаписывать, а не пропускать пришедшие. Проверить это отсюда нечем — правило живёт в pet-project-server (adr/ADR-2026-08-12-access-delegated-to-provider.md);
  • security: поведение браузера с куками. Своих кук сервис не ставит с 2026-08-22, а вместе со встроенным хранилищем ушли и те, что ставила его панель. Класс опустел, и строка стоит здесь затем, чтобы возврат кук читался как возврат недоступного проверке, а не как обычная работа.

Перестали проверять сознательно:

  • autotests: разбор вывода настоящего ffprobe. Проверки приёма звали его до 2026-08-11 — правда, звали так, что он всегда отказывал, — а теперь получают длительность от подставного источника. Своего теста у adapter/metaviewer/ffmpeg нет; решение и его цена — в adr/ADR-2026-08-11-stub-adapters-in-tests.md;

  • работа сервиса с настоящими внешними собеседниками. Сам сервис поднять можно: он встаёт своим единственным входом на выдуманных непустых ключах секций [auth] и [yandex] — наружу они на старте не ходят. Живой прогон — осмотр HTTP, журнала, метрик и остановки — доступен любой задаче; панели среди предметов осмотра нет с 2026-08-22. Прежняя формулировка «всё, что требует поднять сервис целиком» снята задачей local-run-without-telegram-token 2026-08-13; рецепт прогона менялся дважды — с пустого ключа доступа на выключенный вход (telegram-enabled-flag того же дня), а 2026-08-14 признак включения ушёл вместе с самим входом.

    Остаток: за настоящие SpeechKit и Object Storage живой прогон по-прежнему не отвечает — ключи Yandex в прогоне выдуманные, а распознавание подменяют в коде. Проверить живьём можно подъём, отказ старта, маршруты, метрики и остановку; нельзя — расшифровку и заливку.

    Вход живой прогон теперь проверяет целиком, и это сдвиг 2026-08-22. Прежде сессию в прогоне выдать было нечем; теперь заголовок ставит сам сервис по секции [auth.test_headers] под предохранителем [server] debug — прежде cmd/devtools proxy, убранный 2026-08-23, — и живьём проверяются узнавание, заведение учётной записи первым обращением, отказ с недоверенного адреса и отказ старта на пустом перечне. Настоящая Authelia по-прежнему недоступна — её правило на домен живёт в контуре (см. «Не проверит ни один проход»).

Журнал дефектов

Записи новые сверху. [пойман ревью] — дефект нашёл прогон конвейера, [пойман сканером] — тест-сканер internal/archrules, [проскочил] — дефект уехал в код, и поймать его тогда было некому. Две нижние записи восстановлены по истории git 2026-08-10: поле «Чем воспроизведён» называет у них коммит, а не оракул, и выдумывать оракул задним числом нельзя.

2026-08-23 — путь, выбранный анонимом, уезжал в журнал под корнем приложения [пойман ревью]

  • Где: internal/controller/http/journal.go, JournalRoute; задача storage-without-pocketbase
  • Симптом: неузнанный писал в журнал владельца свой текст произвольной длины. Путь под корнем приложения уходил в строку дословно — в том числе при ответе 401, потому что слой журнала стоит снаружи ограничителя частоты
  • Причина: правило «путь спрашивающего в журнал не идёт» было записано только для запроса, отданного приложению. Путь вида /app/<текст> принадлежит сервису, под то правило не подпадал и уезжал целиком, хотя множеством значений под корнем распоряжается тот же аноним
  • Чем воспроизведён: прогон враждебного прохода — путь в 1 044 480 знаков дал прирост журнала в 1 044 632 байта; одно соединение за 1,003 с дало 122 запроса и 121,5 МиБ журнала; 120 отказов ограничителя оставили 240 строк
  • Почему не поймали раньше: правило записали по месту, где его впервые понадобилось применить, а не по признаку «значением распоряжается спрашивающий». Зазор был ровно шириной в корень приложения
  • Что меняем: JournalRoute обобщает всё, что накрыто корнем приложения, а длину отдаёт полем http.path_length; дословно пишутся только адреса из закрытого перечня. Правило в conventions/logging.md переписано на все корни разом — оно стоит теперь у строки о всяком входящем запросе, а не у строки о раздаче приложения

2026-08-23 — ключ бюджета ограничителя выбирал тот, кого ограничивают [пойман ревью]

  • Где: internal/controller/http/rate_limit.go, clientAddress; задача storage-without-pocketbase
  • Симптом: ограничитель пропустил 1200 запросов одного спрашивающего при бюджете 120 за окно. Заодно карта счётчиков росла линейно от числа выдуманных адресов
  • Причина: адрес брался из левого значения X-Forwarded-For, а прокси заголовок дописывает, а не заменяет. Левым значением распоряжается сам спрашивающий, значит он же выбирает и ключ карты — и меняет его на каждом запросе
  • Чем воспроизведён: прогон враждебного прохода — 1200 пропущенных запросов при бюджете 120; 200 000 ключей в карте дали прирост кучи в 19 810 376 байт
  • Почему не поймали раньше: слой писался заново вместе с транспортом, а свойство «ключ бюджета не выбирает тот, кого ограничивают» не стояло ни в конвенции, ни в типовом узле — его держала прежде чужая библиотека
  • Что меняем: цепочка читается справа налево, доверенные адреса отбрасываются, ключом становится первый недоверенный, а заголовок читается всеми строками, а не одной. Требование к контуру этим снято: дописывающий прокси правилом покрыт — security.md, «Периметр»

2026-08-23 — инвариант о колонках записи потерял предмет [пойман ревью]

  • Где: CLAUDE.md, «Инварианты»; internal/adapter/repo/sqlite/record_mapping.go, internal/archrules
  • Симптом: инвариант называл поимённо applyOwnedByPipeline, applyToRecord и recordToAudioRecord — функций с такими именами в коде уже не было. Сослаться на инвариант как на оракул стало нельзя
  • Причина: сторож и отображение переписаны под новую форму хранилища, а текст инварианта остался от прежней. Мест при этом стало три: что спрошено (readRecordColumns), куда лягут (recordRow) и что доедет до сущности (rowToAudioRecord), — а сверялось правилом одно
  • Чем воспроизведён: grep по трём прежним именам — пусто; grep по rowToAudioRecord в internal/archrules — пусто
  • Почему не поймали раньше: инвариант проверяется правилом, а имена в его тексте — ничем. Текст и сторож разошлись молча
  • Что меняем: инвариант назван действующими именами и действительным числом мест; правило internal/archrules расширено на rowToAudioRecord — перечень колонок чтения сверяется с перечнем присвоений в сущность

2026-08-15 — короткая форма рецепта входа не работала, а проверяли длинную [пойман ревью]

  • Где: cmd/oidcstub — подставной провайдер OIDC для локального входа; доккоммент пакета, подсказка флага -sub и проза config.example.toml
  • Симптом: рецепт «второй вошедший получается сменой -sub» записан в трёх местах и в короткой форме не работал вовсе. Заглушка отдавала обоим sub одну и ту же почту умолчанием, вход отвечал 401, а причина оставалась строкой в журнале хранилища
  • Причина: обмен ищет учётную запись сперва по признаку провайдера, а не найдя — по адресу почты. Второй sub при общей почте приходил к первой записи, а признак провайдера на записи уникален — idx_externalAuths_record_provider — и связь отвергалась. Умолчание почты стояло своим значением вместо выведенного из sub
  • Почему не поймали раньше: рецепт проверяли длинной формой, где почта задана флагом явно. Короткую не гонял никто, хотя записана она первой и берут читатели именно её
  • Что меняем: проверять ту форму рецепта, которая записана короче всех. Оракул — прогон именно её: два входа подряд разными -sub без прочих флагов, затем счёт записей в коллекции пользователей. Само умолчание почты теперь выводится из -sub

2026-08-15 — своя раздача статики потеряла отказ от записи успеха [пойман ревью]

  • Где: internal/controller/http/webapp.go, регистрация корневого маршрута; задача spa-skeleton
  • Симптом: каждый успешный ответ разметкой и ресурсом клал в журнал хранилища выбранный анонимом путь вместе с его адресом и держал строку пять суток. При этом строка docs/review.md, добавленная той же задачей, утверждала, что путь анонима в журнал не идёт
  • Причина: готовая раздача статики библиотеки первой же строкой ставит признак «успех не записывать». Своя написана мимо неё — и не зря, подстановка разметки у готовой не отличает отсутствующий ресурс от неизвестного пути, — но признак при переписывании не перенесён. Журналов у сервиса два, а сделанная защита закрыла один
  • Почему не поймали раньше: свойство было записано утверждением, а проверялось только против журнала контейнера. Второй журнал живёт в базе, и ни один тест туда не смотрел
  • Что меняем: утверждение о журнале называет оба журнала поимённо. Оракул — чтение таблицы журнала после прогона: три успешных запроса не оставляют строк, два отказа оставляют

2026-08-15 — единая форма отказа не покрывала то, что рождается не в обработчике [пойман ревью]

  • Где: internal/controller/http/errors.go, слой OneErrorForm; задача json-api-for-spa
  • Симптом: три отказа под корнем приложения — превышение потолка тела, ограничитель частоты и неизвестный путь — уходили телом библиотеки, без машиночитаемого кода и без предела числом. То есть форм отказа на адресах приложения было две, а не одна, — ровно то, ради чего задача и заводилась
  • Причина: отображение доменной ошибки заведено верно, но покрывает лишь то, что вернул обработчик. Предел тела и ограничитель частоты рождают отказ слоями ниже, а «ничего не совпало» — вовсе маршрутом корневой группы, к которому слои нашей группы не привязаны. Комментарий у слоя при этом перечислял все три случая как закрытые
  • Почему не поймали раньше: оракулом служил комментарий, а не прогон. Приёмочный тест звал отображатель напрямую ошибкой, которую сам же и сочинил, — запроса он не слал и потому оставался зелёным независимо от того, что происходит при настоящем HTTP-запросе. Ветвь too_large при этом не имела ни одного производителя в рабочем коде
  • Что меняем: проверка, стерегущая форму ответа, обязана слать настоящий запрос; вызов отображателя напрямую формой ответа не является. Добавлено вопросом в раздел ниже

2026-08-15 — пустой второй ответ распознавателя стирал сохранённую расшифровку [пойман ревью]

  • Где: internal/adapter/repo/pocketbase/text_repo.go, TextRepository.Put и StructureRepository.Put; путь до них — pollstoreOutcome в internal/service/transcribe.go. Кода задачи remove-telegram-intake дефект не касался: она этот путь не трогала
  • Симптом: поток от SpeechKit, закрывшийся на первом же ответе, отказом не считается — наружу уходит пустой результат без отказа. Замена содержимого шла безусловно, и повторный опрос той же операции клал пустое поверх сохранённой расшифровки. Шаг при этом объявлял запись готовой: рубеж двигался, опрос готовности отдавал done без текста
  • Причина: соседний хранитель того же результата — сырой ответ провайдера — от пустого значения защищён условием len(raw) > 0 с самого заведения, а текст и структура реплик такого условия не имели. Разное правило у двух хранителей одного результата
  • Чем воспроизведён: падающий тест враждебного прохода, переснятый триажем, — expected: "Личный разговор." actual: "". Оракул закреплён в дереве: internal/service/recognition_test.go, TestEmptySecondAnswerKeepsArchivedText; он же проверяет, что до второго ответа дело действительно дошло
  • Почему не поймали раньше: повторный опрос одной операции — не редкость, но и не штатный путь: он наступает, когда держатель захвата умер, сохранение рубежа отказало либо человек снял остановку в панели. Ни один прогон до этого не строил такого входа, а от чтения кода защита у соседа выглядела общей
  • Что меняем: правило «пустое не кладётся поверх сохранённого» записано нормой в спеку storage и держится хранилищем, а не шагом: шагов, кладущих текст, больше одного, и правило у одного из них у остальных читалось бы как снятое. Дефект существовал до той правки, чинился решением владельца от 2026-08-14 в задаче, которая его нашла

2026-08-15 — пустая расшифровка перестала быть заметной вместе с убранным входом [пойман ревью]

  • Где: internal/service/transcribe.go, шаг завершения; документы docs/conventions/logging.md и docs/architecture.md
  • Симптом: запись с пустым распознаванием доходила до конечного рубежа и от успешной не отличалась ничем — ни строкой журнала, ни ответом опроса
  • Причина: единственным следом этого случая был текст, уходивший отправителю в чат («на записи нет текста»). Задача убрала доставку целиком, и след исчез вместе с ней — при том, что конвенция журнала называет пустой текст распознавания поимённым примером уровня «может стать проблемой», а обзор архитектуры обещал заглушку
  • Чем воспроизведён: internal/service/recognition_test.go, TestEmptyRecognitionIsNamedInJournal — подставной распознаватель отдаёт готовую операцию с пустым результатом, проверка судит уровень строки и идентификатор записи
  • Почему не поймали раньше: удаление сняло последнего потребителя видимого признака, а не сам признак; такое не видно ни компилятору, ни грепу по удаляемому имени. Нашёл проход конвенций, сверив таблицу уровней журнала с тем, что осталось в коде
  • Что меняем: шаг опроса пишет строку уровня «может стать проблемой» с идентификатором записи; строка обзора архитектуры переписана на фактическое поведение. Класс общий: удаляя канал, проверь, не был ли он единственным потребителем сигнала — сигнал переживает канал только там, где его переносят руками

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-resultforbidigo в .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