удалён вход Telegram, владелец записи стал обязателен в схеме

- убраны клиент бота, транспорт обновлений, отправитель сообщений, сборка
  входа при старте, секция настроек и зависимость go-telegram-bot-api; из
  конвейера ушла доставка ответа отправителю — исход виден опросом готовности.
  Колонки адресата и значение источника остались в схеме: применённые шаги не
  переписываются
- шаг 202608140003 запрещает пустого владельца у аудиозаписи и у файла;
  существующие строки он не проверяет, и это принято сознательно — искать их
  надо запросом до выкладки
- ревью нашло два пред-существующих дефекта, оба закрыты: пустой второй ответ
  распознавателя стирал сохранённую расшифровку, а пустая расшифровка перестала
  быть заметной вместе с убранной доставкой. Попутно поднят golang.org/x/image
  до v0.45.0 — красный шаг vulns, воспроизводился и на чистом master
This commit is contained in:
av
2026-08-15 07:24:35 +03:00
parent 97ceb7bb69
commit 8f7c3a057a
74 changed files with 2453 additions and 2733 deletions
@@ -0,0 +1,192 @@
# Ревью 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=<nil>
ПОСЛЕ ШАГА: строка на месте, 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` подтверждена падающим
тестом и живёт в сервисе прямо сейчас.