## 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 нечем. Имени, данного человеком, оттуда сегодня и не приходит. **Запрет держится проверкой, а не линтером** → следующая журнальная строка с именем отправителя в другом месте кода проверкой не поймается. Смягчение в границах задачи: проверка стоит ровно на том шаге, через который проходит всякая принятая запись. Правило линтера — кандидат в отдельную задачу.