# archive Specification ## Purpose TBD - created by archiving change app-json-contract. Update Purpose after archive. ## Requirements ### Requirement: Адреса приложения живут своим пространством Сервис SHALL вешать собственные адреса приложения под корнем `/app/` и MUST не занимать имён в пространстве `/api/`: последнее принадлежит хранилищу, оно вешает туда собственные наборы адресов, и поменять этот префикс нельзя — он литерал библиотеки, а не настройка. Свободных имён в чужом пространстве сегодня хватает, но соседство остаётся: обновление библиотеки вправе занять новое имя рядом с нашим, и разойдутся они молча — тем же адресом начнёт отвечать не тот обработчик. Цена переезда называется здесь же. Правило неизвестного пути, по которому приложение отдаётся вместо отказа, MUST перечислять **все** корни сервиса, а не один: путь внутри любого корня в приложение не проваливается никогда. Ограничитель частоты хранилища настроен на чужой корень и наших адресов больше не покрывает, поэтому сервис MUST заводить своё правило под корень приложения. Цена этого названа здесь же: ограничитель у хранилища один на всю его поверхность и выключен умолчанием, поэтому включение нашего правила вводит в действие и его собственные — на входе, на заведении записей и на его адресах. Принимается сознательно: без включения наше правило не значит ничего. Пространство `/api/settings` принадлежит хранилищу и остаётся ему: настройки человека живут под корнем приложения. #### Scenario: Адрес приложения отвечает под своим корнем - **GIVEN** человек вошёл и предъявил сессию - **WHEN** он спрашивает список своих записей под корнем приложения - **THEN** ответ приходит от сервиса, а не от хранилища #### Scenario: Прежние адреса приложения не отвечают - **GIVEN** заведена запись - **WHEN** её спрашивают прежними адресами в чужом пространстве - **THEN** ответ имеет код `404` #### Scenario: Ограничитель частоты покрывает адреса приложения - **WHEN** сервис поднялся - **THEN** настройки ограничителя несут правило, чей адрес начинается корнем приложения ### Requirement: Отказ называет причину, а не место Сервис SHALL отвечать на адресах приложения кодом, который отвечает **причине** отказа, а не месту, где он случился. Перечень закрыт и назван поимённо: - пришедший не узнан — `401`, и он MUST наступать **до всякого чтения записи**, одинаково для заведённой записи и для неизвестного идентификатора: иначе по разнице кодов перебирается список заведённых записей; - узнанный предъявитель без учётной записи пользователя — `403`; - неизвестный идентификатор — `404`, и **тем же кодом с тем же телом** MUST отвечать чужая и ничья запись; - негодный ввод — `400`: нечитаемая запись, неизвестное значение параметра, негодный размер страницы; - запись сверх потолка размера — `413`, и тело MUST нести предел числом; - состояние, в котором действие недоступно, — `409`: текста запрошенного вида у записи ещё нет; - отказ хранилища и всякая неназванная причина — `500`. Отображение доменной ошибки в код и сообщение MUST жить **одним местом** на все адреса, и у него MUST быть определённая ветвь по умолчанию. Сегодня такого места нет вовсе, и каждый обработчик решает сам: опрос отвечает «записи нет» на упавшую базу, а приём — «внутренняя ошибка» на негодный файл. Человек читает первое как «моя запись пропала», а второе не говорит ему ничего. Тело отказа MUST быть одной формы на всех адресах приложения и MUST нести **два** поля: машиночитаемый код отказа из закрытого перечня и сообщение, пригодное человеку, на русском языке. Одного сообщения мало: кода HTTP не хватает, чтобы различить «файл негоден», «поля записи нет» и «неизвестное значение параметра» — все три `400`, — а приложению надо решать, предлагать ли повтор и что показать человеку. Разбор русской фразы был бы единственным оставшимся путём, и первая же задача экрана переписала бы контракт, согласованный здесь один раз. Имена полей и перечень кодов нормативны — их разбирает каждый экран, и выбранные кодом они стали бы контрактом молча: - поля тела: `error_code` и `message`; - перечень `error_code`: `unauthorized`, `forbidden`, `not_found`, `bad_request`, `too_large`, `too_many_requests`, `not_ready`, `internal`. Часть отказов рождается **не в обработчике** — предел тела, ограничитель частоты, неизвестный путь под корнем приложения, — и до отображения доменной ошибки не доходит вовсе. Такие отказы MUST приводиться к той же форме: иначе форм на адресах приложения две, а самый частый отказ у человека на мобильной сети — «запись больше потолка» — приходит телом библиотеки, без кода и без предела числом. Перечень закрыт и объявляется **одним местом**. Новая штатная ветвь отказа заводится добавлением в него, а не строкой в обработчике: иначе ветвь по умолчанию отдаст `internal` на обычный конфликт, и владелец сервиса увидит в журнале аварию там, где её нет. Сырой текст ошибки MUST в тело не попадать — ни `err.Error()`, ни детали устройства: имена внешних сервисов, пути на диске, ключи файлов. Полная ошибка остаётся в журнале владельца сервиса. #### Scenario: Сбой хранилища виден как сбой - **GIVEN** хранилище отвечает отказом драйвера на чтение записи - **WHEN** владелец спрашивает свою запись - **THEN** ответ имеет код `500` - **AND** тела записи в ответе нет #### Scenario: Негодная запись видна как негодная - **GIVEN** источник метаданных не может прочитать присланную запись - **WHEN** отправитель шлёт её приёмом - **THEN** ответ имеет код `400` и несёт сообщение, пригодное человеку - **AND** причина отказа в тело ответа не попадает #### Scenario: Отказ по пустому владельцу - **GIVEN** предъявитель узнан, но учётной записи пользователя у него нет - **WHEN** он шлёт запись приёмом - **THEN** ответ имеет код `403` #### 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`