## MODIFIED Requirements ### Requirement: Приём записи по HTTP Сервис SHALL принимать запись от внешней программы запросом `POST /api/audio` с телом `multipart/form-data` и полем `audio` **только от узнанного отправителя**. Запрос без сессии MUST получать код `401`, и по нему MUST не заводиться ни файл, ни аудиозапись. Принятая запись от узнанного отправителя MUST быть сохранена и получить заведённую под неё аудиозапись на рубеже `uploaded`; ответ MUST нести идентификатор записи полем `job_id` и её рубеж полем `status`. Значение рубежа в ответе изменилось: прежде приём отдавал `created`. Перечень состояний назван проектом необратимым, и ломка объявлена прямо — состояние теперь называет достигнутое, а не предстоящее, и `created` в новом перечне нет вовсе. Имена полей ответа нормативны и MUST остаться прежними: контракт HTTP API объявлен проектом необратимым, и переименование поля ломает внешнюю программу молча. Меняются значения поля рубежа, а не его имя. Отказ по отсутствию сессии наступает **раньше** чтения тела: запись, за которую не заплатит узнанный отправитель, не должна попасть даже в память. Приём не судит о годности записи сам: расширение он берёт из имени файла, а пригодность содержимого узнаёт у источника метаданных. Куда именно ложится принятая запись, приёму не принадлежит: раскладку выбирает хранилище, и нормирует её capability `storage`. Владельцем принятой записи приём SHALL назначать предъявителя сессии. Обязательность владельца при этом MUST держаться и схемой хранилища: колонка владельца пустого значения не принимает вовсе, и норму эту держит capability `storage`. Проверка в приёме от этого не лишняя — она отвечает отправителю понятным отказом до того, как запись попадёт в память, а схема отвечала бы отказом сохранения после укладки файла. Предъявитель, чья сессия не даёт учётной записи пользователя, MUST получать отказ `403` и MUST получать его **до чтения тела** — там же, где стоит отказ по отсутствию сессии. Сессия владельца панели — именно такой случай: узнан он всё же узнан, а записи в коллекции пользователей у него нет, и владельцем записи он стать не может. Код здесь другой, чем у запроса без сессии, и это не оплошность: `401` значит «предъяви себя», а предъявитель себя предъявил. Утечки по разнице кодов нет — оба ответа говорят о самом спрашивающем, а не о том, какие записи заведены. Отказ **после** укладки записи потребовал бы убрать уже сохранённый файл, а уборки файлов сервис не умеет вовсе: норма, обязывающая к недостижимому, не пишется. #### Scenario: Запись принята - **GIVEN** источник метаданных читает запись и отдаёт её длительность - **AND** отправитель предъявил сессию - **WHEN** программа шлёт `POST /api/audio` с полем `audio` - **THEN** ответ имеет код `201`, а в теле лежат непустой `job_id` и `status` со значением `uploaded` - **AND** содержимое записи целиком лежит в хранилище одним файлом - **AND** владельцем заведённой аудиозаписи стоит предъявитель сессии #### Scenario: Сессия не даёт учётной записи пользователя - **GIVEN** предъявлена сессия владельца панели - **WHEN** он шлёт `POST /api/audio` с полем `audio` - **THEN** ответ имеет код `403` - **AND** ни файла, ни аудиозаписи не заводится #### Scenario: Сессии нет - **WHEN** программа шлёт `POST /api/audio` с полем `audio` без сессии - **THEN** ответ имеет код `401` - **AND** ни файла, ни аудиозаписи не заводится - **AND** тело ответа не несёт данных записи #### Scenario: Поля с записью нет - **GIVEN** отправитель предъявил сессию - **WHEN** программа шлёт `POST /api/audio` без поля `audio` - **THEN** ответ имеет код `400` и сообщение об отсутствии записи - **AND** ни файла, ни аудиозаписи не заводится #### Scenario: Размеру записи приём не судья - **GIVEN** источник метаданных читает запись и отдаёт её длительность - **AND** отправитель предъявил сессию - **WHEN** программа шлёт запись нулевой длины - **THEN** ответ имеет код `201`: собственного порога по размеру у приёма нет ### Requirement: Опрос готовности задачи Сервис SHALL отдавать рубеж аудиозаписи по запросу `GET /api/status/:id` **только её владельцу**. Запрос без сессии MUST получать код `401`, и тело такого ответа MUST не нести ни рубежа записи, ни текста расшифровки. Ответ владельцу MUST нести идентификатор полем `job_id`, рубеж полем `status` и время заведения полем `created_at`, а текст расшифровки полем `transcription_text`, и это поле MUST отсутствовать в ответе, пока текста нет: пустая строка на месте отсутствующего текста читается как «расшифровка пуста». Видов текста у записи больше одного, поэтому ответ MUST называть вид, который отдаёт: в поле `transcription_text` уходит **сырая расшифровка**, и только она. Вычитанный текст этим полем MUST не подменяться — иначе значение поля менялось бы у одной и той же записи от того, успел ли отработать необязательный шаг, а контракт объявлен необратимым. Отдача «последнего записанного» текста MUST не применяться: она делает ответ функцией порядка записи, а не состояния записи. Перечень значений поля `status` MUST совпадать с перечнем рубежей конвейера: `uploaded`, `normalized`, `submitted`, `transcribed`, `done`. Прежних значений `created`, `converted`, `transcribe`, `failed` и `dead` в ответе MUST не быть. Это объявленная ломка публичного контракта: рубеж называет достигнутое, а отказ перестал быть состоянием. Остановленная запись MUST отдавать рубеж, на котором она остановлена, и MUST нести признак остановки отдельным полем `halted` со значением истины. Машинный текст отказа MUST в ответ не попадать: он принадлежит журналу владельца сервиса, а не отправителю. Этот адрес — **единственное** место, где отправитель узнаёт о неудаче: доставки ответа отправителю у сервиса больше нет, и признак остановки здесь несёт всю обязанность целиком. Отказ без сессии MUST не зависеть от того, есть такая запись или нет: иначе по кодам ответа перебирается список заведённых записей. Запись, принадлежащая другому, MUST отвечать тем же, чем отвечает неизвестный идентификатор, — кодом `404` и тем же телом. #### Scenario: Запись найдена - **GIVEN** отправитель предъявил сессию - **WHEN** он спрашивает рубеж своей записи - **THEN** ответ имеет код `200` и несёт `job_id`, `status` и `created_at` - **AND** значение `status` принадлежит перечню рубежей конвейера #### Scenario: Запись остановлена - **GIVEN** запись остановлена признаком на рубеже приведения - **WHEN** владелец спрашивает её рубеж - **THEN** поле `status` несёт рубеж приведения - **AND** поле `halted` несёт истину - **AND** машинного текста отказа в ответе нет #### Scenario: Сессии нет - **WHEN** программа спрашивает рубеж заведённой записи без сессии - **THEN** ответ имеет код `401` - **AND** тело ответа не несёт ни рубежа записи, ни текста расшифровки #### Scenario: Без сессии неизвестная запись неотличима от заведённой - **WHEN** программа без сессии спрашивает рубеж заведённой записи, а затем рубеж по неизвестному идентификатору - **THEN** оба ответа имеют код `401` #### Scenario: Чужая запись неотличима от неизвестной - **GIVEN** запись заведена одним вошедшим - **WHEN** её рубеж спрашивает другой вошедший - **THEN** ответ имеет код `404` и то же тело, что и ответ по неизвестному идентификатору - **AND** тело ответа не несёт ни рубежа записи, ни текста расшифровки #### Scenario: Расшифровки ещё нет - **GIVEN** отправитель предъявил сессию - **WHEN** он спрашивает рубеж своей записи, которая ещё не дошла до текста - **THEN** поля `transcription_text` в ответе нет вовсе #### Scenario: Записи с таким идентификатором нет - **GIVEN** отправитель предъявил сессию - **WHEN** программа спрашивает рубеж по неизвестному идентификатору - **THEN** ответ имеет код `404` и сообщение о ненайденной записи ### Requirement: Имя файла, данное отправителем, не попадает в журнал Приём SHALL не писать имя файла, данное отправителем, ни в одну свою журнальную запись — ни на успешном пути, ни на пути отказа, где имя могло бы приехать текстом ошибки. Имя приходит извне вместе с записью и принадлежит содержимому личной переписки наравне с текстом расшифровки; журнал уезжает в собранные логи, откуда строку не убрать. Расширение, взятое из этого имени, в журнале остаётся собственным полем: по нему прослеживается путь записи. Что именно попадает в журнал ради прослеживаемости, нормирует требование ниже; наружу расширение выходит только приведённым к известному виду — этому отдано отдельное требование. Оговорка про второй вход из требования ушла вместе с ним: имя, данное отправителем, доходит до сервиса единственным путём — приёмом по HTTP, — и сценарии судят именно его. #### Scenario: Имя записи не видно в журнале принятой записи - **GIVEN** источник метаданных читает запись и отдаёт её длительность - **WHEN** программа шлёт `POST /api/audio` с записью, чья основа имени несёт опознаваемую строку при обычном расширении `.mp3` - **THEN** ни одна журнальная запись приёма этой строки не содержит - **AND** расширение `.mp3` в журнале допустимо #### Scenario: Имя записи не видно в журнале при отказе приёма - **GIVEN** источник метаданных не может прочитать запись - **WHEN** программа шлёт `POST /api/audio` с записью, чья основа имени несёт опознаваемую строку - **THEN** ни одна журнальная запись приёма, включая запись об ошибке, этой строки не содержит ### Requirement: Поднятые входы видны наблюдателю Сервис SHALL отдавать признак поднятости по каждому входу приёма отдельной метрикой и MUST выставлять метку только тому входу, который у сервиса есть. Метки убранного входа в метриках MUST не быть вовсе: признак со значением нуля читался бы как «вход есть, но не поднялся», то есть как поломка, а вечная единица рядом с ним — как исправность того, чего нет. Проверяемое здесь одно — **набор меток**, и это честнее прежнего. Вход остался один, страница метрик отдаётся тем же сервером, что и приём, и значение нуля у единственной метки недостижимо: чтобы прочитать признак, надо дотянуться до входа, о котором он сообщает. Прежнее обоснование — «иначе потерянный вход не виден ничем» — было верно, пока входов было два; сегодня неподнятый вход виден неудачей чтения самих метрик. Различать поднятый и неподнятый вход признак MUST снова, как только входов у сервиса станет больше одного. #### Scenario: В метриках только оставшийся вход - **GIVEN** сервис поднялся - **WHEN** наблюдатель читает метрики - **THEN** признак поднятости несёт метку входа HTTP со значением единицы - **AND** метки убранного входа Telegram в метриках нет вовсе ## REMOVED Requirements ### Requirement: Признак включения решает, поднимается ли вход Telegram **Reason**: Вход Telegram убран из сервиса целиком, и решать о его подъёме стало нечего. Настройки входа — признак включения, ключ доступа и срок ожидания обновлений — уходят из конфигурации вместе с ним. **Migration**: Секцию `[telegram]` и ключ `server.users_while_list` — имя в коде именно такое, с опечаткой, и в боевом файле искать надо его — из файла настроек убрать руками. Оставленные ключи сервис пропускает молча — незнакомые ключи разбор настроек не судит, — и потому файл, забытый как есть, поднимет сервис без бота и без единого слова о том, что секция больше ничего не значит. Записи с источником Telegram, если они в базе есть, остаются нетронутыми и достаются своему владельцу; ответ в чат по ним не уходит. Возврат входа заводится новым изменением вместе со связью чата и учётной записи.