# archive Specification ## Purpose Что приложение спрашивает у сервиса и что получает в ответ: собственное адресное пространство под корнем `/app/`, единая форма отказа, объявленные пределы, страница своих записей, карточка записи, текст названного вида и файл записи названной копии. Приём записи нормирует `intake`, узнавание пришедшего — `access`, раздачу самого приложения — `webapp`, хранение записи и её файлов — `storage`. Сознательно не описаны: правка записи и её удаление — их приносят отдельные задачи, и до них у приложения нет ни одного адреса, меняющего чужую строку. ## Requirements ### Requirement: Адреса приложения живут своим пространством Сервис SHALL вешать собственные адреса приложения под корнем `/app/` и MUST не заводить второго адресного пространства рядом. Пространство `/api/`, прежде принадлежавшее встроенному хранилищу, и адрес панели `/_/` перестают существовать: сервис их не занимает и отвечает на них тем же, чем отвечает всякий неизвестный путь, — норму держит capability `webapp`. Корень приложения остаётся прежним, и это решение подтверждается, а не принимается заново: формы запросов и ответов приложения смена хранилища не трогает ни одним полем. Соседство, ради которого корень был выбран, кончилось вместе с соседом: чужого обновления, вправе занять новое имя рядом с нашим, больше нет. Ограничитель частоты под корнем приложения MUST быть своим: он считает бюджет по адресу спрашивающего и MUST не зависеть от настроек чужой поверхности. Прежде включение нашего правила вводило в действие и чужие правила на чужих адресах; платить за это больше нечем — чужих адресов нет. **Адрес спрашивающего ограничитель MUST брать из заголовка пересылки — и только тогда, когда соединение пришло с адреса из объявленного перечня доверенных.** Во всяком другом случае адресом MUST считаться адрес пира, а пришедший заголовок MUST не влиять на ключ бюджета ничем. **Цепочку пересылки ограничитель MUST читать справа налево, отбрасывая адреса из перечня доверенных, и брать первый недоверенный.** Читаются при этом **все** строки заголовка, а не первая: цепочка законно приходит несколькими строками. Значение, оставшееся слева, ключа бюджета MUST не задавать: прокси заголовок дописывает, а не заменяет, поэтому слева стоит то, что прислал спрашивающий, — и ключ, взятый оттуда, меняется у него на каждом запросе, то есть бюджет обходится с первого. Цепочка, где недоверенного адреса не нашлось вовсе, MUST падать обратно на адрес пира. Обе половины правила закрывают свою поломку. Сервис стоит за обратным прокси, и пир у него один на всех: бюджет, посчитанный по пиру, становится общим на весь сервис, и один спрашивающий исчерпывает его остальным. Обратная ошибка — верить заголовку без сверки пира — отдаёт обход ограничителя ровно тому, кого он ограничивает: значением пересылаемого заголовка распоряжается тот, кто шлёт запрос, и меняет он его на каждом запросе. **Узнавание и ограничитель берут адрес разными способами, и это намеренно.** Узнаванию нужен адрес самого соединения: им оно решает, верить ли заголовку вообще, и взятый из пересылаемого заголовка он сделал бы барьер подделываемым той же строкой, которой обходится, — норму держит capability `access`. Ограничителю нужен адрес того, кого он ограничивает, а тот за прокси в адресе соединения не виден вовсе. Вопросы разные — «кому верить» и «кого считать», — и один ответ на оба ломает либо барьер, либо бюджет. #### Scenario: Адрес приложения отвечает под своим корнем - **GIVEN** человек узнан - **WHEN** он спрашивает список своих записей под корнем приложения - **THEN** ответ приходит от сервиса #### Scenario: Пространства хранилища не существует - **GIVEN** сервис поднялся - **WHEN** запрос приходит на путь под прежним корнем хранилища - **THEN** ответ тот же, что и на всякий неизвестный путь вне корней сервиса #### Scenario: Адреса панели не существует - **GIVEN** сервис поднялся - **WHEN** запрос приходит на прежний адрес панели — и записанный знаком, и записанный его кодом - **THEN** ответ тот же, что и на всякий неизвестный путь вне корней сервиса #### Scenario: Ограничитель частоты покрывает адреса приложения - **GIVEN** сервис поднялся - **WHEN** запросы с одного адреса идут чаще бюджета под корнем приложения - **THEN** лишние получают отказ ограничителя #### Scenario: Два клиентских адреса через один прокси расходуют разные бюджеты - **GIVEN** запросы идут с доверенного адреса, и адрес пира у них один - **WHEN** два разных клиентских адреса шлют запросы под корнем приложения - **THEN** бюджет каждого считается отдельно - **AND** исчерпание бюджета одним не отказывает другому #### Scenario: Заголовок с недоверенного адреса на ключ бюджета не влияет - **GIVEN** запросы приходят с адреса вне перечня доверенных - **WHEN** они несут заголовок пересылки с разными значениями адреса - **THEN** бюджет у них общий и считается по адресу пира #### Scenario: Значение, приписанное спрашивающим, ключа бюджета не задаёт - **GIVEN** запросы приходят с доверенного адреса - **WHEN** они несут цепочку пересылки, где слева стоит меняющееся значение спрашивающего, а справа — адрес, приписанный прокси - **THEN** бюджет считается по правому значению - **AND** запросы чаще бюджета получают отказ ограничителя #### Scenario: Цепочка читается всеми строками заголовка - **GIVEN** запросы приходят с доверенного адреса - **WHEN** цепочка пересылки приходит несколькими строками заголовка - **THEN** ключ бюджета берётся из последней строки, а не из первой ### Requirement: Отказ называет причину, а не место Сервис SHALL отвечать на адресах приложения кодом, который отвечает **причине** отказа, а не месту, где он случился. Перечень закрыт и назван поимённо: - пришедший не узнан — `401`, и он MUST наступать **до всякого чтения записи**, одинаково для заведённой записи и для неизвестного идентификатора: иначе по разнице кодов перебирается список заведённых записей; - неизвестный идентификатор — `404`, и **тем же кодом с тем же телом** MUST отвечать чужая и ничья запись; - негодный ввод — `400`: нечитаемая запись, неизвестное значение параметра, негодный размер страницы, негодный диапазон в запросе файла; - запись сверх потолка размера — `413`, и тело MUST нести предел числом; - состояние, в котором действие недоступно, — `409`: текста или копии файла запрошенного вида у записи ещё нет; - отказ базы и всякая неназванная причина — `500`. Ветвь «узнанный предъявитель без учётной записи пользователя» из перечня ушла вместе со своим единственным случаем: им был владелец панели, предъявивший собственный токен хранилища. Ни панели, ни токенов у сервиса не осталось, а узнавание по заголовку учётную запись заводит само, и предъявителя без неё не бывает. Ветвь, у которой нет достижимого случая, не проверяется ничем и остаётся в коде мёртвой. Отображение доменной ошибки в код и сообщение MUST жить **одним местом** на все адреса, и у него MUST быть определённая ветвь по умолчанию. Тело отказа MUST быть одной формы на всех адресах приложения и MUST нести **два** поля: машиночитаемый код отказа из закрытого перечня и сообщение, пригодное человеку, на русском языке. Одного сообщения мало: кода HTTP не хватает, чтобы различить «файл негоден», «поля записи нет» и «неизвестное значение параметра» — все три `400`, — а приложению надо решать, предлагать ли повтор и что показать человеку. Разбор русской фразы был бы единственным оставшимся путём, и первая же задача экрана переписала бы контракт, согласованный здесь один раз. Имена полей и перечень кодов нормативны — их разбирает каждый экран, и выбранные кодом они стали бы контрактом молча: - поля тела: `error_code` и `message`; - перечень `error_code`: `unauthorized`, `not_found`, `bad_request`, `too_large`, `too_many_requests`, `not_ready`, `internal`. Часть отказов рождается **не в обработчике** — предел тела, ограничитель частоты, неизвестный путь под корнем приложения, — и до отображения доменной ошибки не доходит вовсе. Такие отказы MUST приводиться к той же форме: иначе форм на адресах приложения две, а самый частый отказ у человека на мобильной сети — «запись больше потолка» — приходит телом библиотеки, без кода и без предела числом. Перечень закрыт и объявляется **одним местом**. Новая штатная ветвь отказа заводится добавлением в него, а не строкой в обработчике: иначе ветвь по умолчанию отдаст `internal` на обычный конфликт, и владелец сервиса увидит в журнале аварию там, где её нет. Сырой текст ошибки MUST в тело не попадать — ни текст отказа драйвера, ни детали устройства: имена внешних сервисов, пути на диске, имена файлов. Полная ошибка остаётся в журнале владельца сервиса. #### Scenario: Сбой базы виден как сбой - **GIVEN** база отвечает отказом драйвера на чтение записи - **WHEN** владелец спрашивает свою запись - **THEN** ответ имеет код `500` - **AND** тела записи в ответе нет #### Scenario: Негодная запись видна как негодная - **GIVEN** источник метаданных не может прочитать присланную запись - **WHEN** отправитель шлёт её приёмом - **THEN** ответ имеет код `400` и несёт сообщение, пригодное человеку - **AND** причина отказа в тело ответа не попадает #### Scenario: Форма тела одна на всех ветвях отказа - **WHEN** сервис отказывает по ненайденной записи, по негодному вводу, по недоступному состоянию и по сбою базы - **THEN** тело каждого ответа несёт код отказа и сообщение одними и теми же полями - **AND** код отказа принадлежит закрытому перечню - **AND** ни одно из них не содержит сырого текста ошибки #### Scenario: Неузнанному неизвестная запись неотличима от заведённой - **GIVEN** заведена запись - **WHEN** её карточку спрашивают неузнанным, а затем спрашивают карточку по неизвестному идентификатору - **THEN** оба ответа имеют код `401` и одно тело #### Scenario: Запись сверх потолка размера - **GIVEN** отправитель узнан - **WHEN** он шлёт запись длиннее потолка размера - **THEN** ответ имеет код `413`, а тело несёт предел числом - **AND** ни файла, ни аудиозаписи не заводится ### Requirement: Сервис объявляет свои пределы Сервис SHALL отдавать свои пределы отдельным адресом — `GET /app/config` — и MUST называть в нём потолок размера одной записи, потолок размера страницы, частоту опроса карточки, перечень известных расширений и потолок числа тем у записи. Предел зовётся частотой опроса **карточки**, а не готовности: адрес опроса готовности это же изменение убирает целиком, и читатель через месяц искал бы то, чего нет. Имена полей ответа нормативны: `max_record_size_bytes`, `max_page_size`, `poll_interval_ms`, `known_extensions`, `max_topics_per_record`. **Каждый объявленный предел MUST быть тем же значением, которое сервис применяет, а не его копией.** Правило общее, а не про один потолок размера: приложение, знающее предел своей константой, расходится с сервером молча — до первого отказа на записи, которую человек уже успел отправить по мобильной сети. Ровно то же случается, когда предел объявлен сервером, но взят из второй константы рядом с применяемой. Отсюда источник у каждого: - потолок размера записи — то число, которым сервис ограничивает тело запроса приёма и отвергает запись кодом `413`; - потолок числа тем у записи — то число, которым его ограничивает схема хранилища; норму держит capability `storage`; - потолок размера страницы — то число, до которого сервис усекает запрошенный размер страницы; - частота опроса — выводится из **доли** бюджета ограничителя частоты под корнем приложения и MUST не задаваться своей константой. Доля, а не весь бюджет: опрос идёт не один — в ту же секунду приложение листает список, открывает соседнюю карточку и грузит новую запись, а бюджет один на все адреса приложения и считается по адресу спрашивающего, а не по учётной записи. Объявленная частота, равная всему бюджету, отдавала бы отказ на любом втором запросе — тот самый, которого объявление обещает избежать. Иначе приложение, честно опрашивающее карточку с объявленной частотой, упирается в собственный ограничитель сервиса — и получает отказ, которого сервис сам же ему и обещал избежать; - перечень известных расширений — тот же, что сужает метку метрики, за вычетом собственного умолчания сервиса `audio`: оно не формат, и подсказкой человеку выходить не должно. Второй перечень рядом с первым разошёлся бы с ним молча. **Перечень известных расширений — исключение в другом: сервис по нему не судит.** Приём о годности записи не судит сам — расширение он берёт из имени файла, а пригодность содержимого узнаёт у источника метаданных, — и перечень служит приложению подсказкой для диалога выбора файла, не более. Умолчать об этом нельзя: приложение прочитало бы перечень как «что можно загружать» и отвергало бы запись, которую сервис принял бы и расшифровал. Адрес MUST быть доступен тому же, кому доступны прочие адреса приложения: пределы не тайна, но отдельного открытого адреса ради них не заводится. #### Scenario: Потолок размера равен тому, которым сервис отвергает - **GIVEN** человек узнан - **WHEN** он спрашивает пределы сервиса - **THEN** потолок размера в ответе равен потолку, которым сервис ограничивает тело запроса приёма #### Scenario: Потолок страницы равен применяемому - **WHEN** человек спрашивает пределы сервиса, а затем просит страницу размером сверх объявленного потолка - **THEN** размер отданной страницы не превышает объявленного потолка ### Requirement: Страница своих записей Сервис SHALL отдавать владельцу страницу его записей — `GET /app/audiorecords` — новыми сверху, и MUST не показывать в ней ни одной чужой записи. Спрашивающий с пустым именем MUST не получать ни одной записи. Ответ MUST нести страницу, ключ следующей страницы и общее число записей. Число записей одного человека растёт годами — сервис объявлен архивом, — и ответ без страниц перестал бы помещаться в память телефона. **Страница задаётся ключом, а не номером.** Приём пишет в голову той же ленты, которую читает список, и человек, загрузивший запись и листающий свой архив, — штатный сценарий, а не редкость. Номер страницы сдвинул бы окно на единицу: последний элемент первой страницы пришёл бы вторым разом первым элементом второй, а один элемент между ними не пришёл бы никогда. Отказ молчаливый — ни кода, ни строки в журнале, — и человек видел бы архив, в котором записи нет. Ключ MUST быть непрозрачным для спрашивающего и MUST задавать положение **полным** ключом сортировки — парой «время заведения и идентификатор». Одного времени мало: у записей, принятых одним запросом, оно совпадает, и порядок между ними иначе не определён вовсе. Ключа следующей страницы нет — страница последняя; пустая страница MUST отвечать успехом, а не отказом: отсутствие записей не есть ошибка. Ключ, который сервис не может прочитать — протухший, обрезанный, подделанный, — MUST давать отказ по негодному вводу. Молчаливая отдача первой страницы вместо этого дала бы человеку архив, листающийся по кругу, и ни строки в журнале. **Сторона запроса нормируется наравне со стороной ответа.** У размера страницы MUST быть умолчание и потолок; размер сверх потолка MUST усекаться до него, а не отвергаться, а негодное значение — ноль, отрицательное, нечисловое — MUST давать отказ по негодному вводу. Незаданный потолок был бы способом попросить весь архив одним запросом, то есть обойти постраничность тем самым параметром, ради которого она заведена. Имена полей ответа и элемента нормативны: экраны строятся на них, и переименование после того, как экран написан, стоит правки приложения. - параметры запроса: `cursor`, `limit`, `filter`; - страница: `items`, `next_cursor`, `total_items`; - элемент: `id`, `title`, `original_filename`, `brief`, `topics`, `state`, `halted`, `halt_reason`, `duration_ms`, `size_bytes`, `created_at`. Значение `state` MUST принадлежать перечню рубежей конвейера, а `halt_reason` — перечню причин остановки. Оба перечня объявлены одним местом, и перечислять их порознь в потребителе нельзя: рубеж, добавленный конвейером, иначе разошёлся бы с ответом молча. Чтение страницы MUST не читать ни расшифровки, ни структуры реплик: обе лежат порознь от записи ровно затем, чтобы список их не тянул. Длительность и размер MUST браться колонками самой записи, а не строкой её файла. Машинный текст отказа MUST в элемент страницы не попадать: он принадлежит журналу владельца сервиса. Причина остановки — значение из закрытого перечня, и она не он. Значения причины остановки этим требованием впервые выходят в публичный ответ, и это осознанно: без причины признак остановки не говорит человеку, чего ждать — повтора, своего действия или ничего. Превращает значение в русскую фразу **приложение**, а не сервис: сервис отдаёт значение перечня, и второй словарь фраз на стороне сервера разошёлся бы с тем, что показывает экран. **Отбор MUST различать три состояния, а не два:** запись в работе (`working`), запись остановлена (`halted`), запись прошла конвейер (`done`). Незаданный отбор значит «все». Двух значений не хватает: остановленная запись не в работе и не завершена, и при отборе надвое она выпала бы из обеих половин — то есть исчезла бы из списка при любом значении отбора, хотя ради неё человек список и открывает. Предикат каждого состояния MUST выводиться из дескриптора рубежа и признака остановки, а не перечислять рубежи строкой запроса: рубеж, добавленный конвейером, иначе молча поменял бы состав всех трёх. #### Scenario: Страница отдаётся новыми сверху - **GIVEN** владелец завёл записей больше, чем помещается на страницу - **WHEN** он спрашивает первую страницу - **THEN** в ней лежит ровно столько записей, сколько вмещает страница - **AND** первой стоит заведённая последней - **AND** ответ несёт общее число его записей и ключ следующей страницы #### Scenario: Запись, заведённая между страницами, окна не сдвигает - **GIVEN** владелец прочитал первую страницу и взял ключ следующей - **WHEN** он заводит новую запись и спрашивает следующую страницу этим ключом - **THEN** ни один элемент первой страницы в ней не повторяется - **AND** ни одна запись между страницами не пропущена #### Scenario: Записи с одним временем заведения идут в устойчивом порядке - **GIVEN** две записи заведены одним запросом и время заведения у них совпадает - **WHEN** владелец читает страницу дважды - **THEN** порядок этих записей в обоих ответах один и тот же #### Scenario: Последняя страница - **WHEN** владелец дочитал архив до конца - **THEN** ответ имеет код `200`, а ключа следующей страницы в нём нет #### Scenario: Чужих записей в странице нет - **GIVEN** записи заведены двумя вошедшими - **WHEN** страницу спрашивает один из них - **THEN** в ней лежат только его записи #### Scenario: Список не тянет расшифровку - **GIVEN** у записи есть расшифровка - **WHEN** владелец спрашивает страницу своих записей - **THEN** текста расшифровки в ответе нет - **AND** чтение страницы строку текста не трогает #### Scenario: Размер страницы сверх потолка усекается - **WHEN** владелец просит страницу размером больше объявленного потолка - **THEN** ответ имеет код `200`, а размер страницы равен потолку #### Scenario: Негодный размер страницы отвергается - **WHEN** владелец просит страницу размером ноль либо нечисловым значением - **THEN** ответ имеет код `400` #### Scenario: Остановленная запись видна отбором - **GIVEN** у владельца есть запись в работе, остановленная запись и прошедшая конвейер - **WHEN** он спрашивает каждое из трёх состояний отбором - **THEN** каждая запись приходит ровно в одном из них - **AND** остановленная приходит с признаком остановки и её причиной ### Requirement: Карточка записи отдаётся без текста Сервис SHALL отдавать владельцу карточку одной записи — `GET /app/audiorecords/{id}` — и MUST не класть в неё текста расшифровки. **Карточка несёт те же поля, что и элемент страницы, плюс перечень доступных видов текста** — полем `available_views`. Две формы одной вещи разошлись бы молча, поэтому состав задан одной нормой, а не двумя. Отсюда обязанность, которой держится инвариант проекта «принятая запись не теряется молча»: карточка MUST нести рубеж, признак остановки и её причину. Прежде исход своей записи владелец узнавал опросом готовности; опрос убран, и единственным местом, где отправитель узнаёт о неудаче, становится карточка. Норма эта переехала сюда целиком — capability `pipeline` называет держателем её этот адрес. Вид считается доступным по **содержимому**, а не по наличию ссылки на текст. Ссылка без содержимого — состояние штатное: пустой ответ распознавания сервис признаёт нормой и записывает его в журнал. Строй мы перечень по ссылкам, карточка объявляла бы вид доступным, а адрес текста отвечал бы «ещё не готов» вечно — приложение опрашивало бы его без конца, а человек видел бы завершённую запись, из которой текст «вот-вот появится». Перечень MUST присутствовать в ответе **всегда**, в том числе пустым: отсутствие поля и пустой перечень приложение не различит, а значат они разное. Перечень доступных видов MUST быть **перечнем**, а не признаком «текст есть». Видов больше одного, и шаг завершения пишет их несколькими операциями: состояние «сплошной текст есть, реплик ещё нет» достижимо. Один признак на несколько видов отправил бы приложение за репликами, которых нет, — и исход стал бы функцией того, в каком месте прервался шаг, а не состояния записи. Пустой перечень значит «текста ещё нет». Шестичасовая расшифровка, приехавшая вместе с шапкой записи, задерживает показ на мобильной сети на то время, которое человеку не нужно ждать: шапку он читает сразу, а текст — если решил читать. Запись, принадлежащая другому, MUST отвечать тем же, чем отвечает неизвестный идентификатор. #### Scenario: Карточка без текста - **GIVEN** у записи есть расшифровка - **WHEN** владелец спрашивает её карточку - **THEN** поля с текстом в ответе нет - **AND** перечень доступных видов несёт сырую расшифровку #### Scenario: Остановленная запись видна карточкой - **GIVEN** запись остановлена признаком по исчерпании отказов - **WHEN** владелец спрашивает её карточку - **THEN** карточка несёт достигнутый рубеж, признак остановки и её причину - **AND** машинного текста отказа в ответе нет #### Scenario: Текста ещё нет - **GIVEN** запись не дошла до расшифровки - **WHEN** владелец спрашивает её карточку - **THEN** перечень доступных видов пуст #### Scenario: Чужая карточка неотличима от неизвестной - **GIVEN** запись заведена одним вошедшим - **WHEN** её карточку спрашивает другой вошедший - **THEN** ответ тот же, что и на неизвестный идентификатор, — и кодом, и телом ### Requirement: Текст записи отдаётся названным видом Сервис SHALL отдавать текст записи отдельным адресом — `GET /app/audiorecords/{id}/text` — и MUST отдавать **вид, названный спрашивающим**. Отдача «последнего записанного» сделала бы ответ функцией порядка записи, а не состояния записи. **Перечень видов закрыт, и каждое его значение называет ровно одну хранимую вещь:** - `transcript` — сырая расшифровка сплошным текстом; - `literary` — вычитанный текст сплошным; - `replicas` — реплики со временем. Перечень назван так, а не парой «вид текста плюс форма показа», потому что реплики со временем — не вид текста: они лежат структурой разбора и принадлежат записи, а не тексту. Пара из двух параметров обещала бы сочетания, которых не существует. Вычитанный текст назван здесь, хотя считает его отдельная задача: перечень, заведённый без него, пришлось бы расширять правкой публичного контракта — того самого, который согласуется здесь один раз. До появления вычитанного текста значение просто не встречается в перечне доступных видов у карточки. Значения `transcript` и `literary` MUST совпадать с видами текста, объявленными хранилищем: два словаря об одном разошлись бы молча. Текста запрошенного вида нет — сервис MUST отвечать кодом `409`, а не пустой строкой и не `404`. Пустая строка читается как «расшифровка пуста»; `404` слился бы с ответом на чужую и неизвестную запись, и человек увидел бы «не найдено» на своей записи, загруженной минуту назад, — ровно тот отказ, ради устранения которого заводится весь контракт. Вид, которого сервис не знает, и незаданный вид MUST давать отказ по негодному вводу: умолчание сделало бы ответ функцией того, что успел записать конвейер. Текст чужой записи MUST быть недоступен наравне с её карточкой. #### Scenario: Сырая расшифровка сплошным текстом - **GIVEN** у записи есть сырая расшифровка - **WHEN** владелец спрашивает её текст видом `transcript` - **THEN** ответ несёт содержимое сырой расшифровки #### Scenario: Реплики со временем - **GIVEN** у записи есть структура реплик - **WHEN** владелец спрашивает её текст видом `replicas` - **THEN** ответ несёт реплики, и у каждой стоит её время #### Scenario: Текста этого вида ещё нет - **GIVEN** у записи есть сырая расшифровка и нет структуры реплик - **WHEN** владелец спрашивает её текст видом `replicas` - **THEN** ответ имеет код `409` - **AND** он отличается от ответа на неизвестный идентификатор #### Scenario: Вид неизвестен или не назван - **WHEN** владелец спрашивает текст видом, которого сервис не знает, либо не называет вида вовсе - **THEN** ответ имеет код `400` ### Requirement: Файл записи отдаётся адресом приложения Сервис SHALL отдавать файл записи адресом под корнем приложения — `GET /app/audiorecords/{id}/file` — и MUST отдавать **копию, названную спрашивающим**. Отдача «какой-нибудь» копии сделала бы ответ функцией того, что успел записать конвейер, а не состояния записи. **Копию называет параметр запроса `copy`.** Имя параметра нормативно наравне со значениями: разбирает его каждый экран, и выбранное кодом оно стало бы публичным контрактом молча. **Перечень значений закрыт, и каждое называет ровно одну хранимую вещь:** - `original` — файл, принятый от отправителя; - `normalized` — копия, приведённая к рабочему формату. Копию, которой у записи ещё нет, сервис MUST отдавать отказом состояния — тем же кодом, каким отвечает ненаписанный текст: `409`. Пустой ответ читался бы как пустой файл, а `404` слился бы с ответом на чужую и неизвестную запись, и человек увидел бы «не найдено» на своей записи, загруженной минуту назад. Копия, которой сервис не знает, и незаданная копия MUST давать отказ по негодному вводу — но только у **своей** записи. Порядок проверок MUST быть один: владение записью судится **до** разбора значения копии. Неизвестное либо незаданное значение копии у чужой и у несуществующей записи MUST давать тот же ответ, что и неизвестный идентификатор, — и кодом, и телом. Неотличимость чужой записи от несуществующей главнее формы ответа на негодный ввод: разбор параметра, выполненный раньше, отвечал бы одинаково на чужую и на неизвестную только случайно, а стоило бы ответам разойтись — по этой разнице перебирался бы список заведённых записей одним негодным параметром. Ответ MUST нести длину файла и тип содержимого и MUST допускать выдачу по частям: запись расчётного потолка — шесть часов, и проигрыватель в браузере перематывает её запросом диапазона, а не повторной загрузкой целиком. **Негодный диапазон MUST приводиться к обычному отказу сервиса** — телом той же формы и кодом из закрытого перечня, — а не отвечать кодом `416` и телом библиотеки. Негодных диапазонов два вида, и оба ведут себя одинаково: неудовлетворимый (начало за концом файла) и множественный (в запросе назван больше чем один диапазон). Второй сервис не отдаёт намеренно: ответ из нескольких частей — это отдельный тип содержимого со своими границами, а просит его один только самодельный запрос, потому что проигрыватель в браузере шлёт один диапазон. Причина у требования общая с прочими отказами, рождающимися не в обработчике: форма тела на адресах приложения одна, и код отказа принадлежит закрытому перечню. Ответ `416` с телом библиотеки приходит без полей `error_code` и `message`, и приложение разбирает его отдельной веткой — единственной такой на все адреса. Файл чужой записи MUST быть недоступен наравне с её карточкой и отвечать тем же, чем неизвестный идентификатор. Кто владелец файла и почему право пройти по адресу даёт узнавание, а не выданное значение, нормирует capability `storage`. Имя файла на диске MUST в ответ не попадать: имя, предлагаемое браузеру при сохранении, строится из имени, данного отправителем, и лежит оно колонкой записи. **Перечня доступных копий карточка записи не объявляет** — до задачи об экране прослушивания его в ответе MUST не быть, и об отсутствующей копии спрашивающий узнаёт отказом состояния на самом обращении за файлом. Довод, которым перечень доступных видов текста объявляется карточкой всегда, к копиям файла не относится, и это разные случаи. Видов текста несколько, шаг завершения пишет их несколькими операциями, поэтому состояние «сплошной текст есть, реплик ещё нет» достижимо, а из состояния записи не выводится: приложение обязано узнать перечень, иначе пойдёт за текстом, которого нет. Копий же две, и каждая выводится из рубежа записи, который карточка несёт и так: принятая копия есть у всякой заведённой записи, приведённая — у всякой, прошедшей приведение. Второе поле повторяло бы рубеж и разошлось бы с ним молча. Перечень появится тогда, когда у него появится потребитель: экран прослушивания приносит задача `play-recording-in-app`. Объявлять его раньше — закреплять контракт, которого никто не разбирает. #### Scenario: Владелец забирает принятую копию - **GIVEN** запись принята - **WHEN** владелец просит её файл копией `original` - **THEN** ответ несёт содержимое принятого файла и его длину #### Scenario: Приведённой копии ещё нет - **GIVEN** запись принята и не дошла до приведения - **WHEN** владелец просит её файл копией `normalized` - **THEN** ответ имеет код `409` - **AND** он отличается от ответа на неизвестный идентификатор #### Scenario: Копия неизвестна или не названа - **WHEN** владелец просит файл копией, которой сервис не знает, либо не называет копии вовсе - **THEN** ответ имеет код `400` #### Scenario: Чужой файл неотличим от неизвестной записи - **GIVEN** запись принята одним узнанным - **WHEN** её файл просит другой узнанный - **THEN** ответ тот же, что и на неизвестный идентификатор, — и кодом, и телом #### Scenario: Негодная копия у чужой записи неотличима от неизвестной записи - **GIVEN** запись принята одним узнанным - **WHEN** другой узнанный просит её файл копией, которой сервис не знает - **THEN** ответ тот же, что и на неизвестный идентификатор, — и кодом, и телом - **AND** он не отличается от ответа на ту же просьбу к несуществующей записи #### Scenario: Перечня копий в карточке нет - **GIVEN** запись принята и приведена к рабочему формату - **WHEN** владелец спрашивает её карточку - **THEN** поля с перечнем доступных копий файла в ответе нет #### Scenario: Проигрыватель просит кусок записи - **GIVEN** запись принята - **WHEN** владелец просит её файл с указанием диапазона - **THEN** ответ несёт запрошенный кусок, а не файл целиком #### Scenario: Неудовлетворимый диапазон отвечает обычным отказом - **GIVEN** запись принята, и её файл короче запрошенного начала - **WHEN** владелец шлёт настоящий HTTP-запрос за файлом с заголовком `Range: bytes=99999999-` - **THEN** ответ имеет код `400`, а не `416` - **AND** тело несёт поля `error_code` и `message` #### Scenario: Двух диапазонов в одном запросе сервис не отдаёт - **GIVEN** запись принята - **WHEN** владелец шлёт настоящий HTTP-запрос за файлом с заголовком, называющим два диапазона - **THEN** ответ имеет код `400`, а не `416` - **AND** тело несёт поля `error_code` и `message` - **AND** ответа из нескольких частей сервис не отдаёт