Files
transcriber/openspec/specs/archive/spec.md
T
av c9b7765646 хранилище переехало с PocketBase на SQLite со своим каталогом файлов
- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose
  под файловым замком, одна миграция начальной схемы вместо семи прежних
- транспорт переписан на net/http: свои слои, свой ограничитель частоты,
  отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли
- по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета
  читается справа налево, узнавание известного идёт читающим пулом
2026-08-23 08:06:04 +03:00

672 lines
53 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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** ответа из нескольких частей сервис не отдаёт