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

- расширение приводится к перечню известных форматов прежде метки метрики:
  страница метрик открыта, и хвост имени уезжал на неё дословно
- проверки приёма перехватывают все три потока журнала и читают реестр метрик,
  каждая падает при снятии того, что сторожит
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,45 @@
# ADR-2026-08-11. Наружу расширение выходит только приведённым к перечню
- **Дата:** 2026-08-11
- **Источник:** [openspec/changes/archive/2026-08-11-no-user-filename-in-log/design.md](../../openspec/changes/archive/2026-08-11-no-user-filename-in-log/design.md), раздел `Decisions`
## Решение
Расширение принятой записи приводится к закрытому перечню известных форматов
прежде, чем уйти меткой метрики; всё, чего в перечне нет, заменяется одним общим
значением. Имя файла на диске при этом не трогается — там расширение остаётся
тем, каким пришло.
Дословно из источника:
> Из трёх способов человек выбрал средний. Отвергнуты: оставить как есть и завести
> задачу — канал жил бы до неё, а закрытие этой задачи читалось бы как
> «починено»; приводить расширение везде, включая имя файла на диске, —
> раскладка каталога записей объявлена необратимой и меняется решением человека,
> а не по ходу починки журнала.
## Почему
Расширение берётся из имени, которое дал отправитель, дословно: имя
`запись.тайное-слово` отдаёт `тайное-слово`, а `Разговор с Петровым 11.08`
`08`. Оно уходило меткой метрики, а страница метрик отдаётся без проверки
отправителя. Канал оказался шире того, ради которого задача заводилась: журнал
читает владелец сервиса, метки — кто угодно, и то же значение оседает в
хранилище метрик. Тем же каналом множество значений метки становится
неограниченным: их задаёт анонимный отправитель.
Отказ от нормализации на диске — не экономия, а граница обратимости: формат
имени файла и раскладка `data/files` объявлены необратимыми, и меняются они
решением человека под свою задачу, а не попутно с починкой журнала.
## Цена
- Перечень форматов стал нормой и требует ведения: формат, который сервис
начнёт принимать, до внесения в перечень будет виден в метрике как общее
значение, неотличимо от чужого хвоста.
- Форма метки размера принятой записи изменилась — ведущая точка пропала
(`.mp3` стало `mp3`). Ряды, собранные до выкладки, перестают пополняться.
- Настоящий формат записи, попавшей в общее значение, остаётся видимым только в
журнале — по полю пути строки приёма и полю формата строки конвертации.
- Хвост расширения по-прежнему уходит в журнал внутри пути файла. Это остаток,
он записан в [../security.md](../security.md), и своей задачи у него пока нет.
+1
View File
@@ -32,6 +32,7 @@
| Дата | Запись | Статус |
| --- | --- | --- |
| 2026-08-11 | [Наружу расширение выходит только приведённым к перечню](ADR-2026-08-11-known-format-label.md) | |
| 2026-08-11 | [Приложение пишем на Vue, а Node входит в гейт и в образ](ADR-2026-08-11-spa-on-vue.md) | |
| 2026-08-11 | [Очередь остаётся своей таблицей, но коллекцией PocketBase](ADR-2026-08-11-queue-as-pocketbase-collection.md) | |
| 2026-08-11 | [Хранилище, файлы и вход переезжают в PocketBase](ADR-2026-08-11-pocketbase-storage-with-admin-panel.md) | |
+1
View File
@@ -104,6 +104,7 @@
| Завершение и отказ | `TranscribeService.completeJob` и `failJob` — они же отвечают пользователю |
| Разбор конфигурации | `internal/config.LoadConfig` |
| Метрики | `internal/metrics`, префикс имени `transcriber_` |
| Значения метки формата | `internal/metrics.FormatLabel` — приводит расширение к закрытому перечню, прочее заменяет на `other`; нормирует спека `intake` |
Единых точек, которых **нет** и которые ожидались бы: идентификаторы
генерируются вызовом `uuid.NewString()` по месту, время — вызовом `time.Now()`
+10 -1
View File
@@ -213,7 +213,10 @@ Object Storage, скачивание файла из Telegram и опрос оп
- ключ SpeechKit и заголовок `Authorization`;
- пара ключей Object Storage;
- **сам текст расшифровки и имена файлов пользователя** — это содержимое личной
переписки. Логируем длину текста, а не текст.
переписки. Логируем длину текста, а не текст. Имя файла ничем не заменяем:
ни укороченным именем, ни отпечатком от него — отпечаток та же приватная
величина, а корреляцию держат идентификаторы сущностей. Чем при этом
прослеживается приём, нормирует спека `intake`, а не эта запись.
Дополнительно:
@@ -233,6 +236,12 @@ Object Storage, скачивание файла из Telegram и опрос оп
она не логируется — то есть утечки нет, но защищает от неё только отсутствие
строки лога.
*Расхождение:* расширение берётся из имени отправителя дословно
(`filepath.Ext`), поэтому имя `запись.тайное-слово` отдаёт приватный хвост
расширением, и в журнал оно попадает полем пути. Наружу — в метку метрики — этот
хвост не выходит: там расширение приводится к перечню известных форматов. Остаток
описан в [../security.md](../security.md).
## Куда пишем и уровень
- Пишем JSON в `stdout` одним потоком; сбор и ротацию делает окружение. Не
+23
View File
@@ -103,6 +103,9 @@
- `security`: не строится ли путь на диске или ключ объекта из значения,
пришедшего снаружи, — расширение файла сегодня берётся из имени отправителя
(чтение `service/transcribe.go`, 2026-08-10).
- `security`: не уходит ли значение, пришедшее снаружи, меткой метрики — страница
метрик отдаётся без проверки отправителя, и метка это поверхность пошире
журнала (журнал, запись 2026-08-11 про хвост имени).
- `architecture`: не появился ли второй путь приёма мимо
`createTranscribeJob` — сегодня через него идут оба входа
([architecture.md](architecture.md), «Единые точки проекта»).
@@ -178,6 +181,26 @@ API и имя не откатываются обратной правкой по
поймать их было некому. У восстановленных нет поля «Чем воспроизведён», и
выдумывать его задним числом нельзя.
## 2026-08-11 — хвост имени отправителя уезжал на открытую страницу метрик [пойман ревью]
- **Где:** `internal/service/transcribe.go`, метки `file_extension` у размера
принятой записи и `source_format` у длительности конвертации
- **Симптом:** имя `запись.тайное-слово` клало `тайное-слово` меткой метрики, а
`GET /metrics` отдаётся без проверки отправителя. Тем же каналом множеством значений
метки распоряжался анонимный отправитель
- **Причина:** расширение берётся из имени отправителя дословно (`filepath.Ext`)
и употреблялось меткой без приведения. Канал старше задачи, которая его нашла
- **Чем воспроизведён:** прогон `filepath.Ext` на именах вида
`запись.тайное-слово`, `Разговор с Петровым 11.08`, затем чтение реестра
метрик после приёма — метка несла хвост дословно
- **Почему не поймали:** метрику никто не считал выходом приватного значения.
Тема `security` смотрела журнал, ответ и пути на диске; вопроса про метку в
перечне вопросов не было, и ни один проход её не открывал. Поймали три прохода
разом на задаче, которая закрывала соседний канал
- **Что меняем:** вопрос про метку добавлен в «Вопросы по темам»; правило
приведения нормировано спекой `intake` и записано
[решением](adr/ADR-2026-08-11-known-format-label.md)
## 2026-08-11 — проверка приёма не могла упасть [пойман ревью]
- **Где:** `internal/controller/http/transcribe_test.go`, случай успеха приёма
+28 -6
View File
@@ -186,12 +186,34 @@ Telegram отправителю.
который лежит **не в конфигурации**: его отпечаток хранит сама база.
Тексты расшифровок в логи не пишутся — логируется длина текста и
идентификаторы. **Имя файла, данное отправителем, пишется**: строка
`internal/service/transcribe.go:107` кладёт `file_name` на общем шаге заведения
задачи, то есть для обоих входов. Это нарушение инварианта приватности из
`CLAUDE.md`, оно объявлено критическим, и чинит его задача
`no-user-filename-in-log`; её место в очереди —
[tasks/BACKLOG.md](../tasks/BACKLOG.md).
идентификаторы. Имя файла, данное отправителем, из журнала приёма убрано
2026-08-11 задачей `no-user-filename-in-log`; запрет проверяют тесты приёма по HTTP на
успешном пути и на пути отказа — они ищут значение, а не имя поля.
**Остаток: хвост после последней точки остаётся в журнале.** Расширение берётся
из имени отправителя дословно (`filepath.Ext`), поэтому имя
`запись.тайное-слово` отдаёт `тайное-слово`, а `Разговор с Петровым 11.08`
`08`. Оно стоит в собственном имени файла на диске, а путь к файлу логируется.
Читает этот журнал владелец сервиса. Нормализация расширения на диске — отдельная
работа, задачи на неё пока нет: формат имени файла объявлен необратимым и меняется
решением человека.
**Наружу хвост не выходит.** Метки метрик (`file_extension` у
`transcriber_input_file_size_bytes`, `source_format` у
`transcriber_conversion_duration_seconds`) несут расширение, только приведённое к
закрытому перечню известных форматов; всё прочее заменяется значением `other`.
Это закрыто задачей `no-user-filename-in-log` 2026-08-11 вместе с самим именем.
Заодно у метки размера принятой записи пропала ведущая точка (`.mp3` стало
`mp3`) — форма выровнялась с меткой конвертации, которая точку не носила
никогда. Ряды, собранные до выкладки, перестают пополняться: панель, отобранная
по старому значению, покажет пустоту, и это не поломка.
Требование важно тем, что `GET /metrics` открыт вместе с остальным: без
приведения хвост читал бы кто угодно из интернета, а множеством значений метки
распоряжался бы анонимный отправитель.
Приём из Telegram имени, данного человеком, до сервиса не доводит: оттуда
приходит путь, выданный самим Telegram. Настоящее имя документа дальше проверки
типа файла не идёт.
Токен бота попадает в URL скачивания файла (`file.Link(token)`), и этот URL
нигде не логируется.
+214 -7
View File
@@ -5,7 +5,7 @@ import (
"database/sql"
"encoding/json"
"errors"
"io"
"fmt"
"log/slog"
"mime/multipart"
"net/http"
@@ -13,7 +13,10 @@ import (
"os"
"path"
"path/filepath"
"regexp"
"runtime"
"strings"
"sync"
"testing"
"time"
@@ -27,6 +30,8 @@ import (
"github.com/gin-gonic/gin"
_ "github.com/mattn/go-sqlite3"
"github.com/pressly/goose/v3"
"github.com/prometheus/client_golang/prometheus"
sloggin "github.com/samber/slog-gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -74,6 +79,31 @@ type testEnv struct {
handler *TranscribeHandler
db *sql.DB
storageDir string
journal *journalBuffer
}
// journalBuffer — перехваченный журнал одной проверки. Свой на случай: общий на
// пакет сделал бы исход функцией от соседних случаев — «поля на месте» прошло бы
// на чужой строке, а «маркера нет» покраснело бы от чужой. Замок нужен потому,
// что пишущих в него потоков три: логгер сервиса, стандартный `log` транспорта
// и middleware запроса.
type journalBuffer struct {
mu sync.Mutex
text strings.Builder
}
func (b *journalBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.text.Write(p)
}
// String отдаёт весь перехваченный текст. Проверки ищут в нём значение, а не имя
// поля: имя, вернувшееся под другим ключом, поиск по ключу не разбудил бы.
func (b *journalBuffer) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.text.String()
}
func setupTestDB(t *testing.T) (*sql.DB, *goqu.Database) {
@@ -115,11 +145,26 @@ func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv {
fileRepo := sqlite.NewFileRepository(db, gq)
jobRepo := sqlite.NewTranscriptJobRepository(db, gq)
// Журнал проверкам не нужен: судят они по ответу и по базе. А ветка отказа
// метаданных теперь проходится нарочно, и её ERROR-строки на зелёном
// прогоне размывали бы признак, по которому отличают новый красный шаг
// гейта от объявленного долга.
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
// Журнал уходит в буфер, а не в никуда: по нему судит проверка запрета на
// имя отправителя. Вывод прогона от этого не меняется — ERROR-строки ветки
// отказа по-прежнему не попадают на экран и не размывают признак, по
// которому отличают новый красный шаг гейта от объявленного долга.
journal := &journalBuffer{}
logger := slog.New(slog.NewTextHandler(journal, nil))
// Второй писатель журнала приёма — HTTP-транспорт: он пишет через стандартный
// `log` (расхождение записано в docs/conventions/logging.md). В бою `main.go`
// зовёт `slog.SetDefault`, и такая запись садится в `msg` строки `slog`;
// повторяем это здесь, чтобы оракул видел ту же цепочку, что и прод, а не
// свою. Без перехвата оракул был бы уже требования, которое накрывает все
// журнальные записи приёма.
//
// Подмена процессная, а не своя у случая: `t.Parallel()` в этом файле
// запрещён. При параллельных случаях вывод указывал бы на буфер соседа, и
// проверка запрета прошла бы, ничего не прочитав.
prevDefault := slog.Default()
slog.SetDefault(logger)
t.Cleanup(func() { slog.SetDefault(prevDefault) })
trsService := service.NewTranscribeService(
jobRepo,
@@ -134,7 +179,12 @@ func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv {
handler := NewTranscribeHandler(jobRepo, trsService)
// Роутер собирается той же цепочкой, что и боевой (main.go): у приёма три
// пишущих в журнал потока, и middleware — третий. Без него требование «ни
// одна журнальная запись приёма» проверялось бы шире, чем оракул смотрит.
router := gin.New()
router.Use(sloggin.New(logger))
router.Use(gin.Recovery())
router.MaxMultipartMemory = 32 << 20 // 32 MiB
api := router.Group("/api")
@@ -143,7 +193,7 @@ func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv {
api.GET("/status/:id", handler.GetTranscribeJobStatus)
}
return &testEnv{router: router, handler: handler, db: db, storageDir: storageDir}
return &testEnv{router: router, handler: handler, db: db, storageDir: storageDir, journal: journal}
}
// createMultipartRequest собирает запрос из имени и содержимого. Файла на диске
@@ -382,6 +432,163 @@ func TestCreateTranscribeJob_MetaViewerFailure(t *testing.T) {
assert.Equal(t, 0, countJobs(t, env))
}
// senderNameMarker — метка внутри имени, которое даёт отправитель. ASCII и
// заведомо уникальна: в остальном выводе прогона такой строки нет, поэтому
// находка означает утечку, а не совпадение. Ищется она **значением**, а не
// именем журнального поля: имя, вернувшееся под другим ключом, поиск по ключу
// пропустил бы.
const senderNameMarker = "SENDERNAMELEAKMARKER7Q2"
// Тексты, по которым проверки находят журнальные строки. Оба — записанный долг
// `docs/conventions/logging.md`: `msg` обязан стать короткой категорией, а
// транспорту не положено логировать вовсе. Когда долг закроют, правка будет
// здесь и одна, а смысл утверждений менять не придётся.
const (
msgIntake = "Creating transcribe job"
msgTransportErr = "Err:"
msgMiddleware = "Incoming request"
)
func TestCreateTranscribeJob_SenderFileNameNotLogged(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
// Метка стоит в основе имени, а расширение обычное: расширение запретом не
// накрыто и в журнале остаётся законно.
req := createMultipartRequest(t, senderNameMarker+".mp3", []byte("запись"))
w := httptest.NewRecorder()
env.router.ServeHTTP(w, req)
require.Equal(t, http.StatusCreated, w.Code)
journal := env.journal.String()
// Сперва — что поток middleware вообще перехвачен. Он третий писатель
// журнала приёма, и без этого утверждения снятие его из тестового роутера
// сузило бы оракул молча.
require.Contains(t, journal, msgMiddleware,
"строка middleware о запросе попадает в перехваченный журнал")
assert.NotContains(t, journal, senderNameMarker,
"имя, данное отправителем, не пишется в журнал: инвариант приватности")
}
func TestCreateTranscribeJob_SenderFileNameNotLoggedOnFailure(t *testing.T) {
// Отказ — тот путь, где имя приехало бы в журнал текстом ошибки: приём
// назван конвенцией логирующей границей, и цепочка `%w` осядет полем error.
env := setupTestEnv(t, &stubMetaViewer{err: errors.New("не удалось прочитать запись")})
req := createMultipartRequest(t, senderNameMarker+".mp3", []byte("не запись вовсе"))
w := httptest.NewRecorder()
env.router.ServeHTTP(w, req)
require.Equal(t, http.StatusInternalServerError, w.Code)
journal := env.journal.String()
// Сперва — что второй поток журнала вообще перехвачен. Без этого
// утверждения снятие `slog.SetDefault` из окружения оставило бы проверку
// зелёной, а оракул критического инварианта молча сузился бы вдвое.
require.Contains(t, journal, msgTransportErr,
"строка транспорта, идущая мимо slog, попадает в перехваченный журнал")
assert.NotContains(t, journal, senderNameMarker,
"имя отправителя не пишется в журнал и на пути отказа")
}
func TestCreateTranscribeJob_JournalTracesRecord(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
content := []byte("содержимое записи")
req := createMultipartRequest(t, "sample.mp3", content)
w := httptest.NewRecorder()
env.router.ServeHTTP(w, req)
require.Equal(t, http.StatusCreated, w.Code)
var response CreateTranscribeJobResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
job, err := env.handler.jobRepo.GetByID(response.JobID)
require.NoError(t, err)
require.NotNil(t, job.FileID)
// Отбор по идентификатору **этого** прогона: иначе утверждение прошло бы по
// строке, оставленной соседней проверкой, и прослеживаемость числилась бы
// сохранённой при пустом журнале.
journal := env.journal.String()
assert.Contains(t, journal, *job.FileID, "по журналу видно, какой файл заведён")
assert.Contains(t, journal, ".mp3", "расширение принятой записи в журнале остаётся")
// Разделитель ключа и значения задаёт обработчик: сегодня текстовый, по
// конвенции — JSON. Утверждение держится на значении и переживёт замену.
assert.Regexp(t, fmt.Sprintf(`size["=:\s]+%d`, len(content)), journal,
"размер принятой записи в байтах в журнале остаётся")
// Запись о приёме не сменила адресата: на DEBUG её в боевой настройке не
// будет вовсе, и разбор постфактум опереться будет не на что. Уровень
// ищется в строке самого приёма — соседние строки тоже идут на INFO, и
// поиск по всему журналу не упал бы от понижения этой.
assert.Regexp(t, `(?m)^.*level["=:\s]+INFO.*`+regexp.QuoteMeta(msgIntake)+`.*$`, journal,
"строка приёма остаётся на уровне INFO")
// И она ровно одна: вторая строка приёма означала бы второй путь заведения
// задачи мимо общей точки, то есть место, куда запрет не доехал.
assert.Equal(t, 1, strings.Count(journal, msgIntake),
"на принятую запись приходится одна журнальная строка приёма")
}
// metricLabelValues собирает значения меток названного семейства метрик из
// общего реестра процесса. Проверка судит реестр, а не функцию приведения:
// приведение, снятое в точке употребления, функцию не ломает, а хвост имени
// уходит на страницу метрик, которая отдаётся без проверки отправителя.
func metricLabelValues(t *testing.T, family string) []string {
families, err := prometheus.DefaultGatherer.Gather()
require.NoError(t, err)
var values []string
for _, mf := range families {
if mf.GetName() != family {
continue
}
for _, m := range mf.GetMetric() {
for _, label := range m.GetLabel() {
values = append(values, label.GetValue())
}
}
}
return values
}
func TestCreateTranscribeJob_MetricLabelCarriesNoSenderName(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
// Хвост после последней точки — это тоже кусок имени, данного отправителем.
req := createMultipartRequest(t, "sample."+senderNameMarker, []byte("запись"))
w := httptest.NewRecorder()
env.router.ServeHTTP(w, req)
require.Equal(t, http.StatusCreated, w.Code)
values := metricLabelValues(t, "transcriber_input_file_size_bytes")
require.NotEmpty(t, values, "метрика размера принятой записи заполняется приёмом")
assert.NotContains(t, values, "."+senderNameMarker,
"метка метрики не несёт хвоста имени, данного отправителем")
assert.NotContains(t, values, senderNameMarker,
"метка метрики не несёт хвоста имени и без ведущей точки")
assert.Contains(t, values, "other",
"незнакомое расширение приведено к общему значению")
// А на диске расширение остаётся пришедшим: раскладка каталога записей
// объявлена необратимой, и приведение сюда не распространяется.
files := storedFiles(t, env)
require.Len(t, files, 1)
assert.Equal(t, "."+senderNameMarker, filepath.Ext(files[0]))
}
func TestGetTranscribeJobStatus_Success(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
+69
View File
@@ -0,0 +1,69 @@
package metrics
import (
"strconv"
"strings"
)
// OtherFormatLabel — значение метки для всего, чего нет в перечне known-форматов.
const OtherFormatLabel = "other"
// knownFormats — закрытый перечень расширений, которые допускаются меткой.
//
// Состав: пути, которые выдаёт Telegram (голосовое приходит как
// `voice/file_N.oga`, кружок — с `.mp4`), плюс форматы, доезжающие приёмом по
// HTTP, плюс собственное умолчание сервиса на случай имени без расширения.
// Списку, по которому бот отбирает **документы** (`isAudioDocument`), перечень
// намеренно не равен: тот судит по типу содержимого и своим списком пользуется
// лишь когда типа нет, а сюда попадает и то, что приходит другими путями.
// Сведение двух списков в один уронило бы основной вход сервиса в `other`.
var knownFormats = map[string]struct{}{
"mp3": {},
"wav": {},
"ogg": {},
"oga": {},
"opus": {},
"flac": {},
"m4a": {},
"aac": {},
"wma": {},
"mp4": {},
"mkv": {},
"mov": {},
"avi": {},
"webm": {},
"audio": {}, // умолчание сервиса, когда расширения в имени не было
}
// FormatLabel приводит расширение к виду, годному для метки метрики.
//
// Расширение приходит из имени, которое дал отправитель, и потому может быть
// чем угодно: имя `запись.тайное-слово` отдаёт `тайное-слово`. Страница метрик
// открыта, то есть метка — поверхность пошире журнала. Незнакомое значение
// заменяется одним общим: это закрывает и утечку куска имени, и рост числа
// временных рядов, которым иначе распоряжается анонимный отправитель.
//
// Имени файла на диске это не касается — там расширение остаётся пришедшим.
// ObserveInputFileSize записывает размер принятой записи. Расширение приводится
// здесь, а не у вызывающего: сырая точка употребления — это место, где хвост
// имени отправителя однажды снова уедет наружу, и проверка у вызывающего этого
// не заметит.
func ObserveInputFileSize(ext string, size int64) {
InputFileSizeHistogram.WithLabelValues(FormatLabel(ext)).Observe(float64(size))
}
// ObserveConversionDuration записывает длительность конвертации. Исходный формат
// приводится по той же причине, что и в приёме.
func ObserveConversionDuration(srcExt, targetFormat string, failed bool, seconds float64) {
ConversionDurationHistogram.
WithLabelValues(FormatLabel(srcExt), targetFormat, strconv.FormatBool(failed)).
Observe(seconds)
}
func FormatLabel(ext string) string {
normalized := strings.ToLower(strings.TrimPrefix(ext, "."))
if _, ok := knownFormats[normalized]; ok {
return normalized
}
return OtherFormatLabel
}
+117
View File
@@ -0,0 +1,117 @@
package metrics
import (
"strings"
"testing"
"github.com/prometheus/client_golang/prometheus"
)
// Метка метрики уезжает на страницу, которая отдаётся без проверки отправителя,
// поэтому судим здесь ровно одно: что наружу выходит только известное значение.
func TestFormatLabel(t *testing.T) {
testCases := []struct {
name string
ext string
want string
}{
{name: "известное расширение с точкой", ext: ".mp3", want: "mp3"},
{name: "известное расширение без точки", ext: "mp3", want: "mp3"},
{name: "регистр приводится", ext: ".MP3", want: "mp3"},
{name: "умолчание сервиса", ext: ".audio", want: "audio"},
// Голосовое из Telegram приходит путём вида `voice/file_N.oga`, кружок —
// с `.mp4`. Это основной вход сервиса: усечение перечня до списка, по
// которому бот отбирает документы, схлопнуло бы его в общее значение.
{name: "голосовое из Telegram", ext: ".oga", want: "oga"},
{name: "видеокружок из Telegram", ext: ".mp4", want: "mp4"},
{name: "opus", ext: ".opus", want: "opus"},
// Значение сверяется с литералом, а не с самой константой: сверка с
// константой утверждала бы тавтологию, а спека нормирует слово `other`
// дословно.
{name: "хвост имени отправителя", ext: ".тайное-слово", want: "other"},
{name: "часть даты в имени", ext: ".08", want: "other"},
{name: "пустое", ext: "", want: "other"},
{name: "одна точка", ext: ".", want: "other"},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
if got := FormatLabel(tc.ext); got != tc.want {
t.Errorf("FormatLabel(%q) = %q, ожидалось %q", tc.ext, got, tc.want)
}
})
}
}
// labelValues собирает значения меток названного семейства из общего реестра.
func labelValues(t *testing.T, family string) []string {
families, err := prometheus.DefaultGatherer.Gather()
if err != nil {
t.Fatalf("не удалось прочитать реестр метрик: %v", err)
}
var values []string
for _, mf := range families {
if mf.GetName() != family {
continue
}
for _, m := range mf.GetMetric() {
for _, label := range m.GetLabel() {
values = append(values, label.GetValue())
}
}
}
return values
}
// Метку конвертации приёмом по HTTP не достать: шаг живёт в воркере, и своего
// окружения у него нет. Поэтому обёртка судится здесь — по реестру, а не по
// чистой функции: сырое употребление гистограммы мимо обёртки и есть то место,
// где хвост имени отправителя однажды снова уедет наружу.
func TestObserveConversionDurationLabelsAreKnown(t *testing.T) {
const tail = "CONVLEAKMARKER5X8"
ObserveConversionDuration("."+tail, "ogg", false, 1)
values := labelValues(t, "transcriber_conversion_duration_seconds")
if len(values) == 0 {
t.Fatal("метрика длительности конвертации не заполнилась")
}
assertTailAbsentAndOtherPresent(t, values, tail)
}
// Та же проверка для метки приёма — на случай, если обёртку обойдут только с
// одной стороны.
func TestObserveInputFileSizeLabelsAreKnown(t *testing.T) {
const tail = "INPUTLEAKMARKER5X8"
ObserveInputFileSize("."+tail, 42)
values := labelValues(t, "transcriber_input_file_size_bytes")
if len(values) == 0 {
t.Fatal("метрика размера принятой записи не заполнилась")
}
assertTailAbsentAndOtherPresent(t, values, tail)
}
// assertTailAbsentAndOtherPresent судит значения метки по двум признакам сразу.
// Одного «хвоста нет» мало: приведение, ослабленное до смены регистра, хвост
// пропустило бы, а поиск заглавного маркера в строчном значении его не нашёл бы.
// Поэтому сравнение регистронезависимое, и рядом стоит второй признак — что
// незнакомое расширение вообще доехало до общего значения.
func assertTailAbsentAndOtherPresent(t *testing.T, values []string, tail string) {
t.Helper()
for _, v := range values {
if strings.Contains(strings.ToLower(v), strings.ToLower(tail)) {
t.Fatalf("метка несёт хвост имени, данного отправителем: %q", v)
}
}
for _, v := range values {
if v == "other" {
return
}
}
t.Fatal("незнакомое расширение не приведено к общему значению")
}
+19
View File
@@ -0,0 +1,19 @@
package service
import (
"testing"
"git.vakhrushev.me/av/transcriber/internal/metrics"
)
// Умолчание расширения живёт в этом пакете, а перечень значений метки — в
// пакете метрик, и связывает их только совпадение двух литералов. Компилятор
// расхождения не поймает: смена умолчания просто сложит все записи без
// расширения в общее значение, и метка перестанет отличать «расширения не было»
// от чужого хвоста в имени.
func TestDefaultAudioExtIsKnownToMetrics(t *testing.T) {
if got := metrics.FormatLabel(defaultAudioExt); got == metrics.OtherFormatLabel {
t.Fatalf("умолчание %q не входит в перечень известных форматов: метка отдаёт %q",
defaultAudioExt, got)
}
}
+4 -6
View File
@@ -7,7 +7,6 @@ import (
"log/slog"
"os"
"path/filepath"
"strconv"
"strings"
"time"
@@ -102,9 +101,10 @@ func (s *TranscribeService) createTranscribeJob(job *entity.TranscribeJob, file
storageFileName := fmt.Sprintf("%s%s", fileId, ext)
storageFilePath := filepath.Join(s.storagePath, storageFileName)
// Имя, данное отправителем, в журнал не идёт: инвариант приватности.
// Расширение из него уже стоит в собственном имени файла на диске.
s.logger.Info("Creating transcribe job",
"file_id", fileId,
"file_name", fileName,
"storage_path", storageFilePath)
// Создаем файл на диске
@@ -139,7 +139,7 @@ func (s *TranscribeService) createTranscribeJob(job *entity.TranscribeJob, file
"duration_seconds", info.Seconds)
metrics.InputFileDurationHistogram.WithLabelValues().Observe(float64(info.Seconds))
metrics.InputFileSizeHistogram.WithLabelValues(ext).Observe(float64(size))
metrics.ObserveInputFileSize(ext, size)
// Создаем запись в таблице files
fileRecord := &entity.File{
@@ -207,9 +207,7 @@ func (s *TranscribeService) FindAndRunConversionJob() error {
conversionDuration := time.Since(startTime)
// Записываем метрику времени конвертации
metrics.ConversionDurationHistogram.
WithLabelValues(srcExt, "ogg", strconv.FormatBool(err != nil)).
Observe(conversionDuration.Seconds())
metrics.ObserveConversionDuration(srcExt, "ogg", err != nil, conversionDuration.Seconds())
if err != nil {
s.logger.Error("File conversion failed",
@@ -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 отдавать состояние задачи расшифровки по запросу