имя файла отправителя убрано из журнала приёма

- расширение приводится к перечню известных форматов прежде метки метрики:
  страница метрик открыта, и хвост имени уезжал на неё дословно
- проверки приёма перехватывают все три потока журнала и читают реестр метрик,
  каждая падает при снятии того, что сторожит
This commit is contained in:
av
2026-08-11 16:37:58 +03:00
parent ffa36e96c7
commit bd6001cd8d
18 changed files with 1797 additions and 20 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-11
@@ -0,0 +1,146 @@
## Context
Общий шаг заведения задачи пишет журнальную строку о принятой записи и кладёт в
неё три поля: идентификатор файла, имя, данное отправителем, и путь, по которому
запись легла на диск. Уровень строки — `INFO`, то есть в боевой настройке она
пишется всегда. Через этот шаг проходит и запись из Telegram, и запись по HTTP,
поэтому строка общая для обоих входов.
**Имя, данное отправителем, доходит до этой строки только по HTTP.** Приём по
HTTP отдаёт в сервис имя из формы запроса; приём из Telegram отдаёт путь,
выданный самим Telegram (`voice/file_5.oga`), а настоящее имя документа дальше
проверки типа файла не идёт. Утечка сегодня одна, и она на входе по HTTP; правка
всё равно делается на общем шаге, чтобы второй вход не мог её обойти.
`docs/conventions/logging.md` запрещает имена файлов пользователя прямо, и это же
объявлено критическим инвариантом. Ни линтером, ни проверкой запрет сегодня не
выражен: `sloglint` в наборе не включён, а проверки приёма журнал выбрасывают.
## Goals / Non-Goals
**Goals:**
- Убрать имя отправителя из журнала приёма — на общем шаге, то есть для обоих
входов разом.
- Оставить прослеживаемость: по журналу по-прежнему видно, какой файл заведён,
с каким расширением и какого размера запись.
- Сделать запрет проверяемым на обоих путях приёма — успешном и отказном, — и
проверяемым **по значению**, а не по имени журнального поля.
**Non-Goals:**
- Сверка остальных журнальных строк проекта с запретами. Прочие строки здесь не
пересматриваются — это отдельная работа.
- Нормализация расширения **в имени файла на диске**. Раскладка каталога записей
объявлена необратимой, и меняется она решением человека. Расширение там
остаётся пришедшим, а остаток записан строкой в `docs/security.md`. Метки
метрик под этот отказ не подпадают — про них решение ниже.
- Выражение запрета линтером (`sloglint`, `forbidigo`). Набор линтеров задача не
меняет.
- Перевод `log.Printf` в HTTP-транспорте на общий логгер. Расхождение записано в
конвенции журналирования и живёт своей жизнью; проверка его поток **видит**,
но чинить его здесь не будем.
## Decisions
**Поле с именем убирается, а не заменяется производной от имени.**
Рассматривались два способа сохранить корреляцию по имени: хеш имени и его
длина. Хеш — та же приватная величина в другой записи: по нему имя восстанавливают
перебором, а при повторной отправке одной записи он ещё и связывает отправки
между собой. Длина имени не отвечает ни на один вопрос разбора. Отказано обоим:
корреляцию в проекте держат идентификаторы сущностей, а не имена, и это уже
записано конвенцией журналирования.
**Расширение остаётся тем же полем, что и сейчас, — путём файла в хранилище.**
Путь несёт собственное имя файла: идентификатор плюс расширение. Отдельное поле
под расширение завело бы второй дом одному факту, и два поля начали бы
расходиться на первой же правке выбора расширения. Отвергнуто.
**Размер принятой записи в журнале уже есть** — соседняя строка об успешно
загруженной записи несёт идентификатор файла и размер в байтах. Заводить её
заново не требуется; требование о прослеживаемости она закрывает как есть.
Величина названа байтами во всех трёх местах — в требовании, здесь и в критериях
приёмки: рядом в той же строке лежит длительность, и «длина» читалась бы как она.
**Оракул — перехваченный журнал в проверке приёма по HTTP, и он видит все три
пишущих потока.** Окружение проверки сегодня отдаёт журнал в никуда, чтобы
строки отказа не путались с признаком красного гейта. Вместо «в никуда» журнал
уходит в буфер, и в тот же буфер сводятся: структурный логгер сервиса,
стандартный `log`, через который HTTP-транспорт пишет мимо `slog`, и middleware
запроса — ради него тестовый роутер собирается той же цепочкой, что и боевой.
Иначе квантор требования («ни одна журнальная запись приёма») был бы шире
оракула, и утечка через непокрытый поток оставила бы проверку зелёной.
**Перехват стандартного `log` удерживается положительным утверждением.** Проверка
отказного пути сперва требует, чтобы строка транспорта в буфере **была**, и лишь
затем — чтобы имени в нём не было. Без этого снятие перехвата не уронило бы
ничего, а оракул сузился бы вдвое молча. Перехват при этом процессный, поэтому
`t.Parallel()` в этом файле запрещён — сказано строкой рядом с кодом.
**Буфер свой на каждый случай, и утверждение отбирается по идентификатору этого
прогона.** Общий на пакет буфер сделал бы исход проверки функцией от соседних
случаев: «поля на месте» прошло бы на чужой строке, а «маркера нет» ложно
покраснело бы от чужой. Плюс параллельные случаи писали бы в один
`bytes.Buffer` — гонка на проверке критического инварианта, то есть флаки, а
флаки на таком месте снимают целиком.
**Проверка судит по значению маркера, а не по имени поля.** Маркер — уникальная
ASCII-строка, которой нет в остальном выводе. Поиск по имени поля (`file_name`)
не поймал бы возвращённое имя под другим ключом, а прецедент такого класса в
проекте уже записан: проверка приёма от 2026-08-11 была зелёной и не ловила
ничего. Отсюда же обязательный шаг приёмки — **мутация**: вернуть имя в журнал
под другим ключом, убедиться, что проверка краснеет.
**Отказной путь проверяется наравне с успешным.** Приём назван конвенцией
журналирования логирующей границей: ошибка попадает в журнал полем `error`
вместе со всей цепочкой `%w`. Обёртка вида `fmt.Errorf("сохранение %s: %w", …)`
где угодно ниже отдаст имя именно там, и проверять только успех значит не
проверять этот путь вовсе.
**Косвенные носители имени названы поимённо.** Текст ошибки — закрыт вторым
сценарием. Путь на диске строится из идентификатора и расширения; ключ объекта в
Object Storage берётся из имени **после конвертации**, то есть всегда
`идентификатор.ogg`, — наружу к внешнему сервису хвост не уезжает. Колонка
`error_text` журналом не является и этой задачей не трогается.
**Третий носитель хвоста — метка метрики, и он закрыт приведением.** Это решение
контрольной точки, принятое **после ревью кода**: проход построил путь целиком —
запись с именем `запись.тайное-слово` кладёт хвост меткой, а страница метрик
отдаётся без проверки отправителя, то есть читает её кто угодно, и то же
значение оседает в хранилище метрик. Канал оказался шире того, который задача
закрывала.
Из трёх способов человек выбрал средний. Отвергнуты: оставить как есть и завести
задачу — канал жил бы до неё, а закрытие этой задачи читалось бы как «починено»;
приводить расширение везде, включая имя файла на диске, — раскладка каталога
записей объявлена необратимой и меняется решением человека, а не по ходу
починки журнала. Принято: приводить **только значение метки** — расширение из
закрытого перечня идёт приведённым к нижнему регистру, всё прочее становится
одним общим значением. Имя на диске не трогается вовсе. Тем же ограничением
снимается рост числа временных рядов, которым иначе распоряжается анонимный
отправитель.
**Приведение проверяется по реестру метрик, а не по самой функции.** Проверка
функции в отрыве от точек употребления зелёная и при снятом приведении: правка
места вызова вернула бы хвост наружу молча. Поэтому проверка гонит приём с
незнакомым расширением и читает реестр: хвоста в метках нет, общее значение
есть, а на диске расширение осталось пришедшим.
## Risks / Trade-offs
**Расширение приходит из имени отправителя, и в журнал оно попадает как есть**
имя вида `запись.тайное-слово` отдаёт `тайное-слово` расширением, и оно уедет в
журнал вместе с путём. Смягчение: остаток записывается строкой в
`docs/security.md`, чтобы закрытие задачи не читалось как «канал закрыт
целиком». Нормализация расширения остаётся отдельной работой со своим
требованием.
**Проверкой накрыт только вход по HTTP** → приём из Telegram делит с ним общий
шаг, и правка достаётся ему той же строкой кода, но своей проверки у него нет.
Смягчение: правка делается на общем шаге, а не в транспорте, — обойти её со
стороны Telegram нечем. Имени, данного человеком, оттуда сегодня и не приходит.
**Запрет держится проверкой, а не линтером** → следующая журнальная строка с
именем отправителя в другом месте кода проверкой не поймается. Смягчение в
границах задачи: проверка стоит ровно на том шаге, через который проходит всякая
принятая запись. Правило линтера — кандидат в отдельную задачу.
@@ -0,0 +1,46 @@
## Why
Приём кладёт в журнал имя файла, которое дал отправитель, на каждой принятой
записи. Имя записи из семейного архива — такое же содержимое личной переписки,
как и сам текст расшифровки; инвариант приватности объявляет такую запись
критической и необратимой, потому что строка уже уехала в журнал контейнера.
Доходит это имя до сервиса сегодня только по HTTP: из Telegram приходит путь,
выданный самим Telegram, а не имя человека. Строка журнала при этом общая для
обоих входов, и запрет пишется на неё, а не на транспорт.
## What Changes
- Журнал приёма перестаёт нести имя, данное отправителем. Правка ложится на
общий шаг заведения задачи, через который идут оба входа.
- Прослеживаемость приёма сохраняется: идентификатор записи, её расширение и
размер в байтах в журнале остаются, по ним путь записи собирается отбором.
- Запрет становится проверяемым, и проверяется он на успехе и на отказе: приём
прогоняется с записью, чьё имя содержит опознаваемую строку, и журнал этой
строки не содержит.
- **Метка метрики перестаёт нести кусок имени.** Расширение — хвост после
последней точки — берётся из имени отправителя и уезжало меткой на страницу
метрик, которая отдаётся кому угодно. Теперь оно приводится к перечню
известных форматов, всё прочее становится одним общим значением. Решение
принято контрольной точкой уже после ревью кода, которое построило этот путь.
## Capabilities
### New Capabilities
Новых нет.
### Modified Capabilities
- `intake`: приём получает требование о том, чего его журнал нести не вправе, и
о том, что в нём остаётся ради прослеживаемости.
## Impact
- `internal/service/transcribe.go`, общий шаг заведения задачи — журнальные
строки приёма и обе метки, несущие расширение;
- `internal/metrics` — приведение расширения к перечню известных форматов;
- проверка приёма: новый прогон с опознаваемым именем и разбором перехваченного
журнала;
- `docs/conventions/logging.md` уже запрещает имена файлов пользователя — правки
не требует, разве что строкой о том, чем имя заменено.
@@ -0,0 +1,802 @@
# Триаж ревью кода: `no-user-filename-in-log`
Документ ведётся по кругам. Наверху — последний круг, ниже приложением — отчёт
первого круга целиком, как он был написан. История не переписывается: исход
каждой находки первого круга проставлен отдельным разделом, а не правкой её
текста.
---
# Круг 2 (после контрольной точки о метке метрики)
## Сводка
- **Change:** `no-user-filename-in-log`. База диффа `origin/master`, судится
рабочее дерево (`git diff`) плюс три файла не под git:
`internal/metrics/format_label.go`, `internal/metrics/format_label_test.go`,
`internal/service/format_label_test.go`. Итого 5 изменённых файлов
(+258/17) и 3 новых.
- **Размер:** среднее. **Сложность:** знакомое. **Метка: medium** — максимум по
осям, не менялась между кругами. Второй круг добавил файл в
`internal/metrics`, но проектный триггер «новая метрика в `internal/metrics`»
(`docs/review.md`, «Триггеры метки») опускает до `small` именно новую
**метрику**; здесь новой метрики нет, добавлено приведение значения метки.
Метка остаётся `medium`.
- **Режим прогона:** по графу. Второй круг: `specs`, `code`, `basics`, `triage`.
- **Гейт:** красный **только объявленным долгом**. Проверено мной самим:
`go build ./...`, `go vet ./...`, `gofmt -l .`, `go test ./...` зелёные;
`golangci-lint run` даёт ровно 4 знакомых замечания (`speechkit.go:55`,
`main.go:124`, `worker.go:51`, `transcribe.go:395`);
`openspec validate no-user-filename-in-log --strict``is valid`.
`go test -race -count=5 ./internal/controller/http/``ok, 1.663s`.
Новых красных шагов нет.
- **Находок на входе второго круга:** 15 (specs 6, basics 3, code 6) + 1
кандидат в правила. За два круга — 32.
**После дедупликации по причине и отсева:** 3 в первых двух секциях
(1 блокирующая, 2 к исправлению), 3 гипотезы, 5 кандидатов в правила.
### Находка о прогоне: `autotests` на втором круге не запускался
Второй круг прогнал `specs`, `code` и `basics`. Проход `autotests`**нет**, и
это его тема: круг добавил 130 строк в `internal/controller/http/transcribe_test.go`
и два новых тестовых файла целиком. Дом темы (`CLAUDE.md`, «Гейт» и «Команды»)
второй круг не открывал никто.
Это не абстрактный пробел: **блокирующая находка №1 ниже — ровно тот класс,
который ищет `autotests`**, и нашёл её я мутацией, а не проход. Судить, сколько
ещё такого осталось в добавленных проверках, нечем: я не читаю код в поисках
дефектов, я проверяю чужие выводы, а выводов по теме `autotests` за второй круг
не поступало.
### Сигнал о заниженной метке
- **Круг 1:** `review-code` пришёл и метку заниженной **не** считает. От
`review-basics` сигнала не было — ни «верна», ни «занижена».
- **Круг 2:** сигнала не пришло **ни от одного** прохода — ни от `review-code`,
ни от `review-basics`. Отличить «возражений нет» от «не сказано» по молчанию
нельзя, и я этого не делаю.
### План разметки против исхода — оба круга
План не менялся. Своих тем проекта сверх ядра нет, тем без дома нет.
| тема | дом | глубина | закрывает | круг 1 | круг 2 |
| --- | --- | --- | --- | --- | --- |
| requirements | `openspec/specs/intake/spec.md` + дельта change | разбор | specs | **закрыта**, 4 находки | **закрыта**, 6 находок |
| autotests | `CLAUDE.md`, «Гейт» и «Команды» | — | autotests | **закрыта**, 0 находок + 1 promote | **отчёта не пришло — проход не запускался** |
| conventions | `docs/conventions/logging.md` + весь каталог | разбор | code | **закрыта**, 6 находок | **закрыта**, 6 находок |
| architecture | `docs/architecture.md`, «Единые точки проекта» + `docs/passport.md` | разбор | basics | **закрыта**, ответ по вопросу темы + 1 находка | **закрыта**, 1 находка (дом правила) |
| security | `docs/security.md`, «Куда уходит содержимое записи», «Из чего строятся пути и ключи» | разбор | basics | **закрыта**, 1 находка (главная) | **закрыта**, подтверждение: канал наружу перебран поимённо |
| operations | `docs/architecture.md`, «Эксплуатация» + `docs/database.md` | разбор | basics | **закрыта**, ответы по вопросам темы | **закрыта**, 2 находки (умолчание, видимость настоящего формата) |
---
## Блокирует мердж
### 1. Второй употребитель приведения метки оракулом не закрыт: снятие `FormatLabel` на шаге конвертации не роняет ничего
- **Где:** `internal/service/transcribe.go:212`
`WithLabelValues(metrics.FormatLabel(srcExt), "ogg", strconv.FormatBool(err != nil))`.
Критерий `tasks.md` 2а.2 («Обе метки, несущие расширение, идут через
приведение») отмечен выполненным.
- **Severity:** major. Не критично тем, что код **сегодня верен**: приведение на
месте, хвост наружу не идёт. Критично то, что удержано оно ничем, а чек-лист
утверждает обратное.
- **Оракул (мутация на копии дерева, прогнана на этом прогоне):**
```
# копия дерева, sed по строке 212: FormatLabel(srcExt) -> srcExt
go test ./internal/...
MUT[M2 приведение снято у метки конвертации]: ЗЕЛЁНАЯ (мутация не поймана)
```
Для сравнения — тот же приём на первом употребителе краснеет:
```
MUT[M1 приведение снято у метки размера приёма]: КРАСНЕЕТ
--- FAIL: TestCreateTranscribeJob_MetricLabelCarriesNoSenderName
```
- **Что именно уходит этой меткой:** `srcExt` строится из `srcFile.FileName`
(`transcribe.go:195`), а это имя файла в хранилище вида
`<uuid>.<хвост имени отправителя>`. То есть в метку `source_format` метрики
`transcriber_conversion_duration_seconds` попадает тот же приватный хвост, что
и в метку приёма, и уезжает он на тот же анонимный `GET /metrics`
(`main.go:216`). Канал по свойствам идентичен закрытому.
- **Почему это блокирует, а не «стоит исправить»:** `design.md` этого change сам
объявил такую проверку недостаточной — дословно: «Проверка функции в отрыве от
точек употребления зелёная и при снятом приведении: правка места вызова
вернула бы хвост наружу молча». Решение принято, реализовано для одной точки
из двух, а чек-лист отмечает обе. Это записанное свойство проекта —
`docs/review.md`, «Типовые узлы / Любой узел»: «проверка **способна упасть**…
Признак ищется мутацией», и журнальная запись 2026-08-11 об этом же классе.
Ранжирую первым по правилу молчания: следующий, кто уберёт `FormatLabel` со
строки 212 как лишний вызов, сузит оракул критического инварианта, и никто не
узнает.
- **Чего эта находка НЕ утверждает:** «`/metrics` открыт без аутентификации» —
типовое ложноположительное проекта (`docs/review.md`, «Типовые
ложноположительные», пункт про HTTP API). Новое здесь не открытость, а
неудержанность приведения на второй точке.
- **Действие: развилка.**
> Приведение расширения к перечню стоит в двух местах, а оракул — только на
> одном. Мутация на втором (`transcribe.go:212`, метка `source_format` шага
> конвертации) зелёная: снятие приведения не роняет ни одной проверки.
> Своего тестового окружения у `internal/service` нет вовсе — есть только
> `format_label_test.go` на 19 строк. Что делаем до мерджа?
>
> **(а)** Мержим как есть: пробел записываем строкой в `design.md` разделом
> Risks и вешаем на существующую задачу `tasks/items/pipeline-step-tests.md`
> — она заведена ровно про непокрытые `FindAndRunConversionJob` и соседей.
> Чек-лист `tasks.md` 2а.2 при этом надо переформулировать: «приведение стоит
> на обеих точках, оракул — на одной».
> **(б)** Строим окружение проверки для `FindAndRunConversionJob`: репозитории
> SQLite, каталог хранения, подставной конвертер. Цена сопоставима с самой
> задачей и вылезает за её scope.
> **(в)** Убираем возможность обойти приведение по построению: прячем
> `InputFileSizeHistogram` и `ConversionDurationHistogram` за функциями
> `internal/metrics`, которые сами зовут `FormatLabel`. Тогда сырое расширение
> передать меткой нечем, и обе точки закрывает уже существующая проверка
> реестра. Меняется экспортируемая поверхность пакета `internal/metrics` —
> пакет внутренний, публичного контракта это не трогает, но это правка сверх
> заказанного объёма.
---
## Стоит исправить сейчас
### 2. Комментарий оракула ссылается на механизм, которого в коде нет
- **Где:** `internal/controller/http/transcribe_test.go:492` — «снятие
`log.SetOutput` из окружения оставило бы проверку зелёной». Тот же текст в
`tasks.md`, пункт 2.1: «в него же перенаправляется вывод стандартного `log`».
- **Оракул:** `grep -n "log.SetOutput" internal/controller/http/transcribe_test.go`
отдаёт единственное совпадение — саму эту строку комментария. В окружении
стоит `slog.SetDefault(logger)` (`:167`), как в `main.go:50`. Механизм заменён
в этом же круге по находке прохода `code` — комментарий и чек-лист за правкой
не поехали.
- **Почему сейчас:** причина ровно та же, что у находки №3 первого круга
(`src_ext`, метки не существует), и лечится так же — одним словом. Проза,
называющая несуществующий механизм, сбивает следующего: он не найдёт
`log.SetOutput`, решит, что утверждение мёртвое, и снимет его. Утверждение
живое — мутация подтверждает:
```
MUT[M4 slog.SetDefault снят]: КРАСНЕЕТ
--- FAIL: TestCreateTranscribeJob_SenderFileNameNotLoggedOnFailure
```
- **Действие: инлайн.** В обоих местах заменить `log.SetOutput` на
`slog.SetDefault` и уточнить формулировку: вывод стандартного `log` уходит в
буфер не перенаправлением, а тем, что `slog.SetDefault` сажает его в `msg`
записи `slog`.
### 3. Значение `other` нормировано спекой дословно, но ни одна проверка к литералу не привязана
- **Где:** `internal/metrics/format_label.go:6` — `const OtherFormatLabel = "other"`;
дельта-спека, требование «Метка метрики несёт только известное расширение»:
«всякое другое MUST заменяться единым значением `other`».
- **Оракул (мутация, прогнана):**
```
# const OtherFormatLabel = "other" -> const OtherFormatLabel = ""
MUT[M11 общее значение пустое]: ЗЕЛЁНАЯ (мутация не поймана)
```
Обе проверки — и `TestFormatLabel`, и
`TestCreateTranscribeJob_MetricLabelCarriesNoSenderName` — сверяются с самой
константой, то есть утверждают тавтологию. Значение метки — величина, которую
видит человек в панели снаружи; спека называет её дословно, а код может
сменить её молча, включая на пустую строку, при которой метка из выборок
Prometheus фактически исчезает.
- **Почему сейчас, а не в гипотезы:** это не догадка, а прогнанная мутация, и
закрывается одной строкой в `internal/metrics/format_label_test.go`:
`if OtherFormatLabel != "other" { t.Fatal(...) }` — либо заменой `want:
OtherFormatLabel` на `want: "other"` в трёх случаях таблицы.
- **Действие: инлайн.**
---
## Гипотезы без доказательства
- **Запрет `t.Parallel()` держится комментарием** (перенесено с первого круга и
остаётся верным; механизм только сменился). Теперь процессная подмена — это
`slog.SetDefault`, и ничто, кроме текста рядом, не мешает следующей проверке в
этом файле стать параллельной; тогда перехват уедет к соседу. Оракула нет:
воспроизвести можно только внесением `t.Parallel()`, то есть той самой будущей
правкой. `Confidence: medium`, severity не выше `minor`.
- **Позитивное утверждение `require.Contains(journal, msgTransportErr)`
привязано к расхождению, которое конвенция объявляет подлежащим устранению**
(`log.Printf` в HTTP-транспорте мимо `slog`). Когда расхождение починят,
проверка отказа покраснеет по причине, не связанной с приватностью. Вынос
текста в константу с комментарием про долг (сделан вторым кругом) снижает цену
правки, но событие в будущем оракулом не закрывает. `minor`.
- **Приём из Telegram своего оракула не получил ни на одном круге.** Правка
достаётся ему общим шагом `createTranscribeJob`, и обойти её со стороны
Telegram нечем — но это рассуждение по коду, а не прогон. Объявлено риском в
`design.md`. `minor`.
---
## Promote candidates
- **Правило линтера на ключи `file_name`/`filename` в вызовах логгера**
(`sloglint` либо `forbidigo`). Пришло от `autotests` на первом круге;
`design.md` объявил это non-goal задачи.
- **Свойство в `docs/review.md`, «Типовые узлы / Любой узел»:** оракул ставится
**на каждой точке употребления**, а не на самой функции и не на одной из
точек. Обобщение трёх находок подряд — неудержанного middleware (круг 1, №2),
неудержанного `log`-потока (круг 1, починено) и неудержанной точки конвертации
(круг 2, №1). Три повторения одного класса за один change — заявка на
записанное правило, а не на разовую починку.
- **Конвенции о метриках в проекте нет вовсе.** `docs/conventions/` — пять
файлов: `config`, `database`, `errors`, `logging`, `web-ui`. Что можно класть в
метку, кто отвечает за кардинальность, как объявлять смену формы значения —
дома нет. Второй круг завёл строку в `docs/architecture.md`, «Единые точки
проекта», но это указатель на реализацию, а не правило. Пришло от `code`.
- **Нормализация расширения, взятого из имени отправителя, в журнале и в имени
файла на диске** — отдельной задачей, с явным решением человека про раскладку
`data/files` (`CLAUDE.md` называет её необратимой). Остаток записан прозой в
`docs/security.md`; задачи на него нет.
- **Смена формы значения метки (`.mp3` → `mp3`) записана в `docs/security.md`.**
Дом спорный: это факт эксплуатации, а не модели угроз, и человек, у которого
опустела панель, полезет в `docs/architecture.md`, «Эксплуатация». Не находка
— правило о том, где объявляются несовместимые смены формы метрик, входит в
предыдущий пункт.
---
## Что из починенного починено верно и не ослаблено
Проверено мутациями на копии дерева (`git ls-files` + три файла не под git), не
со слов. Базовый прогон копии зелёный, откат подтверждён.
| мутация | исход | что этим удержано |
| --- | --- | --- |
| приведение снято у метки размера приёма | **КРАСНЕЕТ** (`…MetricLabelCarriesNoSenderName`) | круг 2, specs #1 — для точки приёма |
| приведение снято у метки конвертации | **ЗЕЛЁНАЯ** | **не удержано — находка №1** |
| имя отправителя вернулось под ключом `src` | **КРАСНЕЕТ** (обе проверки запрета) | круг 1: поиск идёт по значению, не по ключу |
| `slog.SetDefault` снят из окружения | **КРАСНЕЕТ** (`…NotLoggedOnFailure`) | круг 2, code #4 — боевая цепочка журнала |
| middleware не подключён к тестовому роутеру | **КРАСНЕЕТ** (`…SenderFileNameNotLogged`) | круг 1, находка №2 |
| строка приёма понижена до `DEBUG` | **КРАСНЕЕТ** (`…JournalTracesRecord`) | круг 1: адресат записи |
| умолчание сервиса выведено из перечня | **КРАСНЕЕТ** (`TestDefaultAudioExtIsKnownToMetrics` + `…DifferentFileExtensions`) | круг 2, basics #1 — дубль умолчания |
| вторая строка приёма в журнале | **КРАСНЕЕТ** (`…JournalTracesRecord`) | круг 1, находка №4 — «ровно одна запись» |
| приведение регистра снято | **КРАСНЕЕТ** (`TestFormatLabel`) | круг 2, specs #5 |
| `oga`, `opus`, `mp4` выведены из перечня | **КРАСНЕЕТ** (`TestFormatLabel`) | круг 2, code #1 — голосовые Telegram |
| `OtherFormatLabel` стал пустым | **ЗЕЛЁНАЯ** | **не удержано — находка №3** |
Дополнительно проверено поимённо, а не со слов:
- **Круг 1, находка №3 закрыта.** `docs/security.md` называет метки
`file_extension` у `transcriber_input_file_size_bytes` и `source_format` у
`transcriber_conversion_duration_seconds`. Сверено с
`internal/metrics/metrics.go:24` и `:44` — совпадает.
`grep -rn "src_ext"` по коду и документам совпадений не даёт (остались только
упоминания в архиве этого же отчёта).
- **Круг 2, specs #4 закрыта.** `design.md` держит решение о метке в Decisions,
с записью развилки и двух отвергнутых вариантов (оставить как есть; приводить
везде, включая диск). `proposal.md` согласован.
- **Круг 2, specs #3 и basics #2 закрыты.** Перечень назван поимённо в
требовании спеки (14 форматов + `audio`), дом правила — строка в
`docs/architecture.md`, «Единые точки проекта».
- **Ослаблений не найдено.** Ни одна проверка первого круга не стала слабее,
`go test ./...` зелёный, `go test -race -count=5` зелёный, новых замечаний
линтера нет, `openspec validate --strict` — valid.
---
## Что осталось непочиненным и почему
Ничего не выброшено молча. Полный список:
1. **Точка конвертации не удержана оракулом** — находка №1, развилка, решение за
человеком. Единственное, что блокирует мердж.
2. **Комментарий про `log.SetOutput`** — находка №2, инлайн, одно слово в двух
местах.
3. **Литерал `other` не привязан к спеке** — находка №3, инлайн, одна строка.
4. **Остаток «хвост расширения остаётся в журнале» задачей не заведён.** Записан
прозой в `docs/security.md` и в Risks `design.md`. Не починено **намеренно**:
заведение задач принадлежит скиллу задач, а не конвейеру ревью. Идёт урожаем
(Promote candidates, пункт 4).
5. **Конвенции о метриках нет** — урожай, Promote candidates, пункт 3. Не
находка об этом коде.
6. **Расширение в имени файла на диске не нормализуется** — решение принято
человеком на контрольной точке и записано отвергнутым вариантом в
`design.md`. Раскладка `data/files` объявлена необратимой в `CLAUDE.md`.
Закрыто как решение, не как пробел.
7. **Сценарий «Известное расширение идёт как есть» назван неточно** — тело
сценария (`sample.MP3` → `mp3`) описывает приведение регистра, заголовок
говорит «как есть». Выброшено по отсеву вкусовщины: поведения не меняет, на
стоимость следующей правки не влияет, записанной конвенции не нарушает —
текст требования выше разночтение снимает дословно.
Потолок первых двух секций **не срабатывал**: 1 из 3 и 2 из 4. За срезом не
осталось ничего.
---
## Границы покрытия
### План
Шесть тем, у каждой есть дом и глубина; все шесть перечислены в таблице выше с
исходом по каждому кругу. Тем без дома нет. Своих тем проекта сверх ядра нет.
**Тема без отчёта одна: `autotests` на втором круге** — проход не запускался,
дом темы (`CLAUDE.md`, «Гейт» и «Команды») второй круг не открывал никто, а
добавленный кругом код — это на 100% тестовый код.
### Проходы
- **Круг 1**, метка `medium`, режим «по графу»: `review-autotests`,
`review-specs`, `review-code`, `review-basics` (темы `security`, `operations`,
`architecture`, глубина разбор), `review-triage`.
- **Круг 2**, та же метка и режим: `review-specs`, `review-code`,
`review-basics`, `review-triage`.
- **Не запускались:** `review-autotests` на втором круге — причина мне не
сообщена (в задании сказано «`specs`, `code`, `basics` прогнаны заново», без
обоснования пропуска). Проходы ревью дизайна (`specs`, `rubric` на предложении)
отработали до кода и в этот прогон не входят.
### Чего в конвейере нет вовсе
Четыре строки, которые не принесёт ни один проход:
1. **Решения проекта не сверялись.** `docs/adr/` — процессный документ, прогон
его не открывает. Расхождение изменения с записанным решением ловит сверка
документации (`av-dev-docs:healthcheck`), а не ревью. В каталоге лежат четыре
ADR, включая `ADR-2026-08-11-stub-adapters-in-tests.md`, — ни один из них
этим прогоном не читался как источник требований.
2. **Записанные наблюдения проекта не использовались.** `docs/research/` — тоже
процессный. Все числа в этом отчёте сняты на этом прогоне, команды приложены.
3. **Поимённая сверка с руководством по стилю Go не задавалась ни одним
проходом.** Различение «идиоматично против распространено» — например, `map[string]struct{}`
против `slices.Contains` в `format_label.go`, или уместность тавтологичного
сравнения с константой — не спрашивал никто.
4. **Альтернативной реализации, с которой можно сдиффить решения, у конвейера
нет.** Проход независимой реализации снят по стоимости, а не по замеру; «не
знаю, чего не знаю» никто не достаёт.
### Чего запущенные проходы не могли проверить в принципе
- Ни один проход не гонял сервис на настоящих данных: `testdata` в проекте нет
по запрету `CLAUDE.md`, реальные ключи Yandex Cloud под запретом, боевую БД и
`data/files` трогать нельзя. Все оракулы — синтетический вход и мутации.
Для находки про внешний формат это существенно: перечень из 14 расширений
собран рассуждением о том, что выдаёт Telegram и что берёт `ffmpeg`, и **ни на
одном настоящем файле не проверен**.
- `specs` судит код против заказанного поведения и не ищет дефектов вне него;
`code` судит технику и конвенции и не судит требования; `basics` идёт по трём
темам и только по их записанным домам; `autotests` судит прогон и мутации, а
не смысл проверок, — и второй круг не судил вовсе.
- Приём из Telegram своей проверки не получил ни на одном круге (объявлено
риском в `design.md`).
- Шаг конвертации (`FindAndRunConversionJob`) не покрыт ни одним тестом вообще —
это записано отдельной задачей `tasks/items/pipeline-step-tests.md`, а не
находка этого прогона.
- Триаж не читает код в поисках дефектов: пропуск любого прохода — мой пропуск
тоже. Пропуск `autotests` на втором круге назван поимённо выше.
### Что осталось целиком на человеке
Из `docs/review.md`, «Недоступно проверке», двумя отдельными списками — они не
сливаются.
**Не проверит ни один проход:**
- `operations`: поведение внешних сервисов под нагрузкой и на границах —
SpeechKit и Object Storage поднять в тесте нечем;
- `operations`: реальный профиль нагрузки. Проект живёт на единицах записей в
день, и утверждения о росте (в том числе о кардинальности метки) остаются
условиями, а не замерами;
- `security`: стойкость `ffmpeg` к вредоносному входу — разбор чужого формата
отдан внешней программе, и она вне нашей границы.
**Перестали проверять сознательно:**
- `autotests`: разбор вывода настоящего `ffprobe`. Проверки приёма получают
длительность от подставного источника; своего теста у
`adapter/metaviewer/ffmpeg` нет. Решение и его цена —
`docs/adr/ADR-2026-08-11-stub-adapters-in-tests.md`.
**Плюс общее, вне зависимости от проекта:** история инцидентов, поведение под
реальным потоком, поведение внешних систем в их версиях, завязка потребителей на
текущее поведение и вопрос «а нужна ли эта функциональность вообще». Последнее
здесь не пустое: панели и алерты, отобранные по `file_extension=".mp3"`,
перестанут пополняться после выкладки, и знает об этом только человек.
### Каких документов проекта не хватило
Строка на каждый, с причиной. Слить нельзя — чинится разным.
- **`CLAUDE.md`, «Ориентир по размеру порции: не замерялся».** Разметка «инлайн
против развилки» опирается на right-size, а мерки right-size в проекте нет.
Пометки «инлайн» в находках 2 и 3 поставлены по объёму правки (одно слово,
одна строка), и это моё предположение, а не сверка с записанным ориентиром.
- **Конвенции о метриках в `docs/conventions/` нет** — каталог есть, файла нет.
Правила «что можно класть в метку», «кто отвечает за кардинальность», «как
объявляется несовместимая смена формы значения» дома не имеют. Отсюда развилка
вместо однозначного вердикта в находке №1 и вкусовой характер вопроса о доме
записи про `.mp3` → `mp3`.
- **`docs/review.md`, «Типовые ложноположительные» — раздел есть и не пуст**,
четыре пункта; применён пункт про открытый HTTP API (дважды: к находке №1
этого круга и к находке №1 первого). Деградации по нему нет.
- Прочие нужные документы на месте и использованы: инварианты `CLAUDE.md`,
`docs/review.md` целиком, `docs/security.md`, `docs/conventions/logging.md`,
`docs/architecture.md`.
### Сработавшие потолки
- **Ни один проход ни на одном круге не сообщил свой потолок** — ни сколько
находок показал из скольких, ни что осталось за срезом. Это находка о прогоне,
повторившаяся во второй раз: судить, полон ли вход триажа, нечем. «15 находок
на входе второго круга» может означать «15 из 15», а может «15 из скольких-то».
- **Потолок триажа не срабатывал:** 1 из 3 в «Блокирует мердж», 2 из 4 в «Стоит
исправить сейчас». Ничего не выброшено из-за потолка. Выброшенное выброшено по
дедупликации (basics #1 и code #3 — одна причина, дубль умолчания; specs #1
второго круга и моя находка №1 — одна причина, приведение без оракула на точке
употребления) и по отсеву вкусовщины (пункт 7 раздела «Что осталось
непочиненным»).
### Метка `medium`, не `small`
Строка про `small` к этому прогону неприменима: `basics` отработал на глубине
«разбор», дома тем `security`, `operations` и `architecture` открывались на обоих
кругах.
---
## Можно ли мержить
**Кода, который сейчас неверен, я не нашёл ни на одном круге второго прохода.**
Приведение стоит на обеих точках, хвост имени отправителя наружу не выходит,
инвариант приватности из `CLAUDE.md` соблюдён, гейт красный только объявленным
долгом, `openspec validate --strict` — valid.
**Мержить можно после того, как человек закроет развилку №1** — она про то, чем
удержано верное поведение, а не про само поведение. Любой из трёх вариантов
развилки делает состояние мерджабельным; вариант (а) — самый дешёвый и требует
только переформулировать чек-лист `tasks.md` 2а.2, чтобы он не утверждал того,
чего нет.
Находки №2 и №3 — инлайн, вместе это одно слово в двух местах и одна строка в
тесте; мерджу они не мешают, но чинятся дешевле сейчас, чем потом.
Формулировка «критичных проблем не обнаружено» к этому прогону применима **только
вместе с секцией границ покрытия выше**, и главная её строка — `autotests` на
втором круге не запускался, а блокирующую находку нашёл я мутацией, а не проход.
---
---
# Приложение: отчёт первого круга, полностью, как был написан
> Ниже — текст триажа первого круга без правок. Исходы его находок проставлены
> в разделе «Что из починенного починено верно» выше: находка №1 закрыта
> решением контрольной точки (приведение метки), находки №2, №3 и №4 починены и
> удержаны мутациями.
## Сводка
- **Change:** `no-user-filename-in-log`. База диффа `origin/master`, судится
рабочее дерево (`git diff`): 4 файла, +172/15.
- **Размер:** среднее. **Сложность:** знакомое. **Метка: medium** — максимум по
осям. Разметчик сам назвал спорным неприменение проектного триггера
«изменение, трогающее оба входа сразу»: правка лежит в единой точке
`createTranscribeJob`, а не отдельной работой по каждому входу. Человек на
чекпоинте согласился оставить `medium`.
- **Режим прогона:** по графу. Состав ревью кода: `autotests`, `specs`, `code`,
`basics`, `triage`.
- **Сигнал о заниженной метке:** `review-code` пришёл и метку заниженной **не**
считает. От `review-basics` сигнала в переданных мне выводах нет — ни «метка
верна», ни «занижена»; отличить «возражений нет» от «не сказано» по молчанию
нельзя, и я этого не делаю.
- **Гейт:** красный **только объявленным долгом**. Проверено мной самим, не со
слов прохода: `go build ./...`, `go vet ./...`, `gofmt -l .`, `go test ./...`
зелёные; `golangci-lint run` даёт ровно 4 знакомых замечания
(`speechkit.go:55`, `main.go:124`, `worker.go:51`, `transcribe.go:395`).
Смещение `394 → 395` внесено самим диффом. Новых красных шагов нет.
- **Находок на входе:** 17 (autotests 0 + 1 promote, specs 4, basics 6, code 6).
**После дедупликации по причине и отсева:** 4 в первых двух секциях, 3 в
гипотезах, 3 кандидата в правила.
### План разметки против исхода
| тема | дом | глубина | закрывает | исход |
| --- | --- | --- | --- | --- |
| requirements | `openspec/specs/intake/spec.md` + дельта change | разбор | specs | **закрыта**, 4 находки |
| autotests | `CLAUDE.md`, «Гейт» и «Команды» | — | autotests | **закрыта**, 0 находок + 1 promote |
| conventions | `docs/conventions/logging.md` + весь каталог | разбор | code | **закрыта**, 6 находок |
| architecture | `docs/architecture.md`, «Единые точки проекта» + `docs/passport.md` | разбор | basics | **закрыта**, ответ по вопросу темы + 1 находка |
| security | `docs/security.md`, «Куда уходит содержимое записи», «Из чего строятся пути и ключи» | разбор | basics | **закрыта**, 1 находка (главная) |
| operations | `docs/architecture.md`, «Эксплуатация» + `docs/database.md` | разбор | basics | **закрыта**, ответы по вопросам темы |
Тем без дома нет. Тем без отчёта нет. Своих тем проекта сверх ядра нет.
---
## Блокирует мердж
### 1. Дельта-спека письменно узаконивает произвольный текст отправителя, а хвост уходит на анонимный `/metrics`
- **Где:** `openspec/changes/no-user-filename-in-log/specs/intake/spec.md`
(«Расширение, взятое из этого имени, запретом не накрыто»);
`internal/service/transcribe.go:95` и `:143`; `internal/metrics/metrics.go:24`
и `:44`; `main.go:216`.
- **Severity:** critical. Инвариант `CLAUDE.md`: «Текст расшифровки, имя файла
пользователя и его сообщение в лог не пишутся — только длина и
идентификаторы. Нарушение необратимо».
- **Причина одна** на три носителя, поэтому это одна находка, а не три: `ext`
берётся из недоверенного имени дословно. Её нашли три прохода независимо
(`specs` #1, `basics` #1, `code` #1) — приоритет от этого выше, `confidence`
нет: под всеми проходами одна модель.
- **Оракул (построен и прогнан на этом прогоне):**
```
filepath.Ext("запись.тайное-слово") -> ".тайное-слово"
filepath.Ext("Разговор с Петровым 11.08") -> ".08"
filepath.Ext("отчёт.для Ивановой") -> ".для Ивановой"
```
Метка Prometheus отдаётся наружу дословно — программа с тем же выражением и
тем же `HistogramVec`, что в `metrics.go:24`, на `GET /metrics`:
```
transcriber_input_file_size_bytes_count{file_extension=".тайное-слово"} 1
```
Путь до этой строки достроен по коду: `header.Filename`
(`internal/controller/http/transcribe.go:43`) → `CreateJobFromApi` →
`createTranscribeJob` → `ext` (`:95`) → `WithLabelValues(ext)` (`:143`);
`router.GET("/metrics", gin.WrapH(promhttp.Handler()))` (`main.go:216`) стоит
за `sloggin` и `gin.Recovery` и ни за какой проверкой.
- **Чего эта находка НЕ утверждает:** «HTTP API открыт без аутентификации» —
типовое ложноположительное проекта (`docs/review.md`, «Типовые
ложноположительные»), и открытость `/metrics` записана в `docs/security.md`
строкой «Метрики и здоровье». Новое здесь не открытость, а то, что на эту
открытую поверхность попадает значение, которым распоряжается анонимный
отправитель, — и что дельта-спека это разрешает текстом.
- **Почему первое место в ранжировании:** ущерб необратим по букве инварианта
(строка уехала в собранные логи и в хранилище метрик), а канал шире того, что
задача закрыла: журнал читает владелец, `/metrics` — кто угодно из интернета.
Тем же концом это неограниченная кардинальность метрики, и множество значений
метки задаёт анонимный отправитель — этот исход вероятнее утечки осмысленного
слова и вредит эксплуатации.
- **Действие: развилка.**
> Хвост после последней точки в имени отправителя уходит дословно в журнал, в
> метку `file_extension` и в метку `source_format` на анонимный `/metrics`.
> Дельта-спека сейчас пишет это в канон фразой «расширение запретом не
> накрыто». Что делаем до мерджа?
>
> **(а)** Мержим как есть: остаток записан в модель угроз, нормализация уходит
> отдельной задачей. Канон при этом получает разрешающую фразу.
> **(б)** Мержим, сузив формулировку требования (например: в журнал и в метку
> идёт расширение из списка разрешённых, прочее заменяется на `.bin`), и в этой
> же задаче нормализуем **только значение метки метрики**. Имя файла на диске
> не трогается, раскладка `data/files` не меняется, правка локальна.
> **(в)** Нормализуем `ext` целиком в `createTranscribeJob`. Тогда меняется
> формат имени файла на диске — `CLAUDE.md` называет это необратимым и
> требующим отдельного решения человека, то есть возврата на чекпоинт.
### 2. Третий журнальный поток в оракуле ничем не удержан: снятие middleware не роняет ни одной проверки
- **Где:** `internal/controller/http/transcribe_test.go`, `setupTestEnv` и
`TestCreateTranscribeJob_SenderFileNameNotLogged`.
- **Severity:** major. Класс тот же, что проход `code` нашёл для стандартного
`log` и что уже починено; для потока middleware дефект остался.
- **Оракул (мутация на копии дерева, прогнана):** удаление
`router.Use(sloggin.New(logger))` из тестового роутера —
`ok git.vakhrushev.me/av/transcriber/internal/controller/http`, зелено. Для
сравнения, три другие мутации краснеют как заявлено: снятие `log.SetOutput`
роняет `…NotLoggedOnFailure`; имя под ключом `upload` роняет обе проверки
запрета; `Info → Debug` на строке приёма роняет `…JournalTracesRecord`.
- **Почему это важно, а не педантизм:** `design.md` включил middleware в
тестовый роутер ровно затем, чтобы квантор требования («ни одна журнальная
запись приёма») совпал с оракулом. Сегодня совпадение держится ничем: любой,
кто уберёт строку как лишнюю, сузит оракул критического инварианта молча.
Это записанное свойство проекта — `docs/review.md`, «Типовые узлы / Любой
узел»: «проверка способна упасть», и там же журнальная запись 2026-08-11 об
этом же классе.
- **Что удержит:** в перехваченном журнале есть строка middleware, вот она:
`msg="Incoming request" … request.path=/api/audio … response.status=201`.
Достаточно `require.Contains(journal, "Incoming request")` в проверке
успешного пути — рядом с уже стоящим `require.Contains(journal, "Err:")` в
проверке отказа.
- **Действие: инлайн.**
---
## Стоит исправить сейчас
### 3. Модель угроз называет метку `src_ext`, которой не существует
- **Где:** `docs/security.md:200` — «`file_extension` у размера принятой записи и
`src_ext` у длительности конвертации».
- **Оракул:** `internal/metrics/metrics.go:44` — метки
`{"source_format", "target_format", "error"}`. `grep -rn "src_ext"` по коду
отдаёт единственное совпадение: локальную переменную Go в
`internal/service/transcribe.go:194`. Журнальное поле рядом называется
`src_format`. Метки `src_ext` нет ни в одной метрике.
- **Почему сейчас:** эта строка — единственный носитель остатка в будущее.
Задача про нормализацию будет искать по имени метки и не найдёт её.
`file_extension` назван верно, ошибка ровно в одном слове.
- **Действие: инлайн.** Заменить `src_ext` на `source_format`.
### 4. Критерий рубрики «ровно одна запись приёма» оракулом не закрыт
- **Где:** `openspec/changes/no-user-filename-in-log/tasks.md`, критерий «в
буфере ровно одна запись приёма на принятую запись, её уровень `INFO`»;
проверка `TestCreateTranscribeJob_JournalTracesRecord`.
- **Что есть:** уровень теперь привязан к строке самого приёма и мутацией
проверен (`Info → Debug` краснеет). Числа записей не проверяет ничто.
- **Почему сейчас, а не в гипотезы:** это не догадка, а разрыв между отмеченным
как выполненный критерием и оракулом; закрывается одной строкой
`strings.Count(journal, "Creating transcribe job") == 1`. Заодно это сторож на
вторую строку приёма, в которую имя вернётся мимо нынешних проверок.
- **Действие: инлайн.**
---
## Гипотезы без доказательства
- **Запрет `t.Parallel()` держится комментарием.** `log.SetOutput` процессный, и
ничто, кроме текста рядом, не мешает следующей проверке в этом файле стать
параллельной; тогда перехват уедет к соседу. Оракула нет: воспроизвести это
можно только внесением `t.Parallel()`, то есть той самой будущей правкой.
`Confidence: medium`, severity не выше `minor`.
- **Позитивное утверждение `require.Contains(journal, "Err:")` привязано к
расхождению, которое конвенция объявляет подлежащим устранению** (`log.Printf`
в HTTP-транспорте мимо `slog`). Когда расхождение починят, проверка отказа
покраснеет по причине, не связанной с приватностью. Оракула нет — событие в
будущем. `minor`.
- **Кардинальность метки `file_extension` не замерена.** Утверждение «число
временных рядов растёт неограниченно» верно по построению, но роста никто не
мерил, а `docs/review.md` прямо велит не считать неизмеренный рост новой
находкой (строка про «файлы и объекты не удаляются»). Вес — только внутри
развилки №1.
---
## Promote candidates
- **Правило линтера на ключи `file_name`/`filename` в вызовах логгера**
(`sloglint` либо `forbidigo`). Пришло от `autotests`; `design.md` объявил это
non-goal задачи. Претензия на правило проекта, а не на этот код.
- **Свойство в `docs/review.md`, «Типовые узлы / Любой узел»:** каждый
перехваченный в оракуле поток журнала удерживается **своим** положительным
утверждением, иначе оракул сужается молча. Обобщение находки №2 и уже
починенной находки прохода `code`.
- **Нормализация расширения, взятого из имени отправителя** — отдельной задачей
в урожай (список разрешённых расширений, `.bin` для прочего), с явным решением
про имя файла на диске. Исход зависит от развилки №1.
---
## Что из уже починенного починено недостаточно
Проверено мутациями на копии дерева, не со слов.
- **Починено верно:** снятие `log.SetOutput` роняет проверку отказа; имя под
чужим ключом роняет обе проверки запрета; `Info → Debug` роняет проверку
прослеживаемости; утверждения о размере и уровне переписаны так, что
переживают смену обработчика на JSON (`size["=:\s]+%d` и
`level["=:\s]+INFO` разбирают и `size=33`, и `"size":33`); мёртвый
`log.SetFlags` снят; тестовый роутер собирается той же цепочкой, что боевой;
второй дом состава журнальной строки из `docs/conventions/logging.md` убран,
строка «*Расхождение:*» на месте.
- **Недостаточно — находка №2:** middleware в роутер добавлен, но не удержан;
фикс закрыл поток `log` и не закрыл поток, ради которого роутер и меняли.
- **Недостаточно — находка №3:** переписанный остаток в `docs/security.md`
называет несуществующую метку `src_ext`.
- **Недостаточно — находка №4:** критерий «ровно одна запись приёма» отмечен
выполненным, оракула у него нет.
- **Ослаблений не найдено:** ни одна прежняя проверка не стала слабее, `go test
./...` зелёный, новых замечаний линтера нет.
Отдельно подтверждаю утверждения, на которых стоит `design.md`, — проверял сам:
приём из Telegram имени, данного человеком, до сервиса не доводит
(`internal/controller/tg/tg.go:276` берёт `file.FilePath`, выданный Telegram;
`document.FileName` дальше проверки типа в `isAudioDocument` не идёт), а ключ
объекта в Object Storage строится после конвертации и всегда имеет вид
`идентификатор.ogg` (`internal/service/transcribe.go:190`).
---
## Границы покрытия
### План
Шесть тем, у каждой есть дом и глубина, все шесть перечислены в сводке выше с
исходом. Тем без дома нет, тем без отчёта нет. Своих тем проекта сверх ядра нет.
### Проходы
- **Запускались** на метке `medium`, режим «по графу»: `review-autotests`,
`review-specs` (код против спек), `review-code` (техника и конвенции, глубина
разбор), `review-basics` (темы `security`, `operations`, `architecture`,
глубина разбор), `review-triage`.
- **Не запускались:** проходы ревью дизайна (`specs`, `rubric`) — они
отработали до кода, на этапе предложения, и в этот прогон не входят.
- **Чего в конвейере нет вовсе** — четыре строки, которые не принесёт ни один
проход:
1. **Решения проекта не сверялись.** `docs/adr/` — процессный документ, прогон
его не открывает. Расхождение изменения с записанным решением ловит сверка
документации (`av-dev-docs:healthcheck`), а не ревью.
2. **Записанные наблюдения проекта не использовались.** `docs/research/` —
тоже процессный. Все числа в этом отчёте сняты на этом прогоне, команды
приложены.
3. **Поимённая сверка с руководством по стилю Go не задавалась ни одним
проходом.** Различение «идиоматично против распространено» не спрашивал
никто.
4. **Альтернативной реализации, с которой можно сдиффить решения, у конвейера
нет.** Проход независимой реализации снят по стоимости; «не знаю, чего не
знаю» никто не достаёт.
### Чего запущенные проходы не могли проверить в принципе
- Ни один проход не гонял сервис на настоящих данных: `testdata` в проекте нет
по запрету `CLAUDE.md`, реальные ключи Yandex Cloud под запретом, боевую БД и
`data/files` трогать нельзя. Все оракулы — синтетический вход и мутации.
- `autotests` судит прогон и мутации, а не смысл проверок; `specs` судит код
против заказанного поведения и не ищет дефектов вне него; `code` судит технику
и конвенции и не судит требования; `basics` идёт по трём темам и только по их
записанным домам.
- Приём из Telegram своей проверки не получил вовсе (объявлено риском в
`design.md`): правка достаётся ему общим шагом кода, но оракула на него нет.
- Триаж не читает код в поисках дефектов: пропуск любого прохода — мой пропуск
тоже.
### Что осталось целиком на человеке
Из `docs/review.md`, «Недоступно проверке», двумя списками — они не сливаются.
**Не проверит ни один проход:**
- `operations`: поведение SpeechKit и Object Storage под нагрузкой и на границах
— поднять их в тесте нечем;
- `operations`: реальный профиль нагрузки; проект живёт на единицах записей в
день, и утверждения о росте остаются условиями;
- `security`: стойкость `ffmpeg` к вредоносному входу — разбор чужого формата
отдан внешней программе.
**Перестали проверять сознательно:**
- `autotests`: разбор вывода настоящего `ffprobe`. Проверки приёма получают
длительность от подставного источника; своего теста у
`adapter/metaviewer/ffmpeg` нет. Решение и его цена —
`docs/adr/ADR-2026-08-11-stub-adapters-in-tests.md`.
**Плюс общее, вне зависимости от проекта:** история инцидентов, поведение под
реальным потоком, поведение внешних систем в их версиях, завязка потребителей на
текущее поведение и вопрос «а нужна ли эта функциональность вообще».
### Каких документов проекта не хватило
- **`CLAUDE.md`, «Ориентир по размеру порции: не замерялся».** Разметка
«инлайн против развилки» опирается на right-size, а мерки right-size в проекте
нет. Пометки «инлайн» в находках 2–4 поставлены по объёму правки (одна-две
строки), и это моё предположение, а не сверка с записанным ориентиром.
- **Дома у вопроса «что можно класть в метку метрики» нет.** `docs/security.md`
описывает открытость `/metrics`, но правила состава меток нет ни в модели
угроз, ни в конвенциях; отсюда развилка вместо однозначного вердикта в находке
№1.
- Прочие нужные документы на месте и использованы: инварианты `CLAUDE.md`,
`docs/review.md` (включая «Типовые ложноположительные» — применён пункт про
открытый HTTP API), `docs/security.md`, `docs/conventions/logging.md`.
Деградации по ним нет.
### Сработавшие потолки
- **Ни один из четырёх проходов не сообщил свой потолок** — ни сколько находок
показал из скольких, ни что осталось за срезом. Это находка о прогоне: судить,
полон ли вход триажа, нечем, и «на входе 17 находок» может означать «17 из
17», а может «17 из скольких-то».
- **Потолок триажа сработал мягко:** 2 из 3 в «Блокирует мердж» и 2 из 4 в
«Стоит исправить сейчас». Ничего не выброшено из-за потолка; выброшенное
выброшено по дедупликации (одна причина на три носителя в находке №1, один
класс на четыре формулировки в находке №2) и по отсеву — снятый `log.SetFlags`
и второй дом в конвенции уже починены, а «изоляция буфера держится не тем, чем
заявлено» после правки сведено к комментарию и уехало в гипотезы.
Формулировка «критичных проблем не обнаружено» к этому прогону неприменима:
критичная проблема обнаружена и стоит развилкой №1.
@@ -0,0 +1,91 @@
## ADDED Requirements
### Requirement: Имя файла, данное отправителем, не попадает в журнал
Приём SHALL не писать имя файла, данное отправителем, ни в одну свою журнальную
запись — ни на успешном пути, ни на пути отказа, где имя могло бы приехать
текстом ошибки. Имя приходит извне вместе с записью и принадлежит содержимому
личной переписки наравне с текстом расшифровки; журнал уезжает в собранные логи,
откуда строку не убрать.
Расширение, взятое из этого имени, в журнале остаётся: оно стоит в собственном
имени файла на диске, и по нему прослеживается путь записи. Что именно попадает в
журнал ради прослеживаемости, нормирует требование ниже; наружу расширение
выходит только приведённым к известному виду — этому отдано третье требование.
Сценарии судят приём по HTTP, потому что имя, данное отправителем, доходит до
сервиса только оттуда: из Telegram приходит путь, выданный самим Telegram, а не
имя человека. Правка при этом ложится на общий шаг заведения задачи, через
который идут оба входа, поэтому своей нормы приём из Telegram здесь не получает —
её напишет задача, которая тронет его поведение.
#### Scenario: Имя записи не видно в журнале принятой записи
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **WHEN** программа шлёт `POST /api/audio` с записью, чья основа имени несёт
опознаваемую строку при обычном расширении `.mp3`
- **THEN** ни одна журнальная запись приёма этой строки не содержит
- **AND** расширение `.mp3` в журнале допустимо
#### Scenario: Имя записи не видно в журнале при отказе приёма
- **GIVEN** источник метаданных не может прочитать запись
- **WHEN** программа шлёт `POST /api/audio` с записью, чья основа имени несёт
опознаваемую строку
- **THEN** ни одна журнальная запись приёма, включая запись об ошибке, этой
строки не содержит
### Requirement: Журнал приёма прослеживает запись
Приём SHALL писать в журнал идентификатор заведённого файла, расширение принятой
записи и её размер в байтах. По ним путь записи собирается отбором по журналу, и
удаление имени отправителя прослеживаемости не отнимает.
Расширение засчитывается присутствием собственного имени файла в хранилище:
отдельного поля под него приём не заводит.
#### Scenario: Идентификатор, расширение и размер на месте
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **WHEN** программа шлёт `POST /api/audio` с записью
- **THEN** журнал приёма несёт идентификатор заведённого файла, расширение
принятой записи и её размер в байтах
### Requirement: Метка метрики несёт только известное расширение
Сервис SHALL приводить расширение принятой записи к известному виду прежде, чем
употребить его меткой метрики: расширение приводится к нижнему регистру и
сверяется с закрытым перечнем; совпавшее идёт приведённым, всякое другое MUST
заменяться единым значением `other`. Перечень — `mp3`, `wav`, `ogg`, `oga`,
`opus`, `flac`, `m4a`, `aac`, `wma`, `mp4`, `mkv`, `mov`, `avi`, `webm`, плюс
`audio`: последнее не формат, а собственное умолчание сервиса на случай имени
без расширения, и различать его от чужого хвоста метка обязана.
Страница метрик отдаётся без проверки отправителя, поэтому метка — поверхность
пошире журнала: её читает кто угодно. Тем же ограничением снимается и рост числа
временных рядов, которым иначе распоряжается анонимный отправитель.
Требование намеренно шире приёма: под него подпадает и метка шага конвертации.
Когда конвертацию нормируют своей capability, обязанность переезжает туда вместе
с ней.
Имя файла на диске это требование не трогает: там расширение остаётся тем, каким
пришло, — это уже нормировано требованием «Имя файла в хранилище».
Настоящий формат записи, попавшей в `other`, остаётся видимым в журнале: значение
`other` в метке означает «расширение не из перечня», а само оно стоит в поле
пути журнальной строки приёма и в поле формата строки конвертации.
#### Scenario: Незнакомое расширение наружу не выходит
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **WHEN** программа шлёт запись с именем, чей хвост после последней точки не
принадлежит перечню known-форматов
- **THEN** метка метрики принимает значение `other`
- **AND** файл в каталоге хранения сохраняет пришедшее расширение
#### Scenario: Известное расширение идёт как есть
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **WHEN** программа шлёт запись с именем `sample.MP3`
- **THEN** метка метрики принимает значение `mp3`
@@ -0,0 +1,89 @@
## 1. Правка журнала приёма
- [x] 1.1 Убрать поле с именем, данным отправителем, из журнальной строки общего
шага заведения задачи (`internal/service/transcribe.go`,
`createTranscribeJob`). Идентификатор файла и путь к нему в хранилище остаются,
уровень строки остаётся `INFO`.
- [x] 1.2 Проверить остаток поиском по **значению**: прогон приёма с маркером в
имени, поиск маркера по всему выводу прогона — ничего не найдено. Поиск по
имени поля `file_name` остатком не считается.
## 2. Проверка
- [x] 2.1 Окружение проверок приёма по HTTP отдаёт журнал в буфер вместо
`io.Discard`. Буфер свой на каждый случай, а цепочка журнала воспроизводит
боевую: окружение зовёт `slog.SetDefault` и собирает роутер тем же набором
middleware, что `main.go`, — иначе два потока из трёх остаются вне оракула.
Вывод прогона от этого не меняется.
- [x] 2.7 У каждого из трёх потоков журнала своё удерживающее утверждение:
снятие потока из окружения роняет проверку, а не проходит молча.
- [x] 2.2 Проверка успешного приёма: имя записи несёт маркер — уникальную
ASCII-строку, которой нет в остальном выводе, — при обычном расширении `.mp3`.
В перехваченном журнале маркера нет.
- [x] 2.3 Проверка отказного приёма: источник метаданных не читает запись, имя
несёт тот же маркер. В перехваченном журнале, включая запись об ошибке,
маркера нет.
- [x] 2.4 Проверка прослеживаемости: в журнале есть идентификатор заведённого
файла, расширение принятой записи и её размер в байтах. Утверждение отбирается
по идентификатору **этого** прогона.
- [x] 2.5 Мутация: вернуть имя в журнальную строку под **другим** ключом —
проверки 2.2 и 2.3 краснеют; снять мутацию — зеленеют.
- [x] 2.6 `go test -race -count=5 ./internal/controller/http/` зелёный.
## 2а. Метка метрики (добавлено чекпоинтом после ревью кода)
- [x] 2а.1 Расширение приводится к закрытому перечню известных форматов прежде,
чем уйти меткой метрики; всё прочее — `other`. Имя файла на диске не трогается.
- [x] 2а.2 Обе метки, несущие расширение, идут через приведение: размер принятой
записи и длительность конвертации. Сырой точки употребления гистограммы в
сервисе не остаётся — приведение живёт внутри обёрток пакета метрик, и обойти
его можно только заведя новую точку.
- [x] 2а.4 Обе обёртки судятся по реестру метрик, а не по чистой функции:
снятие приведения в любой из них роняет проверку.
- [x] 2а.3 Проверка приведения: известное расширение с точкой и без, смена
регистра, умолчание сервиса, хвост имени отправителя, часть даты, пустое.
## 3. Гейт и документы
- [x] 3.1 `task gate` зелёный сверх объявленного долга (4 замечания
`golangci-lint` в существующем коде).
- [x] 3.2 `docs/security.md`: строка «Имя файла, данное отправителем, пишется»
переписана остатком — имя из журнала приёма убрано, хвост после последней
точки продолжает попадать в журнал внутри пути файла в хранилище.
- [x] 3.3 Решить, нужна ли строка в `docs/conventions/logging.md` о том, чем
заменено имя, и либо дописать её, либо назвать причину отказа.
## Критерии приёмки
### Из записи задачи `no-user-filename-in-log`, дословно
- Имени, данного отправителем, нет ни в одной журнальной строке приёма. Оракул —
прогон приёма с записью, чьё имя содержит опознаваемую строку, и `grep` этой
строки по перехваченному журналу: ничего не найдено.
- Идентификатор файла, его расширение и длина в журнале остаются: по ним путь
записи прослеживается. Оракул — тот же перехваченный журнал, `file_id` и
`size` на месте.
### Рубрика ревью дизайна
- Запрещённое значение не появляется ни в одном поле и ни в одном `msg` записи о
приёме, включая ветку отказа. Оракул — прогон успеха и прогон отказа с
маркером в имени, поиск маркера по перехваченному журналу пуст в обоих.
- Оракул перехватывает весь журнальный поток приёма, а не один обработчик.
Оракул — мутация: вернуть имя в обход `slog`, проверка краснеет.
- Проверка способна упасть. Оракул — мутация с **другим** ключом поля роняет
проверку; проверка ищет значение, а не имя ключа.
- Маркер уникален и записан ASCII, поиск идёт по сырому тексту буфера. Оракул —
при возвращённом поле утечка находится, несмотря на экранирование обработчиком.
- Буфер журнала свой на случай, утверждение о полях отбирается по идентификатору
этого прогона. Оракул — `go test -race -count=5 ./internal/controller/http/`
зелёный.
- Запись о приёме не исчезает и не меняет адресата: уровень остаётся `INFO`,
категория `msg` прежняя. Оракул — в буфере ровно одна запись приёма на
принятую запись, её уровень `INFO`.
- Прослеживаемость названа полями поимённо, с единицей у числового: размер — в
байтах. Оракул — критерий приёмки называет те же ключи, что и требование.
- Косвенные носители имени названы поимённо и каждый закрыт либо назван
остатком: текст ошибки — закрыт проверкой 2.3, путь на диске и ключ объекта
строятся из идентификатора и расширения, расширение — остаток строкой в
`docs/security.md`.
+90
View File
@@ -78,6 +78,96 @@ MUST нести идентификатор задачи полем `job_id` и
- **THEN** ответ имеет код `500`
- **AND** задача расшифровки не заводится
### Requirement: Имя файла, данное отправителем, не попадает в журнал
Приём SHALL не писать имя файла, данное отправителем, ни в одну свою журнальную
запись — ни на успешном пути, ни на пути отказа, где имя могло бы приехать
текстом ошибки. Имя приходит извне вместе с записью и принадлежит содержимому
личной переписки наравне с текстом расшифровки; журнал уезжает в собранные логи,
откуда строку не убрать.
Расширение, взятое из этого имени, в журнале остаётся: оно стоит в собственном
имени файла на диске, и по нему прослеживается путь записи. Что именно попадает в
журнал ради прослеживаемости, нормирует требование ниже; наружу расширение
выходит только приведённым к известному виду — этому отдано отдельное требование.
Сценарии судят приём по HTTP, потому что имя, данное отправителем, доходит до
сервиса только оттуда: из Telegram приходит путь, выданный самим Telegram, а не
имя человека. Правка при этом ложится на общий шаг заведения задачи, через
который идут оба входа, поэтому своей нормы приём из Telegram здесь не получает —
её напишет задача, которая тронет его поведение.
#### Scenario: Имя записи не видно в журнале принятой записи
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **WHEN** программа шлёт `POST /api/audio` с записью, чья основа имени несёт
опознаваемую строку при обычном расширении `.mp3`
- **THEN** ни одна журнальная запись приёма этой строки не содержит
- **AND** расширение `.mp3` в журнале допустимо
#### Scenario: Имя записи не видно в журнале при отказе приёма
- **GIVEN** источник метаданных не может прочитать запись
- **WHEN** программа шлёт `POST /api/audio` с записью, чья основа имени несёт
опознаваемую строку
- **THEN** ни одна журнальная запись приёма, включая запись об ошибке, этой
строки не содержит
### Requirement: Журнал приёма прослеживает запись
Приём SHALL писать в журнал идентификатор заведённого файла, расширение принятой
записи и её размер в байтах. По ним путь записи собирается отбором по журналу, и
удаление имени отправителя прослеживаемости не отнимает.
Расширение засчитывается присутствием собственного имени файла в хранилище:
отдельного поля под него приём не заводит.
#### Scenario: Идентификатор, расширение и размер на месте
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **WHEN** программа шлёт `POST /api/audio` с записью
- **THEN** журнал приёма несёт идентификатор заведённого файла, расширение
принятой записи и её размер в байтах
### Requirement: Метка метрики несёт только известное расширение
Сервис SHALL приводить расширение принятой записи к известному виду прежде, чем
употребить его меткой метрики: расширение приводится к нижнему регистру и
сверяется с закрытым перечнем; совпавшее идёт приведённым, всякое другое MUST
заменяться единым значением `other`. Перечень — `mp3`, `wav`, `ogg`, `oga`,
`opus`, `flac`, `m4a`, `aac`, `wma`, `mp4`, `mkv`, `mov`, `avi`, `webm`, плюс
`audio`: последнее не формат, а собственное умолчание сервиса на случай имени
без расширения, и различать его от чужого хвоста метка обязана.
Страница метрик отдаётся без проверки отправителя, поэтому метка — поверхность
пошире журнала: её читает кто угодно. Тем же ограничением снимается и рост числа
временных рядов, которым иначе распоряжается анонимный отправитель.
Требование намеренно шире приёма: под него подпадает и метка шага конвертации.
Когда конвертацию нормируют своей capability, обязанность переезжает туда вместе
с ней.
Имя файла на диске это требование не трогает: там расширение остаётся тем, каким
пришло, — это уже нормировано требованием «Имя файла в хранилище».
Настоящий формат записи, попавшей в `other`, остаётся видимым в журнале: значение
`other` в метке означает «расширение не из перечня», а само оно стоит в поле
пути журнальной строки приёма и в поле формата строки конвертации.
#### Scenario: Незнакомое расширение наружу не выходит
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **WHEN** программа шлёт запись с именем, чей хвост после последней точки не
принадлежит перечню known-форматов
- **THEN** метка метрики принимает значение `other`
- **AND** файл в каталоге хранения сохраняет пришедшее расширение
#### Scenario: Известное расширение идёт как есть
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **WHEN** программа шлёт запись с именем `sample.MP3`
- **THEN** метка метрики принимает значение `mp3`
### Requirement: Опрос готовности задачи
Сервис SHALL отдавать состояние задачи расшифровки по запросу