## ADDED Requirements

### 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** ответа из нескольких частей сервис не отдаёт

## MODIFIED 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** ни файла, ни аудиозаписи не заводится
