## 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 нечем. Имени, данного человеком, оттуда сегодня и не приходит.

**Запрет держится проверкой, а не линтером** → следующая журнальная строка с
именем отправителя в другом месте кода проверкой не поймается. Смягчение в
границах задачи: проверка стоит ровно на том шаге, через который проходит всякая
принятая запись. Правило линтера — кандидат в отдельную задачу.
