- main.go и journal_route_test.go переехали в cmd/transcriber без правок содержимого; образ собирает ./cmd/transcriber поимённо - cmd/oidcstub отвечает на /authorize, /token и /userinfo, проверок не делает и слушает петлевой адрес: войти без Authelia стало чем - ступень сборки приложения переехала на node:24 с alpine — musl ждёт ответа на AAAA, которого нет, и npm ci висел вместо отказа
820 lines
80 KiB
Markdown
820 lines
80 KiB
Markdown
# Ревью: настройка и журнал
|
||
|
||
## Как настроен конвейер
|
||
|
||
Артефакты прогонов лежат в `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/http`):
|
||
|
||
- проверяет право отправителя до всякой работы;
|
||
- не логирует ошибку, которую уже залогировал доменный слой;
|
||
- переводит доменную ошибку в свой ответ, а не отдаёт сырой текст;
|
||
- закрывает то, что открыл, на всех ветках выхода.
|
||
|
||
**Раздача собранного приложения и шаг его сборки** (`controller/http/webapp.go`,
|
||
шаг `front`):
|
||
|
||
- путь, принадлежащий корню сервиса, разметку не отдаёт никогда, а перечень
|
||
корней порождает регистрацию маршрутов, а не описывает её;
|
||
- несовпавший ресурс под каталогом сборщика отвечает `404`, а не разметкой с
|
||
кодом `200`;
|
||
- раздача ставит долгий неотзываемый срок хранения **только** файлу из каталога
|
||
сборщика: отозвать его у браузера сервису нечем;
|
||
- отсутствие сборки громкое — код ответа, страница и строка журнала; «сборки
|
||
нет» отличается от «файла нет»;
|
||
- вшито то, что собрано этим прогоном, а не то, что осталось от прошлого;
|
||
- шаг следует словарю кодов: отказ сети и реестра — 3, красная сборка — 1, и он
|
||
**отказывает, а не висит**;
|
||
- путь, выбранный анонимом, не уходит ни меткой метрики, ни строкой журнала — и
|
||
журналов **два**: свой, в вывод контейнера, и журнал хранилища, куда
|
||
библиотека кладёт путь целиком вместе с адресом отправителя. Второй молчит
|
||
только на успехе и только потому, что признак отказа от записи поставлен
|
||
руками: готовая раздача статики ставит его сама, своя — нет.
|
||
|
||
**Клиент внешнего сервиса** (`adapter/recognizer/yandex`):
|
||
|
||
- имеет таймаут и не виснет, когда внешний сервис не отвечает;
|
||
- не кладёт секрет в URL и не даёт ему утечь через ошибку транспорта;
|
||
- различает «сервис ответил отказом» и «сервис недоступен»;
|
||
- вырожденный ответ (пустой, усечённый, без ожидаемого поля) не превращает в
|
||
успех молча.
|
||
|
||
**Репозиторий хранилища** (`internal/adapter/repo/pocketbase`; шаги схемы —
|
||
подпакетом `migrations`):
|
||
|
||
- список колонок совпадает в обоих местах — `applyOwnedByPipeline` вместе с
|
||
`applyToRecord` и `recordToAudioRecord` — и в шаге схемы (инвариант
|
||
[CLAUDE.md](../CLAUDE.md), «Инварианты»);
|
||
- захват задачи не выдаёт одну строку двум вызывающим, а результат пишет только
|
||
держатель захвата;
|
||
- репозиторий кладёт время в сыром запросе тем же видом, каким хранилище пишет
|
||
свои `created`/`updated` ([database.md](database.md), «Представление данных»);
|
||
- отказ хранилища не выходит наружу дословно: он несёт ключ файла целиком.
|
||
|
||
**Обёртка над внешним процессом** (`adapter/converter/ffmpeg`,
|
||
`adapter/metaviewer/ffmpeg`):
|
||
|
||
- отсутствие программы в `PATH` отличается от отказа обработки;
|
||
- вход, пришедший от пользователя, не попадает в аргументы командной строки
|
||
неразобранным;
|
||
- пустой или частично записанный выходной файл считается отказом;
|
||
- процесс не висит вечно.
|
||
|
||
**Любой узел** — сверх свойств своего рода:
|
||
|
||
- изменённое место покрыто хоть одним **проходящим** тестом. Тест, который
|
||
никогда не был зелёным, обнуляет сигнал всего пакета: настоящий отказ в нём
|
||
становится неотличим от привычного шума (журнал, запись 2026-08-10).
|
||
|
||
Свойств о годности самих проверок здесь больше нет — ни мутации теста, ни
|
||
мутации оракула критерия приёмки, ни требования сценария к норме. Запрет и его
|
||
границы — [CLAUDE.md](../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](../openspec/specs/pipeline/spec.md).
|
||
|
||
**Оговорка, и она тут главная:** ложноположительным считается только само
|
||
молчание воркера. Проверка **формы** узнавания ложноположительной не является:
|
||
приведение типа на этом месте — настоящий дефект, закрытый 2026-08-11 задачей
|
||
`errors-as-instead-of-typecast`. Появилось снова — это регрессия, и выбрасывать
|
||
её как известную нельзя.
|
||
- **«Захват записи не в транзакции — гонка двух воркеров».** ~~По построению её
|
||
нет: три воркера читают три разных состояния, и одну строку они не делят.~~
|
||
**Отменено 2026-08-14 задачей `record-centric-model`:** построение снято. Пул
|
||
одинаковых воркеров конкурирует за один и тот же набор записей, и второй
|
||
воркер на тот же рубеж теперь есть всегда, когда их больше одного. Находка о
|
||
гонке захвата стала настоящей и выбрасывается только по существу — механика
|
||
захвата и её слабые места в [database.md](database.md), «Представление
|
||
данных». Строка оставлена отменённой, а не удалена: прогон, помнящий прежнюю
|
||
редакцию, иначе выбросил бы настоящую находку как известную.
|
||
- **«Файлы и объекты не удаляются, диск растёт».** Факт верный и записан в
|
||
[database.md](database.md); срок хранения не задан сознательно, задачи на него нет.
|
||
Новой находкой это не считается, пока не измерен рост.
|
||
- **«Запись без владельца не достаётся никому».** Строка отменена **дважды**, и
|
||
обе отмены оставлены намеренно: прогон, помнящий любую из прежних редакций,
|
||
иначе выбросил бы настоящую находку как известную.
|
||
|
||
До задачи `record-ownership` здесь стояло «вошедший видит чужие записи — не
|
||
дефект и не новость»: разграничения не было сознательно. Первая отмена
|
||
2026-08-14 завела разграничение и объявила не дефектом уже другое — запись без
|
||
владельца, принятую ботом.
|
||
|
||
Вторая отмена того же дня, задачей `remove-telegram-intake`, сняла и это:
|
||
колонка владельца пустого значения больше не принимает, ничьих записей у
|
||
сервиса не бывает вовсе. **Запись без владельца сегодня — настоящая находка**,
|
||
а не известное исключение.
|
||
|
||
### Вопросы по темам
|
||
|
||
Форма: `<тема>: <вопрос> (<откуда>)`.
|
||
|
||
- `operations`: не завёл ли инструмент разработчика второй дом тому, что уже есть
|
||
в проверках. Подставных провайдера OIDC в репозитории теперь два — `cmd/oidcstub`
|
||
и `fakeProvider` в `internal/controller/http/login_test.go`, — с теми же
|
||
адресами и той же посылкой про `email_verified`, и они уже разошлись в мелочи
|
||
(`token_type` «bearer» против «Bearer»). Тем же вопросом судится подставной
|
||
распознаватель. Записанной конвенции о единственном доме подставных внешних
|
||
собеседников у проекта нет, поэтому спрашивать надо, а не считать нарушением
|
||
(ревью задачи про заглушку OIDC, 2026-08-15).
|
||
- `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`: появился ли таймаут у обращения к S3 и SpeechKit — ни у одного
|
||
из них таймаута нет, и проброс контекста на этот вопрос **не отвечает**:
|
||
контекст здесь несёт жизнь процесса, а не дедлайн вызова (чтение `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`: не появился ли второй путь приёма мимо `createRecord` — сегодня
|
||
он единственный, которым запись попадает в хранилище
|
||
([architecture.md](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](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 — любой её кусок;
|
||
- смена модели очереди: захват, повторы и воркеры разом;
|
||
- каркас приложения: сборка фронтенда, раздача статики и шаг гейта разом;
|
||
- изменение, убирающее или возвращающее вход приёма целиком.
|
||
|
||
**Незнакомое здесь** (поднимает до `large`, ось формы решения):
|
||
|
||
- вход через OIDC и разграничение доступа: как связать чат Telegram с учётной
|
||
записью, до начала работы назвать нельзя;
|
||
- всё, что делается на выбранном фреймворке впервые: правила
|
||
[conventions/web-ui.md](conventions/web-ui.md) выведены из выбора и из замера
|
||
на пробном экране, а не из написанного кода, и первая же задача проверяет их
|
||
собой — форма решения нащупывается по ходу;
|
||
- установка на телефон: service worker перехватывает запросы, и что он кэширует,
|
||
до работы назвать нельзя;
|
||
- работа с записями в несколько часов: потолки внешних сервисов не замерены,
|
||
форма решения зависит от замера;
|
||
- приём дорожки из видео и форматов, которых `ffmpeg` не берёт текущей командой;
|
||
- всё, что требует записи в `research/` прежде, чем начать.
|
||
|
||
**Мелкое здесь** (опускает до `small`):
|
||
|
||
- новая метрика в `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);
|
||
- **работа сервиса с настоящими внешними собеседниками.** Сам сервис поднять
|
||
можно: он встаёт своим единственным входом на выдуманных непустых ключах
|
||
секций `[auth]` и `[yandex]` — наружу они на старте не ходят. Живой прогон —
|
||
осмотр HTTP, панели, журнала, метрик и остановки — доступен любой задаче.
|
||
Прежняя формулировка «всё, что требует поднять сервис целиком» снята задачей
|
||
`local-run-without-telegram-token` 2026-08-13; рецепт прогона менялся дважды —
|
||
с пустого ключа доступа на выключенный вход (`telegram-enabled-flag` того же
|
||
дня), а 2026-08-14 признак включения ушёл вместе с самим входом.
|
||
|
||
**Остаток**: за настоящие SpeechKit и Object Storage живой прогон по-прежнему
|
||
не отвечает — ключи Yandex в прогоне выдуманные, а распознавание подменяют в
|
||
коде. Проверить живьём можно подъём, отказ старта, маршруты, метрики и
|
||
остановку; нельзя — расшифровку и заливку. Вход через живого провайдера OIDC
|
||
тоже недоступен: сессию в прогоне выдать нечем.
|
||
|
||
## Журнал дефектов
|
||
|
||
Записи новые сверху. `[пойман ревью]` — дефект нашёл прогон конвейера,
|
||
`[пойман сканером]` — тест-сканер `internal/archrules`, `[проскочил]` — дефект
|
||
уехал в код, и поймать его тогда было некому. Две нижние записи восстановлены по
|
||
истории git 2026-08-10: поле «Чем воспроизведён» называет у них коммит, а не
|
||
оракул, и выдумывать оракул задним числом нельзя.
|
||
|
||
## 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`; путь до них — `poll` → `storeOutcome` в
|
||
`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](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)
|