# Ревью remove-telegram-intake — триаж ## Сводка - **Режим:** по графу. **Метка:** `large` (разметка `review-scope`). 50 файлов, −2394 строки, семь удалённых файлов кода, новый шаг схемы; необратимый элемент — шаг схемы, переписаны инварианты `CLAUDE.md`. - **База диффа:** `origin/master` отстала на два закрытых изменения; сверка шла против `HEAD` плюс незакоммиченное рабочее дерево. - **Гейт:** зелёный целиком (`task gate` → 0). - **Сигнал о заниженной метке:** `review-code` возражений не подал. `review-basics` не запускался — второго независимого голоса о метке нет. - **На вход:** 22 находки (specs 5, code 8, architecture 3, ops 2, adversary 4, autotests 0) плюс 4 замечания. После дедупликации по причине — 11 различных причин, одна выброшена как ошибочная. ### План с исходом по каждой теме | тема | дом | глубина | кто закрывает | исход | | --- | --- | --- | --- | --- | | requirements | `openspec/specs/{intake,pipeline,access,storage}` + дельты | разбор | specs | закрыта, 5 находок | | autotests | `CLAUDE.md` «Гейт»/«Команды» | — | autotests | закрыта, 0 находок + замечание о дрейфе `go-linters.md` | | conventions + техника | `docs/conventions/` | разбор | code | закрыта, 7 находок + 1 ошибочная | | architecture | `docs/architecture.md` (+ `passport.md`) | доказательство | architecture | закрыта, 3 находки | | security | `docs/security.md` | доказательство | adversary | закрыта, 4 находки + 3 свойства без пути | | operations | `docs/architecture.md` «Эксплуатация» (+ `database.md`) | доказательство | ops | закрыта, 2 находки | Тем без отчёта нет. Тем без дома нет. `basics` не запускался — своих тем сверх ядра план ему не дал. ## Блокирует мердж ### Ничья запись переживёт новый шаг схемы и станет незакрываемой - Файл: `internal/adapter/repo/pocketbase/migrations/202608140003_owner_required.go:20-30`; `openspec/changes/remove-telegram-intake/design.md`, «Migration Plan», п. 1 - Severity: major. Confidence: high - Оракул (переснят триажем, `go test ./internal/adapter/repo/pocketbase/ -run TestTriageProbeOwnerRequiredOverOwnerlessRow`): ``` PRE-STEP: ничья запись заведена, owner="" STEP 003 (Required=true) поверх ничьей записи: err= ПОСЛЕ ШАГА: строка на месте, owner="" FindAndAcquire: выдана запись Save остановленной ничьей записи: err=failed to update audio record: owner: cannot be blank. ``` - Последствие: `Required` у поля связи PocketBase — проверка при сохранении записи, а не ограничение таблицы. Шаг проходит зелёным на базе с ничьей записью, и запись остаётся. Дальше она выдаётся воркеру (захват идёт сырым запросом мимо валидации), любое сохранение падает, `halt()` перехватывает отказ до строк, растящих `WorkerJobCounter` и пишущих `record_events`. Запись не останавливается, берётся снова по истечении срока захвата и повторяется неограниченно: ни метрики, ни журнала событий, только строка в логе контейнера. Предвыкладочная проверка, на которой стоит безопасность шага, не отличает чистую базу от грязной. - Найдено проходами: specs, code, ops (три прохода, одна причина). - **Действие: развилка.** Три варианта: (1) шаг сам считает ничьи строки и отказывается; (2) шаг остаётся, утверждение «падает на живой базе» уходит из комментария и плана, а в порядок выкладки добавляется ручная проверка запросом; (3) шаг приводит данные — необратимо, решение владельца обязательно. ### Второй ответ провайдера, приехавший пустым, стирает сохранённую расшифровку - Файл: `internal/service/transcribe.go:716-733` (`poll` → `storeOutcome`), `internal/adapter/repo/pocketbase/text_repo.go:40-53` - Severity: critical. Confidence: high - Оракул: падающий тест, переснят триажем — `expected: "Личный разговор." actual: ""`. - Последствие: поток gRPC SpeechKit, закрывшийся на первом `Recv`, отказом не считается — `outcome` пуст, `err == nil`. `Texts.Put` кладёт пустое поверх сохранённого безусловно, рубеж двигается, запись доходит до `done` без текста. Сырой ответ уцелеет (`Finish` пишет вложение под условием `len(raw) > 0`), но `ReadRaw`/`Parse` в боевом коде не зовёт никто. - **Регрессией этого изменения не является:** `poll`, `storeOutcome` и `text_repo.go` диффом не тронуты. Изменение сняло последний видимый признак вырожденного ответа — заглушку «на записи нет текста», — но она уходила только в Telegram. - Найдено проходом: adversary. - **Действие: развилка.** (1) `storeOutcome` не пишет пустое поверх непустого; (2) вырожденный ответ считается отказом шага; (3) задача, мердж как есть. ## Стоит исправить сейчас ### Пустая расшифровка перестала замечаться, а два документа обещают, что она замечена - Файл: `internal/service/transcribe.go` (`finish`), `docs/conventions/logging.md:62`, `docs/architecture.md:142` - Severity: major. Confidence: high - Оракул: `logging.md:62` называет «пустой текст распознавания» поимённым примером уровня `WARN`; `architecture.md:142` утверждал «запись завершается заглушкой „на записи нет текста"». Заглушку изменение убрало вместе с доставкой, замены не было. - **Действие: инлайн. Исправлено:** `poll` пишет `WARN` с идентификатором записи при пустом результате; строка `architecture.md:142` переписана на фактическое поведение; заведён оракул `TestEmptyRecognitionIsNamedInJournal`. ### Приёмка переписанного инварианта «остановленная запись несёт причину» не может упасть - Файл: `internal/service/pipeline_test.go` - Severity: minor. Confidence: high - Оракул: `Halt()` ставит `HaltedAt` и `HaltReason` одним движением, `IsHalted()` читает `HaltedAt` — значит `require.NotNil(HaltReason)` следует из `require.True(IsHalted())`. Независимый сигнал (`sender.sent()`) убран вместе с доставкой. - **Действие: инлайн. Исправлено:** вторым утверждением взята строка журнала событий — отдельное сохранение, способное упасть само по себе; добавлена третья причина остановки. ### Удаление входа не доведено: подавление линтера, конвенции, фикстуры и комментарии - Файл: `.golangci.yml:158`; `docs/conventions/go-linters.md:81,92,155`; `internal/contract/error.go`; `internal/config/config_test.go`; `internal/service/transcribe.go`; `internal/contract/repository.go`; `internal/metrics/format_label.go`; `internal/service/ownership_test.go`; `openspec/specs/pipeline/spec.md:104` - Severity: minor. Confidence: high - Последствие: подавление `errcheck` по мёртвому символу — заряженная мина: вход убран временно, метод `send` вернётся под тем же именем и молча окажется без проверки отказов. Остальное — документы и комментарии, обещающие ответ отправителю, включая нормативный доклад `contract/repository.go`. - Найдено проходами: specs, code, architecture, autotests (одна причина, четыре прохода). - **Действие: инлайн. Исправлено целиком:** снято подавление и строки про `send`; `controller/tg` убран из таблицы архправил; удалён `ErrDeliveryChannelDown`; переписаны комментарии; фикстуры переведены с мёртвой секции; требование «Захват задачи неделим» внесено в дельту. ### Анонимный запрос кладёт до мегабайта своего текста в журнал контейнера одной строкой - Файл: `main.go:184-202` - Severity: major. Confidence: high - Оракул: живой прогон — 900000 байт в пути → 404, журнал вырос с 1048 до 901194 байт. - Последствие: длину строки журнала задаёт неузнанный посетитель; разбор инцидента по журналу становится невозможен. Тот же класс назван недопустимым в `internal/controller/http/auth.go:277-284`. - **Дефект пред-существующий:** `main.go` этим изменением правится только в части подъёма входов. - **Действие: развилка.** Чинить здесь или заводить задачей. ## Гипотезы без доказательства - **`transcribed` — лишний рубеж** (architecture): готовая расшифровка платит отдельный захват и попадает под сторож застревания за проход, переставляющий одну колонку. Оракула, что это случалось, нет. Вопрос владельцу — место ему в задаче. - **Отказ приёма по пустому владельцу приходит новому вызывающему как 500** (specs): путь сегодня недостижим, транспорт отвергает раньше. - **`http.status_code=0` у всякого отвергнутого запроса** (adversary, `main.go:194-199`). Пред-существующее. - **Хвост имени отправителя уезжает в журнал внутри поля `error`** (adversary): инвариант приватности не нарушен, нарушена форма изъятия. Пред-существующее. - **Три свойства без построенного пути** (adversary): длительность из метаданных ничем не ограничена (переполнение `int`); непустой, но более короткий ответ провайдера затирает и вложение; `POST /api/collections/users/request-verification` открыт анониму. **Выброшено как ошибочное:** находка `code` о `gofmt` на `pipeline_test.go` — `gofmt -l .` пуст, проверено дважды. **Выброшено как вкусовщина:** переименование `CreateJobFromApi`. **Проектные ложноположительные:** строка «Запись без владельца не достаётся никому» в `docs/review.md` отменена дважды, последний раз этой же задачей — находка о ничьей записи ей подтверждается, а не отсеивается. ## Promote candidates - **В `docs/conventions/logging.md`:** длину поля журнала не задаёт вызывающий — всё, что приходит извне, уезжает в журнал усечённым до объявленного предела. Правила нет, случай второй. - **Отклонённый кандидат:** страж существования символов в `exclude-functions` — заводить нельзя, `CLAUDE.md` «Проверок над проверками не заводить» запрещает стражей предмета у правил. ## Границы покрытия **Что запускалось.** Шесть проходов на метке `large`, режим по графу: specs, autotests, code, architecture, adversary, ops. `basics` не запускался — план не дал ему тем сверх ядра. Корректор метки (`review-code`) отработал, возражений не подал; второго корректора в прогоне не было. **Потолки проходов.** Ни один проход не сообщил свой потолок и что осталось за срезом, хотя контракт обязывает. Неизвестно, показал ли `code` все находки или только верхние. **Что не влезло в потолок триажа** (названо, а не выброшено): - откат образа после вычистки секции `[telegram]` из боевого конфига роняет старт прежнего бинаря (`ops`, оракул — прогон исторического бинаря `9a964f2`); - ряд метрики `intake_up{telegram}` исчезает, а не обнуляется — цена не названа в документах владельца; - `down202608140003` не покрыт (0.0%) — так у всех четырёх шагов `down*`, и они операционно недостижимы: cobra-команды PocketBase не подключены; - частичное покрытие двух требований дельты: «Поднятые входы видны наблюдателю» (оракул только живой) и «Приведённая копия получает владельца записи» (косвенно). **Что осталось целиком на человеке** — из `docs/review.md`, «Недоступно проверке»: поведение внешних сервисов под нагрузкой и на границах, реальный профиль нагрузки, стойкость `ffmpeg` к вредоносному входу, поведение настоящей Authelia, поведение браузера с куками. Сознательно перестали проверять: разбор вывода настоящего `ffprobe`; работа с настоящими SpeechKit и Object Storage; вход через живого провайдера OIDC. **Четыре строки, которые в конвейере не закрывает никто:** 1. Решения проекта не сверялись — `docs/adr/` процессный, расхождение ловит `av-dev:doc-healthcheck`. 2. Записанные наблюдения (`docs/research/`) не использовались; всякое число снято на этом прогоне. 3. Поимённая сверка с руководствами по стилю Go не задавалась ни одним проходом. 4. Альтернативной реализации, с которой можно сдиффить решения, у конвейера нет. **Чем работал триаж.** Две пробы на копии дерева в скретчпаде, базы — временные каталоги. Боевых данных, боевых ключей и выкладки не касался. Формулировки «критичных проблем не обнаружено» в отчёте нет: одна `critical` подтверждена падающим тестом и живёт в сервисе прямо сейчас.