Files
transcriber/docs/review.md
T
av b7d4660aef канон: раскладка повышена до версии 4
- слово «провенанс» снято из словаря проектных текстов: у числа теперь
  «происхождение», у вопроса и находки — «откуда»
- правлены форма вопроса в docs/review.md, шапка раздела об OIDC в
  docs/research/pocketbase.md и две записи задач; формулировки прошли
  вычитку агентами doc-wording и task-wording
- архив openspec/changes/archive/ не тронут: слово, верное на день записи,
  остаётся свидетельством
2026-08-14 09:51:01 +03:00

641 lines
61 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Ревью: настройка и журнал
## Как настроен конвейер
Артефакты прогонов лежат в `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](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](../CLAUDE.md), «Инварианты»);
- захват задачи не выдаёт одну строку двум вызывающим, а результат пишет только
держатель захвата;
- репозиторий кладёт время в сыром запросе тем же видом, каким хранилище пишет
свои `created`/`updated` ([database.md](database.md), «Представление данных»);
- отказ хранилища не выходит наружу дословно: он несёт ключ файла целиком.
**Обёртка над внешним процессом** (`adapter/converter/ffmpeg`,
`adapter/metaviewer/ffmpeg`):
- отсутствие программы в `PATH` отличается от отказа обработки;
- вход, пришедший от пользователя, не попадает в аргументы командной строки
неразобранным;
- пустой или частично записанный выходной файл считается отказом;
- процесс не висит вечно.
**Любой узел** — сверх свойств своего рода:
- изменённое место покрыто хоть одним **проходящим** тестом. Тест, который
никогда не был зелёным, обнуляет сигнал всего пакета: настоящий отказ в нём
становится неотличим от привычного шума (журнал, запись 2026-08-10).
Свойств о годности самих проверок здесь больше нет — ни мутации теста, ни
мутации оракула критерия приёмки, ни требования сценария к норме. Запрет и его
границы — [CLAUDE.md](../CLAUDE.md), «Запреты».
### Типовые ложноположительные
- **«Воркер глотает ошибку `NoopJobError`».** Не дефект: этот тип означает «задач
в этом состоянии нет», и `internal/controller/worker/worker.go` намеренно не
логирует его и не считает в метрику. Норма записана требованием
[pipeline](../openspec/specs/pipeline/spec.md).
**Оговорка, и она тут главная:** ложноположительным считается только само
молчание воркера. Проверка **формы** узнавания ложноположительной не является:
приведение типа на этом месте — настоящий дефект, закрытый 2026-08-11 задачей
`errors-as-instead-of-typecast`. Появилось снова — это регрессия, и выбрасывать
её как известную нельзя.
- **«Захват задачи не в транзакции — гонка двух воркеров».** По построению её
нет: три воркера читают три разных состояния, и одну строку они не делят.
Механика захвата и её слабые места — [database.md](database.md),
«Представление данных». Находка становится настоящей ровно тогда, когда
появится второй экземпляр процесса или второй воркер на то же состояние.
- **«Файлы и объекты не удаляются, диск растёт».** Факт верный и записан в
[database.md](database.md); срок хранения не задан сознательно, задачи на него нет.
Новой находкой это не считается, пока не измерен рост.
- **«У записи нет владельца: вошедший видит чужие записи».** Не дефект и не
новость: приём, опрос и файл закрыты сессией OIDC с 2026-08-12, а
разграничения по владельцу нет сознательно — [security.md](security.md),
«Периметр», и `openspec/specs/access`, «Purpose». Находкой считается новая
поверхность, выставленная наружу, либо путь к содержимому записи **без**
сессии, а не повторение этого факта.
### Вопросы по темам
Форма: `<тема>: <вопрос> (<откуда>)`.
- `operations`: как шаг отвечает на отмену посреди работы — контекст доходит до
внешнего собеседника и это держат правила `noctx` и `contextcheck`
([conventions/go-linters.md](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](security.md) и
[conventions/logging.md](conventions/logging.md)).
- `security`: не строится ли путь на диске или ключ объекта из значения,
пришедшего снаружи, — расширение файла сегодня берётся из имени отправителя
(чтение `service/transcribe.go`, 2026-08-10).
- `security`: не уходит ли значение, пришедшее снаружи, меткой метрики — страница
метрик отдаётся без проверки отправителя, и метка это поверхность пошире
журнала (журнал, запись 2026-08-11 про хвост имени).
- `architecture`: не появился ли второй путь приёма мимо
`createTranscribeJob` — сегодня через него идут оба входа
([architecture.md](architecture.md), «Единые точки проекта»).
- `architecture`: не поехало ли поведение в `architecture.md` вместо спеки —
заведены четыре capability (`intake`, `pipeline`, `storage`, `access`), и
первые две описаны частично. Поведение прочих узлов, включая
приём из Telegram, живёт в обзоре под маркерами долга, а соблазн дописать туда
ещё — самый большой.
- `conventions`: новая колонка правится во всех четырёх местах репозитория
(CLAUDE.md, «Инварианты»).
- `autotests`: покрыт ли изменённый шаг конвейера хоть одним **проходящим**
тестом. Что уже закрыто проверками, видно по журналу дефектов ниже и по
[conventions/go-linters.md](conventions/go-linters.md), «Механизировано»;
числа файлов здесь не называем — оно протухает с каждой задачей.
- `autotests`: судит ли проверка ответа по готовому ответу, а не по изменяемому
состоянию обработчика — **только там, где ответ идёт мимо recorder**, через
свой `http.ResponseWriter`. Обращение к живой карте recorder'а с
2026-08-12 роняет гейт правилом линтера
([conventions/go-linters.md](conventions/go-linters.md), «Механизировано»), и
спрашивать о нём не нужно.
- `security`: не открылась ли снова поверхность, которую приносит хранилище, —
собственная регистрация, вход по паролю, одноразовый код, восстановление
доступа, продление сессии. Всё это приходит включённым и закрывается нами
(задача `oidc-login` 2026-08-12).
- `security`: не появился ли второй способ получить сессию к тому же человеку —
заголовок вместо куки назван осознанно, прочие способы обязаны быть закрыты.
- `operations`: доходит ли отзыв доступа у провайдера до сервиса и за какой срок —
после входа сервис к провайдеру не обращается, и канал здесь один
(ADR-2026-08-12-session-without-refresh).
- `architecture`: не зовётся ли на каждый запрос то, что меняет состояние
приложения, — сборка роутера хранилища оказалась именно такой.
### Триггеры метки
Проектная конкретизация правила выбора метки. Умолчание — `medium`.
**Крупное здесь** (поднимает до `large`, ось объёма):
- изменение, трогающее конвейер задач целиком: состояние, воркер, шаг сервиса и
колонку разом;
- замена хранилища или переход на PocketBase — любой её кусок;
- смена модели очереди: захват, повторы и воркеры разом;
- каркас приложения: сборка фронтенда, раздача статики и шаг гейта разом;
- изменение, трогающее оба входа сразу — Telegram и HTTP.
**Незнакомое здесь** (поднимает до `large`, ось формы решения):
- вход через OIDC и разграничение доступа: как связаны пользователь Telegram и
пользователь приложения, до начала работы назвать нельзя;
- всё, что делается на выбранном фреймворке впервые: правила
[conventions/web-ui.md](conventions/web-ui.md) выведены из выбора и из замера
на пробном экране, а не из написанного кода, и первая же задача проверяет их
собой — форма решения нащупывается по ходу;
- установка на телефон: service worker перехватывает запросы, и что он кэширует,
до работы назвать нельзя;
- работа с записями в несколько часов: потолки внешних сервисов не замерены,
форма решения зависит от замера;
- приём дорожки из видео и форматов, которых `ffmpeg` не берёт текущей командой;
- всё, что требует записи в `research/` прежде, чем начать.
**Мелкое здесь** (опускает до `small`):
- правка текста, который видит пользователь Telegram;
- новая метрика в `internal/metrics`;
- правка `config.example.toml` и умолчаний `defaultConfig()` без нового поля;
- правка документов канона.
Помни отрицательный тест: миграция, формат файла на диске, публичный контракт
API и имя не откатываются обратной правкой после мерджа — какими бы маленькими
ни были, они не `small`.
### Недоступно проверке
**Не проверит ни один проход:**
- `operations`: поведение внешних сервисов под нагрузкой и на границах —
SpeechKit и Object Storage поднять в тесте нечем;
- `operations`: реальный профиль нагрузки. Проект работает на единицах записей в
день, и утверждения о росте остаются условиями, а не замерами;
- `security`: стойкость `ffmpeg` к вредоносному входу — разбор чужого формата
отдан внешней программе, и она вне нашей границы;
- `security`: поведение настоящей Authelia и её правило на нашего клиента.
Провайдера в прогоне нет, подменяет его свой сервер; кто допущен — настройка
выкладки вне репозитория, и по коду её не проверить
([adr/ADR-2026-08-12-access-delegated-to-provider.md](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](adr/ADR-2026-08-11-stub-adapters-in-tests.md);
- **работа сервиса с настоящими внешними собеседниками.** Сам сервис поднять
теперь можно: с `telegram.enabled = false` он встаёт и работает одним входом
(`openspec/specs/intake`, «Признак включения решает, поднимается ли вход
Telegram»). Живой прогон — осмотр HTTP, панели, журнала и остановки — доступен
теперь любой задаче. Прежняя формулировка «всё, что требует поднять сервис целиком»
снята задачей `local-run-without-telegram-token` 2026-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](conventions/logging.md), «Ошибка
HTTP-транспорта несёт URL». Хуже: там же стояло объявленное *Расхождение* с
оценкой «сегодня она не логируется — то есть утечки нет», и оценка была
неверной. Строка лога существовала всё это время, но проза о ней не знала, а
машина прозу не проверяет
- **Что меняем:** чистку перенесли с места употребления на **границу клиента**
`internal/adapter/telegram`, `NewBot`: свой `Do` разворачивает отказ в
первопричину, а подменённый логгер библиотеки вычищает токен из строк длинного
опроса, которые она печатает сама, мимо нашего `slog`. Транспорт бота токена
больше не получает: клиента ему отдают готовым. Расхождение в конвенции
закрыто, оценка в [security.md](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` и записано
[решением](adr/ADR-2026-08-11-known-format-label.md)
## 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](../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](database.md)