tasks: заведён урожай ревью remove-telegram-intake

- шесть записей по кластерам причин: журнал под внешним значением, нулевой
  код ответа в журнале, затирание вложения бедным ответом, открытый анониму
  адрес подтверждения почты, рубеж расшифровки без работы, пределы длительности
- находка про код 500 у отказа приёма дописана в json-api-for-spa: там живёт
  единая точка отображения доменной ошибки
- telegram-account-link и bot-api-only-through-bot-client оставлены с оговоркой,
  что предмета у них нет до возвращения входа
This commit is contained in:
av
2026-08-15 07:46:16 +03:00
parent 8f7c3a057a
commit b4b19db6e4
11 changed files with 364 additions and 3 deletions
@@ -21,6 +21,13 @@
Ступень по лестнице механизации — четвёртая: свойство о структуре, а не о
вызове, и выражается тест-сканером в `internal/archrules`.
**Предмета у задачи сейчас нет.** Клиент бота удалён вместе со входом задачей
`remove-telegram-intake` 2026-08-15 — временно, решение записано
[ADR-2026-08-15-telegram-intake-removed-temporarily](../../docs/adr/ADR-2026-08-15-telegram-intake-removed-temporarily.md).
Запись остаётся гигиеной **возврата**: класс стоил проекту одного дефекта, и
запрет надо ставить вместе с новым клиентом, а не после него.
## Затрагивает
- `internal/archrules` — новое правило-сканер и его перечень предметов;
@@ -0,0 +1,52 @@
# 🐞 Закрыть анониму адрес подтверждения почты
- **Тип:** fix
- **Категория:** Очередь — Заведена урожаем ревью и встала в конец машинально: место в плане стройки назначает человек.
- **Зачем:** Адрес подтверждения почты хранилища открыт без сессии: зная адрес вошедшего, аноним вызывает у сервиса отправку письма — сегодня безвредно, потому что почтового отправителя нет.
- **Теги:** review-2026-08-15
Поверхность коллекции пользователей закрывается тем же способом, что и остальные
её адреса.
Шаг схемы входа через провайдера закрыл заведение записи, вход паролем и
одноразовый код — но не запрос подтверждения почты. Проверено прогоном по
собранному роутеру: запрос без сессии отвечает `204`.
Сервис своей проверки почты не делает вовсе: учётные записи заводит и проверяет
внешний провайдер, а адрес приезжает вместе с входом. То есть адрес не просто
открыт — он не нужен.
Сегодня безвреден: почтовый сервер не настроен, письмо уходить некуда. Каналом
досаждения он становится вместе с задачей `email-notification`, которая почту как
раз заводит, — и закрыть его дешевле до неё, чем после.
## Воспроизведение
Собрать роутер сервиса и послать `POST /api/collections/users/request-verification`
без сессии, назвав адрес почты вошедшего. Ответ — `204`: сервис принял запрос от
неузнанного и завёл отправку письма. Ожидается отказ, как у прочих адресов
коллекции пользователей.
Письма при этом не уходит, и потому вреда сегодня нет: почтовый сервер не
настроен. Проверка судит **код ответа**, а не доставку.
## Затрагивает
- шаг схемы, закрывающий адреса коллекции пользователей: заводится новый, прежний
не переписывается;
- `docs/security.md`, раздел о поверхности хранилища — перечень закрытых адресов.
## Критерии приёмки
- Запрос подтверждения почты без сессии отвергается. Оракул: проверка по
собранному роутеру — ответ не `204`, письма не заводится.
- Прочие способы открыть сессию остались закрытыми. Оракул: существующие
проверки поверхности хранилища зелёные, состав их не сокращён.
- Перечень закрытых адресов в модели угроз назван заново. Оракул: строка в
`docs/security.md`.
## Рамки
Новый шаг схемы — применённые не переписываются. Найдено прогоном ревью задачи
`remove-telegram-intake` 2026-08-15, враждебный проход, секция свойств без
построенного пути.
@@ -0,0 +1,49 @@
# 🔬 Пределы длительности, приходящей из метаданных
- **Тип:** research
- **Категория:** Очередь — Заведена урожаем ревью и встала в конец машинально: место в плане стройки назначает человек.
- **Зачем:** Длительность приходит от ffprobe числом и не проверяется ничем: приведение к целому переполняется, а испорченная гистограмма чинится только перезапуском.
- **Теги:** review-2026-08-15
Длительность записи сервис берёт у источника метаданных и не проверяет ни на
знак, ни на потолок. Дальше сервис приводит её к целому, умножает на тысячу и
кладёт колонкой записи, а заодно отправляет в гистограмму длительностей.
Число вне диапазона целого при приведении даёт наименьшее возможное значение —
отрицательное. Оно портит сумму семейства метрик необратимо: ряд не чинится
ничем, кроме перезапуска процесса, а страница метрик открыта без проверки
отправителя.
Потолок записи у проекта объявлен — шесть часов, — и из него выведен предел
размера файла. Длительность такого предела не имеет вовсе.
## Вопрос
Может ли `ffprobe` вернуть длительность вне диапазона целого на входе, который
собрал отправитель, — и какой предел ставить на границе адаптера?
Что для ответа нужно узнать:
- какое поле и в какой форме `ffprobe` берёт за длительность у контейнеров,
которые сервис принимает: доверяет ли объявленному в контейнере числу или
считает по потоку;
- есть ли контейнер, где число объявлено явно и потому подделывается: Matroska
либо Ogg с подделанной меткой позиции — первые кандидаты;
- что делает приём, если длительность отрицательна либо больше объявленного
потолка: отвергать запись, обрезать значение или принимать, назвав в журнале.
## Куда ляжет ответ
Наблюдение о поведении `ffprobe` — в `docs/research/`, своей записью с командой и
условиями замера: разведки о `ffmpeg` у проекта пока нет ни одной, а открытые
вопросы про долгие записи и видео стоят в
[architecture.md](../../docs/architecture.md). Выбранный предел — задачей
следующим прогоном.
## Рамки
Прогон на реальных ключах Yandex запрещён и здесь не нужен: разведка меряет
`ffprobe`, а не распознавание. Найдено прогоном ревью задачи
`remove-telegram-intake` 2026-08-15, враждебный проход, секция свойств без
построенного пути — путь не достроен именно потому, что нужен специально
собранный контейнер.
@@ -0,0 +1,66 @@
# 🐞 Не давать отправителю задавать, что уедет в журнал
- **Тип:** fix
- **Категория:** Очередь — Заведена урожаем ревью и встала в конец машинально: место в плане стройки назначает человек.
- **Зачем:** Путь запроса уезжает в журнал целиком: аноним пишет туда до мегабайта своего текста одной строкой, а хвост имени файла приезжает внутри чужого текста ошибки вместо своего поля.
- **Теги:** review-2026-08-15
Длину и содержимое строки журнала задаёт тот, кто прислал запрос, а не сервис.
Мест два, причина одна. Слой журнала пишет путь запроса полем `http.route`
целиком: приведение режет только последний сегмент и только у ссылок на файлы.
Отказ подготовки рабочей копии пересказывает отказ библиотеки целиком, а тот
несёт полное имя временного файла — вместе с расширением, взятым из имени
отправителя.
Журнал в этом проекте — место, где наблюдаются инварианты: по нему видно
недоставку, остановку конвейера и молчаливый выход из шага. Вытесненный чужим
текстом, он перестаёт отвечать на вопрос, ради которого заведён.
Класс проект уже назвал недопустимым в приведении отказа провайдера входа
([conventions/logging.md](../../docs/conventions/logging.md)): «без приведения
аноним пишет в журнал что угодно и сколько угодно». Здесь тот же канал шире и
дешевле — сессия для него не нужна.
## Воспроизведение
**Путь запроса.** Поднять сервис и послать запрос, чей путь длиной 900 000 байт:
```
python3 -c 'socket … GET /<900000×"A"> HTTP/1.1'
```
Ответ — `404`, журнал вырастает с 1048 до 901 194 байт одной строкой. Потолок
замерен: 1 048 000 байт ещё проходят, 1 100 000 отвергаются кодом `431`.
Внедрения строк при этом нет — библиотека журнала квотирует значение.
**Хвост имени.** Принять запись, чьё имя несёт 280 байт после последней точки.
Отказ подготовки рабочей копии приезжает в журнал уровня `ERROR` строкой вида
`error="failed to create work file: open /tmp/transcriber-<число>.<280 байт>:
file name too long"`, и поля `file_ext` в ней нет вовсе.
## Затрагивает
- слой журнала запроса в `main.go` и приведение пути `journalRoute`;
- отказ подготовки рабочей копии в `internal/service`, шаг приёма записи;
- `docs/conventions/logging.md` — правило о длине поля журнала;
- умолчание `MaxHeaderBytes` у сервера: сегодня оно единственное неназванное
рядом с названным сроком чтения.
## Критерии приёмки
- Путь запроса в журнале не длиннее объявленного предела, и усечение видно в
самой строке. Оракул: тот же запрос с путём в 900 000 байт — прирост журнала
не превышает предела, а маршрут по первым байтам по-прежнему различим.
- Отказ подготовки рабочей копии не пересказывает отказ библиотеки: расширение
идёт своим полем. Оракул: проверка приёма с длинным хвостом имени — строки с
этим хвостом в журнале нет, поле `file_ext` есть.
- Правило записано конвенцией: длину поля журнала не задаёт вызывающий. Оракул:
строка в `docs/conventions/logging.md` и ссылка на неё из обеих правок.
## Рамки
Приведение пути не должно делать маршрут неразличимым: по журналу отличают
`/api/audio` от `/api/status/:id`. Найдено прогоном ревью задачи
`remove-telegram-intake` 2026-08-15, враждебный проход; дефект существовал до той
правки, и владелец решил вынести его задачей.
+12 -2
View File
@@ -12,6 +12,14 @@
включая негодный файл. Экран, построенный на таком контракте, показывает «не
найдено» при упавшей базе.
Отдельная ветка того же класса найдена ревью задачи `remove-telegram-intake`
2026-08-15, проход сверки требований: приём отвергает пустого владельца своей
ошибкой, а транспорт переводит **любую** ошибку службы в `500`. Спека приёма при
этом держит эту проверку ради понятного отказа — «отвечает отправителю понятным
отказом до того, как запись попадёт в память». Сегодня путь недостижим: слой
предъявления отвергает такого вызывающего раньше. Он становится достижимым вместе
с личными токенами и экранами приложения, то есть ровно здесь.
## Затрагивает
- `POST /api/audio` и `GET /api/status/:id` — коды ответа и форма ошибки;
@@ -26,8 +34,10 @@
- Код ответа отвечает причине отказа, а не месту, где он случился: сбой базы при
чтении даёт `500`, а не `404`, негодный файл — `400` с человекочитаемым
текстом, а не `500`. Оракул — два теста: репозиторий, возвращающий ошибку
драйвера, и файл, который отвергает разбор метаданных.
текстом, а не `500`, а отказ приёма по пустому владельцу — `403`, как и отказ
слоя предъявления. Оракул — три теста: репозиторий, возвращающий ошибку
драйвера; файл, который отвергает разбор метаданных; вызов приёма без учётной
записи.
- Тело ошибки одной формы на всех эндпоинтах, не содержит сырого `err.Error()` и
опечатки `transcibe`. Оракул — тест на четырёх ветвях отказа (форма совпадает,
текста внутренней ошибки в теле нет) плюс пустой `grep -rn 'transcibe'
@@ -0,0 +1,57 @@
# 🐞 Не затирать сохранённое вложение более бедным ответом
- **Тип:** fix
- **Категория:** Очередь — Заведена урожаем ревью и встала в конец машинально: место в плане стройки назначает человек.
- **Зачем:** Пустой ответ распознавателя сохранённое больше не стирает, а более короткий — стирает: вложение заменяется, и пересчитать архив становится нечем.
- **Теги:** review-2026-08-15
Сохранённый ответ провайдера заменяется только тем, что не беднее прежнего.
Задача `remove-telegram-intake` закрыла половину этого класса: текст и структура
реплик пустым больше не затираются, и норма записана требованием спеки хранилища
«Пустой результат не кладётся поверх сохранённого». Сырой ответ провайдера
защищён только от **пустого** — условием на непустую длину; ответ не пустой, но
короче прежнего, проходит это условие и заменяет сохранённое.
Цена именно у вложения самая высокая. Оно единственное, из чего архив
пересчитывается без повторной оплаты: решение хранить его дословно записано
[ADR-2026-08-14-provider-payload-stored-verbatim](../../docs/adr/ADR-2026-08-14-provider-payload-stored-verbatim.md),
и обещание там прямое — «когда мы научимся размечать говорящих, архив
пересчитается из сохранённого без единого рубля».
## Воспроизведение
Повторный опрос одной операции наступает штатно: держатель захвата умер,
сохранение рубежа отказало, человек снял признак остановки в панели.
Довести запись до сохранённого ответа, вернуть её на рубеж отправленной и
опросить снова, отдав со стороны провайдера ответ короче прежнего. Вложение
заменяется на короткое; прежнего не остаётся нигде. Оракул того же класса для
пустого ответа уже написан — `TestEmptySecondAnswerKeepsArchivedText`, — и этот
пишется по его образцу.
## Затрагивает
- коллекция `recognitions`, колонка `payload` — сохранённый ответ провайдера вложением;
- спека `storage`, требование «Пустой результат не кладётся поверх сохранённого»
— норма расширяется с пустого на «беднее прежнего»;
- определение «беднее»: длина в байтах либо число реплик разбора — выбирается
задачей.
## Критерии приёмки
- Более короткий ответ сохранённое вложение не заменяет. Оракул: проверка по
образцу `TestEmptySecondAnswerKeepsArchivedText` — второй опрос отдаёт ответ
короче, сохранённое вложение остаётся прежним, и проверка убеждается, что до
второго обращения дело дошло.
- Более полный ответ сохранённое заменяет. Оракул: та же проверка наоборот —
второй ответ длиннее, вложение обновилось.
- Норма записана требованием спеки, а не только кодом. Оракул: правленое
требование `storage` и ссылка на него из кода.
## Рамки
Найдено прогоном ревью задачи `remove-telegram-intake` 2026-08-15, враждебный
проход, секция свойств без построенного пути: пустой случай доказан падающим
тестом и закрыт, этот — выведен из того же кода и оракула не имеет. Тексту и
структуре реплик защита уже стоит, трогать их не надо.
+50
View File
@@ -0,0 +1,50 @@
# 🐞 Писать в журнал код ответа отвергнутого запроса
- **Тип:** fix
- **Категория:** Очередь — Заведена урожаем ревью и встала в конец машинально: место в плане стройки назначает человек.
- **Зачем:** У всякого запроса, отвергнутого роутером, в журнале стоит нулевой код: всплеск отказов доступа неотличим от обычного трафика.
- **Теги:** review-2026-08-15
Код ответа в журнале называет исход запроса, а не ноль.
Обработчик, завершившийся отказом роутера — `401` без сессии, `403` без учётной
записи, `404` на чужой записи, `500` на сбое, — пишет статус в ответ **после**
возврата из слоя журнала. Слой читает его раньше и получает ноль. Верное значение
попадает в журнал только у обработчиков, отвечающих самостоятельно.
Разграничение записей по владельцу устроено так, что чужая запись отвечает тем
же, чем несуществующая. Единственное место, где виден перебор идентификаторов, —
журнал; а именно эти строки и приезжают с нулём.
## Воспроизведение
Поднять сервис и послать четыре запроса подряд:
```
/auth/login → 302 ... http.route=/auth/login http.status_code=302
/api/audio → 404 ... http.route=/api/audio http.status_code=0
/nope → 404 ... http.route=/nope http.status_code=0
POST /api/audio без сессии → 401 ... http.status_code=0
```
Первый обработчик отвечает сам и код пишет верно; три остальных отвергнуты
роутером и дают ноль.
## Затрагивает
- слой журнала запроса в `main.go`: место, где снимается код ответа;
- `docs/conventions/logging.md` — словарь полей запроса.
## Критерии приёмки
- Отвергнутый запрос пишет в журнал свой код. Оракул: те же четыре запроса —
`401`, `403`, `404` и `500` приезжают в поле кода, ноля в нём нет ни у одного.
- Обработчик, отвечающий самостоятельно, продолжает писать свой код. Оракул:
запрос входа по-прежнему даёт `302`.
## Рамки
Найдено прогоном ревью задачи `remove-telegram-intake` 2026-08-15, враждебный
проход, оракул — живой прогон. Дефект существовал до той правки: изменение этот
слой не трогало. Метрики кодов ответа у сервиса нет вовсе, и заводить её этой задачей не
надо — она про журнал.
+8
View File
@@ -10,6 +10,14 @@
Берётся после `record-ownership`: связывать не с чем, пока у записи нет
владельца.
**Предмета у задачи сейчас нет.** Вход Telegram убран задачей
`remove-telegram-intake` 2026-08-15 — временно, решение записано
[ADR-2026-08-15-telegram-intake-removed-temporarily](../../docs/adr/ADR-2026-08-15-telegram-intake-removed-temporarily.md).
Эта задача остаётся **условием возврата**: без связи чата с учётной записью вход
вернуть нельзя — записи снова пошли бы без владельца, а колонка владельца пустого
значения больше не принимает.
## Затрагивает
- связь учётной записи с чатом Telegram: таблица и её миграция;
@@ -0,0 +1,56 @@
# 🔬 Судьба рубежа расшифровки, у которого не осталось работы
- **Тип:** research
- **Категория:** Очередь — Заведена урожаем ревью и встала в конец машинально: место в плане стройки назначает человек.
- **Зачем:** Шаг завершения после убранной доставки только двигает колонку, а рубеж при этом подпадает под сторож застревания: готовая расшифровка платит отдельный захват и час сторожа.
- **Теги:** review-2026-08-15
Шаг завершения заводился ради ответа отправителю: он читал текст расшифровки и
отправлял его в чат, а перевод записи на конечный рубеж был хвостом этой работы.
Доставка убрана задачей `remove-telegram-intake`, и от шага осталось присваивание
колонки с сохранением.
Цена этого остатка не нулевая. Между «расшифровка сохранена» и конечным рубежом
лежит полный круг воркера: захват с ростом числа отказов, чтение, сохранение,
строка журнала событий, счётчик. Рубеж при этом числится своей работой и потому
подпадает под предел простоя — час. Запись с готовым текстом, простоявшая этот час
без свободного воркера (очередь, ноль воркеров настройкой, окно выкладки),
останавливается признаком с причиной «застряла», и отправитель видит остановку у
записи, чья расшифровка лежит в хранилище.
Вопрос не в том, дефект это или нет: сегодня и то и другое поведение законно.
Вопрос в том, чем рубеж остаётся дальше.
## Вопрос
Остаётся ли рубеж расшифровки отдельным шагом конвейера — и если да, какую работу
он держит?
Две формы ответа видны сразу, и у каждой своя цена:
- **шаг опроса двигает запись сразу на конечный рубеж**, а рубеж расшифровки
уходит к отжившим состояниям. Круг воркера и час сторожа исчезают; цена —
правка спеки конвейера (цепочка рубежей, требование «Рубеж записи называет
достигнутое») и значение, остающееся в перечне схемы;
- **рубеж остаётся объявленным местом** под задачу выводов из текста — заголовок,
пересказ и темы считаются шагом, который встанет ровно сюда. Тогда это надо
сказать словами в конвейере и в спеке, иначе следующий читатель прочтёт шаг как
забытый код. Цена — час сторожа над готовой записью, и его тоже надо назвать.
Ответ зависит от того, чем станет `llm-insights-adapter`: отдельным шагом
конвейера или продолжением распознавания. Открытым вопросом это стоит в
[architecture.md](../../docs/architecture.md).
## Куда ляжет ответ
Решение — в `docs/adr/`, продвижением записки; сама записка не нужна, разведка
здесь выбирает форму, а не измеряет внешний мир. Изменение спеки конвейера, если
выбран первый ответ, заводит следующий прогон.
## Рамки
Перечень рубежей закрыт схемой, и удаление значения из него потребовало бы шага
схемы: разведка это только называет ценой, а решение принимает человек. Найдено
прогоном ревью задачи `remove-telegram-intake` 2026-08-15, архитектурный проход;
оракула у находки нет — случай выведен из кода и предела простоя, но не
воспроизведён.