приём и чтение записей сведены к одному контракту приложения

- адреса приложения переехали в своё пространство `/app/`, опрос готовности
  убран целиком: рубеж и причину остановки владелец узнаёт карточкой записи,
  текст — отдельным адресом названного вида
- заведена единая точка отображения доменной ошибки и слой, приводящий к той же
  форме отказы библиотеки: тело несёт машиночитаемый код рядом с сообщением
- у записи появились имя файла отправителя, длительность и размер своими
  колонками, а у ленты владельца — свой индекс: без него страница сканировала
  весь архив сервиса
This commit is contained in:
av
2026-08-15 13:51:23 +03:00
parent 79ff12548f
commit 3a2da3004b
55 changed files with 5506 additions and 466 deletions
+56 -11
View File
@@ -149,15 +149,19 @@ MUST не заводить: учётные записи держит прова
заголовка нет**: предъявленный заголовок побеждает, иначе браузер с сессионной
кукой получал бы не то, что предъявил на собственных адресах хранилища.
Область действия слоя MUST быть ограничена адресами приложения — приёмом записи
и опросом готовности. Собственная поверхность хранилища под него не подпадает:
часть её защищена сегодня ровно тем, что браузер заголовка сам не шлёт, и
расширение слоя на всё сняло бы эту защиту молча.
Область действия слоя MUST быть ограничена **адресами приложения** — теми, что
живут под его собственным корнем. Собственная поверхность хранилища под него не
подпадает: часть её защищена сегодня ровно тем, что браузер заголовка сам не
шлёт, и расширение слоя на всё сняло бы эту защиту молча.
Область названа корнем, а не перечнем адресов: перечень рос бы с каждым новым
адресом приложения, и забытый в нём адрес остался бы без слоя молча — сессия,
предъявленная кукой, перестала бы на нём работать, а на соседнем работала бы.
#### Scenario: Кука открывает доступ
- **GIVEN** человек вошёл и получил куку сессии
- **WHEN** он шлёт запрос к API с этой кукой и без заголовка
- **WHEN** он шлёт запрос к адресу приложения с этой кукой и без заголовка
- **THEN** запрос проходит
#### Scenario: Кука защищена от чтения скриптом
@@ -170,6 +174,12 @@ MUST не заводить: учётные записи держит прова
- **WHEN** запрос несёт и куку сессии, и заголовок `Authorization`
- **THEN** проверку проходит значение заголовка, а не куки
#### Scenario: Слой не расширяется на поверхность хранилища
- **GIVEN** человек вошёл и получил куку сессии
- **WHEN** он шлёт запрос к собственному адресу хранилища с одной лишь кукой
- **THEN** значение куки в заголовок не перекладывается
### Requirement: Значение, дающее доступ, не печатается
Сервис SHALL не писать в журнал, в ответ и в метку метрики ни значение сессии,
@@ -362,10 +372,11 @@ MUST не делать. Кто допущен, определяет правил
имя.
Обращение к чужой записи MUST быть неотличимо от обращения к несуществующей.
Отдельный отказ «доступ запрещён» превращает опрос в перебор — по разнице
Отдельный отказ «доступ запрещён» превращает чтение в перебор — по разнице
ответов считывается, какие записи заведены, а идентификатор записи и есть то,
что разграничение прячет. Каким именно ответом это выражено, нормирует
capability `intake`: там живёт адрес опроса, и держатель нормы обязан быть один.
capability `archive`: там живут адреса чтения записи, и держатель нормы обязан
быть один.
Пустой владелец MUST не совпадать ни с одной записью. Правило записано со стороны
**спрашивающего** и остаётся в силе, хотя записей без владельца в хранилище
@@ -377,13 +388,13 @@ capability `intake`: там живёт адрес опроса, и держат
#### Scenario: Своя запись доступна
- **GIVEN** человек вошёл и принял запись
- **WHEN** он спрашивает состояние этой записи своей сессией
- **THEN** ответ несёт состояние записи
- **WHEN** он спрашивает карточку этой записи своей сессией
- **THEN** ответ несёт данные записи
#### Scenario: Чужая запись неотличима от несуществующей
- **GIVEN** запись принята одним вошедшим
- **WHEN** её состояние спрашивает другой вошедший
- **WHEN** её карточку спрашивает другой вошедший
- **THEN** ответ тот же, что и на неизвестный идентификатор, — и кодом, и телом
#### Scenario: Владельца не задают запросом
@@ -399,5 +410,39 @@ capability `intake`: там живёт адрес опроса, и держат
#### Scenario: Пустой владелец не открывает ничего
- **GIVEN** заведены две записи: своя и чужая
- **WHEN** состояние каждой спрашивают с пустым владельцем
- **WHEN** карточку каждой спрашивают с пустым владельцем
- **THEN** ответ на обе тот же, что и на неизвестный идентификатор
### Requirement: Приложение узнаёт вошедшего
Сервис SHALL отдавать приложению сведения о том, кто вошёл, — `GET /app/me` — и
MUST отвечать отказом `401`, когда сессии нет. Своей страницы со скриптом,
которой сервер отрисовал бы имя вошедшего, у сервиса нет: приложение собирает
разметку само и вошедшего узнаёт ответом.
Кука сессии недоступна скриптам страницы, и прочитать из неё имя приложение не
может вовсе — этот адрес единственный способ его узнать.
Ответ MUST нести идентификатор учётной записи и имя, пригодное к показу, полями
`id` и `name`. Адрес почты MUST в ответ не попадать: он приходит от провайдера и
принадлежит человеку, а не сервису, и правило о непечатаемых значениях запрещает
ему выходить наружу наравне с журналом.
#### Scenario: Вошедший узнан
- **GIVEN** человек вошёл и получил куку сессии
- **WHEN** приложение спрашивает, кто вошёл
- **THEN** ответ несёт идентификатор его учётной записи
#### Scenario: Сессии нет
- **WHEN** приложение спрашивает, кто вошёл, без сессии
- **THEN** ответ имеет код `401`
- **AND** тело ответа не несёт учётной записи
#### Scenario: Адреса почты в ответе нет
- **GIVEN** человек вошёл, и у его учётной записи есть адрес почты
- **WHEN** приложение спрашивает, кто вошёл
- **THEN** адреса почты в ответе нет
+470
View File
@@ -0,0 +1,470 @@
# 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`
+104 -113
View File
@@ -12,21 +12,43 @@
## Requirements
### Requirement: Приём записи по HTTP
Сервис SHALL принимать запись от внешней программы запросом `POST /api/audio` с
телом `multipart/form-data` и полем `audio` **только от узнанного отправителя**.
Сервис SHALL принимать запись запросом `POST /app/audiorecords` с телом
`multipart/form-data` и полем `audio` **только от узнанного отправителя**.
Запрос без сессии MUST получать код `401`, и по нему MUST не заводиться ни файл,
ни аудиозапись. Принятая запись от узнанного отправителя MUST быть сохранена и
получить заведённую под неё аудиозапись на рубеже `uploaded`; ответ MUST нести
идентификатор записи полем `job_id` и её рубеж полем `status`.
получить заведённую под неё аудиозапись на рубеже `uploaded`.
Значение рубежа в ответе изменилось: прежде приём отдавал `created`. Перечень
состояний назван проектом необратимым, и ломка объявлена прямо — состояние
теперь называет достигнутое, а не предстоящее, и `created` в новом перечне нет
вовсе.
Приём стоит тем же адресом, что и список записей, и отличается от него только
методом: он **заводит аудиозапись**, а не кладёт файл. Прежнее имя называло
содержимое запроса, и по нему приём читался как отдельная от записи вещь — хотя
запись он и создаёт.
Имена полей ответа нормативны и MUST остаться прежними: контракт HTTP API
объявлен проектом необратимым, и переименование поля ломает внешнюю программу
молча. Меняются значения поля рубежа, а не его имя.
Ответ MUST нести **список** заведённых записей и место под признак повторного
файла у каждой, даже когда файл в запросе один. Форма согласована один раз и
вперёд: приём, отдающий одну запись, пришлось бы переписывать вместе с приёмом
нескольких файлов и с распознаванием повтора по содержимому, а экран загрузки —
переделывать под вторую форму. Число файлов в запросе при этом остаётся прежним:
меняется форма ответа, не число файлов.
Элемент списка MUST нести те же поля, что и карточка записи, плюс признак
повторного файла полем `duplicate`: две формы одной вещи разошлись бы молча.
Состав карточки нормирует capability `archive`.
Прежние имена полей ответа — `job_id` и `status` — MUST не употребляться: адрес
опроса убран целиком, и идентификатор записи зовётся `id`. Это объявленная ломка
публичного контракта: стадия проекта — стройка, на сервере данных нет, а внешней
программы на прежнем контракте не существует — своего токена у неё не было.
Значение рубежа в ответе MUST принадлежать перечню рубежей конвейера и MUST не
перечисляться этой нормой порознь: рубеж объявлен одним дескриптором, и
перечисленный здесь второй раз он разошёлся бы с ним молча. Рубеж называет
достигнутое, а не предстоящее, и `created` в перечне отсутствует вовсе.
Запись сверх потолка размера MUST отвергаться до заведения файла и аудиозаписи,
и код с телом такого отказа нормирует capability `archive` наравне с прочими
ветвями. Потолок применяется уже сегодня, а ответ на его срабатывание —
самый частый отказ у человека на мобильной сети — прежде не был нормирован
ничем и уходил телом ограничителя тела, мимо единой формы.
Отказ по отсутствию сессии наступает **раньше** чтения тела: запись, за которую
не заплатит узнанный отправитель, не должна попасть даже в память.
@@ -62,22 +84,23 @@
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **AND** отправитель предъявил сессию
- **WHEN** программа шлёт `POST /api/audio` с полем `audio`
- **THEN** ответ имеет код `201`, а в теле лежат непустой `job_id` и `status`
со значением `uploaded`
- **WHEN** программа шлёт `POST /app/audiorecords` с полем `audio`
- **THEN** ответ имеет код `201`, а в теле лежит список из одного элемента
- **AND** элемент несёт непустой `id`, поле `state` со значением `uploaded` и
место под признак повторного файла
- **AND** содержимое записи целиком лежит в хранилище одним файлом
- **AND** владельцем заведённой аудиозаписи стоит предъявитель сессии
#### Scenario: Сессия не даёт учётной записи пользователя
- **GIVEN** предъявлена сессия владельца панели
- **WHEN** он шлёт `POST /api/audio` с полем `audio`
- **WHEN** он шлёт `POST /app/audiorecords` с полем `audio`
- **THEN** ответ имеет код `403`
- **AND** ни файла, ни аудиозаписи не заводится
#### Scenario: Сессии нет
- **WHEN** программа шлёт `POST /api/audio` с полем `audio` без сессии
- **WHEN** программа шлёт `POST /app/audiorecords` с полем `audio` без сессии
- **THEN** ответ имеет код `401`
- **AND** ни файла, ни аудиозаписи не заводится
- **AND** тело ответа не несёт данных записи
@@ -85,7 +108,7 @@
#### Scenario: Поля с записью нет
- **GIVEN** отправитель предъявил сессию
- **WHEN** программа шлёт `POST /api/audio` без поля `audio`
- **WHEN** программа шлёт `POST /app/audiorecords` без поля `audio`
- **THEN** ответ имеет код `400` и сообщение об отсутствии записи
- **AND** ни файла, ни аудиозаписи не заводится
@@ -100,8 +123,13 @@
Сервис SHALL сохранять принятую запись под собственным именем — идентификатором,
к которому приписано расширение из имени файла отправителя. Имя, данное
отправителем, MUST не попадать в хранилище: оно приходит извне и содержимым
своим приёму не подконтрольно.
отправителем, MUST не попадать в **имя файла** в хранилище: оно приходит извне и
содержимым своим приёму не подконтрольно.
Норма сужена: имя отправителя доходит теперь до самой аудиозаписи собственной
колонкой — по нему человек узнаёт свою запись, — но не до имени файла и не до
журнала. Что с ним делает приём, нормирует требование «Имя файла отправителя
подписывает запись».
Расширения в присланном имени нет — сервис MUST подставить `.audio`, чтобы у
файла в хранилище расширение было всегда.
@@ -130,15 +158,20 @@
### Requirement: Отказ чтения метаданных
Сервис SHALL отвечать отказом, когда источник метаданных не смог прочитать
принятую запись. Ответ MUST иметь код `500`, а причина отказа MUST не попадать в
тело ответа: она принадлежит журналу, а не отправителю.
принятую запись. Ответ MUST иметь код `400`: причина отказа — присланная запись,
а не сбой сервиса, и код, называющий место отказа вместо его причины, не говорит
отправителю ничего. Сама причина MUST не попадать в тело ответа: она принадлежит
журналу, а не отправителю.
Отображение этой ошибки в код и сообщение живёт одним местом на все адреса
приложения; норму держит capability `archive`.
#### Scenario: Источник метаданных вернул ошибку
- **GIVEN** источник метаданных не может прочитать запись
- **WHEN** программа шлёт `POST /api/audio` с этой записью
- **THEN** ответ имеет код `500`
- **AND** задача расшифровки не заводится
- **WHEN** программа шлёт `POST /app/audiorecords` с этой записью
- **THEN** ответ имеет код `400` и несёт сообщение, пригодное человеку
- **AND** аудиозаписи не заводится
### Requirement: Имя файла, данное отправителем, не попадает в журнал
@@ -148,6 +181,10 @@
личной переписки наравне с текстом расшифровки; журнал уезжает в собранные логи,
откуда строку не убрать.
Запрет держится, хотя имя доходит теперь до самой записи: колонку записи видит
один её владелец, а журнал — владелец сервиса и всякий, кому достались собранные
логи.
Расширение, взятое из этого имени, в журнале остаётся собственным полем: по нему
прослеживается путь записи. Что именно попадает в журнал ради прослеживаемости,
нормирует требование ниже; наружу расширение выходит только приведённым к
@@ -160,16 +197,16 @@
#### Scenario: Имя записи не видно в журнале принятой записи
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **WHEN** программа шлёт `POST /api/audio` с записью, чья основа имени несёт
опознаваемую строку при обычном расширении `.mp3`
- **WHEN** программа шлёт `POST /app/audiorecords` с записью, чья основа имени
несёт опознаваемую строку при обычном расширении `.mp3`
- **THEN** ни одна журнальная запись приёма этой строки не содержит
- **AND** расширение `.mp3` в журнале допустимо
#### Scenario: Имя записи не видно в журнале при отказе приёма
- **GIVEN** источник метаданных не может прочитать запись
- **WHEN** программа шлёт `POST /api/audio` с записью, чья основа имени несёт
опознаваемую строку
- **WHEN** программа шлёт `POST /app/audiorecords` с записью, чья основа имени
несёт опознаваемую строку
- **THEN** ни одна журнальная запись приёма, включая запись об ошибке, этой
строки не содержит
@@ -187,14 +224,14 @@
#### Scenario: Идентификатор, расширение и размер на месте
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **WHEN** программа шлёт `POST /api/audio` с записью
- **WHEN** программа шлёт `POST /app/audiorecords` с записью
- **THEN** журнал приёма несёт идентификатор заведённого файла, расширение
принятой записи и её размер в байтах
#### Scenario: Имени файла в хранилище в журнале нет
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **WHEN** программа шлёт `POST /api/audio` с записью
- **WHEN** программа шлёт `POST /app/audiorecords` с записью
- **THEN** имени, под которым файл лёг в хранилище, в журнале приёма нет
### Requirement: Метка метрики несёт только известное расширение
@@ -236,89 +273,6 @@
- **WHEN** программа шлёт запись с именем `sample.MP3`
- **THEN** метка метрики принимает значение `mp3`
### Requirement: Опрос готовности задачи
Сервис SHALL отдавать рубеж аудиозаписи по запросу `GET /api/status/:id`
**только её владельцу**. Запрос без сессии MUST получать код `401`, и тело
такого ответа MUST не нести ни рубежа записи, ни текста расшифровки. Ответ
владельцу MUST нести идентификатор полем `job_id`, рубеж полем `status` и время
заведения полем `created_at`, а текст расшифровки полем `transcription_text`, и
это поле MUST отсутствовать в ответе, пока текста нет: пустая строка на месте
отсутствующего текста читается как «расшифровка пуста».
Видов текста у записи больше одного, поэтому ответ MUST называть вид, который
отдаёт: в поле `transcription_text` уходит **сырая расшифровка**, и только она.
Вычитанный текст этим полем MUST не подменяться — иначе значение поля менялось бы
у одной и той же записи от того, успел ли отработать необязательный шаг, а
контракт объявлен необратимым. Отдача «последнего записанного» текста MUST не
применяться: она делает ответ функцией порядка записи, а не состояния записи.
Перечень значений поля `status` MUST совпадать с перечнем рубежей конвейера:
`uploaded`, `normalized`, `submitted`, `transcribed`, `done`. Прежних значений
`created`, `converted`, `transcribe`, `failed` и `dead` в ответе MUST не быть.
Это объявленная ломка публичного контракта: рубеж называет достигнутое, а отказ
перестал быть состоянием.
Остановленная запись MUST отдавать рубеж, на котором она остановлена, и MUST
нести признак остановки отдельным полем `halted` со значением истины. Машинный
текст отказа MUST в ответ не попадать: он принадлежит журналу владельца сервиса,
а не отправителю. Этот адрес — **единственное** место, где отправитель узнаёт о
неудаче: доставки ответа отправителю у сервиса больше нет, и признак остановки
здесь несёт всю обязанность целиком.
Отказ без сессии MUST не зависеть от того, есть такая запись или нет: иначе по
кодам ответа перебирается список заведённых записей.
Запись, принадлежащая другому, MUST отвечать тем же, чем отвечает неизвестный
идентификатор, — кодом `404` и тем же телом.
#### Scenario: Запись найдена
- **GIVEN** отправитель предъявил сессию
- **WHEN** он спрашивает рубеж своей записи
- **THEN** ответ имеет код `200` и несёт `job_id`, `status` и `created_at`
- **AND** значение `status` принадлежит перечню рубежей конвейера
#### Scenario: Запись остановлена
- **GIVEN** запись остановлена признаком на рубеже приведения
- **WHEN** владелец спрашивает её рубеж
- **THEN** поле `status` несёт рубеж приведения
- **AND** поле `halted` несёт истину
- **AND** машинного текста отказа в ответе нет
#### Scenario: Сессии нет
- **WHEN** программа спрашивает рубеж заведённой записи без сессии
- **THEN** ответ имеет код `401`
- **AND** тело ответа не несёт ни рубежа записи, ни текста расшифровки
#### Scenario: Без сессии неизвестная запись неотличима от заведённой
- **WHEN** программа без сессии спрашивает рубеж заведённой записи, а затем
рубеж по неизвестному идентификатору
- **THEN** оба ответа имеют код `401`
#### Scenario: Чужая запись неотличима от неизвестной
- **GIVEN** запись заведена одним вошедшим
- **WHEN** её рубеж спрашивает другой вошедший
- **THEN** ответ имеет код `404` и то же тело, что и ответ по неизвестному
идентификатору
- **AND** тело ответа не несёт ни рубежа записи, ни текста расшифровки
#### Scenario: Расшифровки ещё нет
- **GIVEN** отправитель предъявил сессию
- **WHEN** он спрашивает рубеж своей записи, которая ещё не дошла до текста
- **THEN** поля `transcription_text` в ответе нет вовсе
#### Scenario: Записи с таким идентификатором нет
- **GIVEN** отправитель предъявил сессию
- **WHEN** программа спрашивает рубеж по неизвестному идентификатору
- **THEN** ответ имеет код `404` и сообщение о ненайденной записи
### Requirement: Поднятые входы видны наблюдателю
Сервис SHALL отдавать признак поднятости по каждому входу приёма отдельной
@@ -344,3 +298,40 @@
- **THEN** признак поднятости несёт метку входа HTTP со значением единицы
- **AND** метки убранного входа Telegram в метриках нет вовсе
### Requirement: Имя файла отправителя подписывает запись
Приём SHALL класть имя файла, данное отправителем, в собственную колонку
аудиозаписи и MUST не класть его в колонку заголовка. По имени файла человек
узнаёт свою запись до того, как у неё появится заголовок; заголовок же несёт
название, которое дал человек либо посчитала языковая модель, и одной колонкой на
оба смысла посчитанное название затирало бы то, по чему запись узнают, — а
вернуть затёртое было бы неоткуда.
Колонка заголовка у принятой записи MUST оставаться пустой: приём заголовков не
сочиняет.
Имя приходит извне и содержимым своим приёму не подконтрольно, поэтому приём
MUST ограничивать его длину и MUST убирать из него управляющие знаки прежде, чем
сохранить. Предел длины и перечень убираемого задаёт сервис, а не отправитель.
Приложение показывает заголовок, а имя файла подставляет, пока заголовка нет.
#### Scenario: Имя доходит до записи
- **GIVEN** отправитель предъявил сессию
- **WHEN** он шлёт запись с именем `разговор.mp3`
- **THEN** колонка имени файла у заведённой записи несёт `разговор.mp3`
#### Scenario: Заголовок принятой записи пуст
- **GIVEN** отправитель предъявил сессию
- **WHEN** он шлёт запись с именем `разговор.mp3`
- **THEN** колонка заголовка у заведённой записи пуста
#### Scenario: Длинное и грязное имя приходит обрезанным и очищенным
- **GIVEN** отправитель предъявил сессию
- **WHEN** он шлёт запись, чьё имя длиннее предела и несёт управляющие знаки
- **THEN** колонка имени файла несёт имя не длиннее предела
- **AND** управляющих знаков в нём нет
+18 -13
View File
@@ -568,8 +568,8 @@ MUST не быть привязаны к отдельному шагу: кажд
Запись, захваченная с числом отказов сверх заданного предела, MUST
останавливаться признаком тем, кто её захватил, и MUST не отдаваться шагу в
работу. Остановка эта видна отправителю опросом готовности наравне с прочими —
норму держит capability `intake`.
работу. Остановка эта видна владельцу записи **карточкой записи** наравне с
прочими — норму держит capability `archive`.
Этот сторож MUST отвечать только за повторы внутри шага. Время, проведённое
записью в рубеже, MUST мериться отдельным сторожем: одно число не справляется ни
@@ -583,7 +583,7 @@ MUST не быть привязаны к отдельному шагу: кажд
- **WHEN** запись проходит заданное число отказов
- **THEN** у неё появляется признак остановки
- **AND** следующий захват её не выдаёт
- **AND** опрос готовности отдаёт владельцу записи признак остановки
- **AND** карточка записи отдаёт владельцу признак остановки
#### Scenario: Шаг уносит процесс, не объявив отказа
@@ -608,22 +608,27 @@ MUST не быть привязаны к отдельному шагу: кажд
Шаг конвейера SHALL доводить запись до достигнутого рубежа и MUST не обращаться
к отправителю вовсе — ни с готовым текстом, ни с сообщением о неудаче. Исход
своей записи отправитель узнаёт опросом готовности и в панели владельца; адрес
опроса и содержимое ответа нормирует capability `intake`.
своей записи владелец узнаёт **карточкой записи** и в панели владельца сервиса;
адрес карточки и содержимое ответа нормирует capability `archive`.
Держатель нормы сменился вместе с убранным опросом готовности: прежде исход
отдавал адрес опроса, нормированный capability `intake`, и адреса этого больше
нет. Обязанность при этом не изменилась — изменилось только то, каким адресом
она исполняется.
Требование заведено взамен доставки в чат, убранной вместе с входом Telegram.
Без него молчание конвейера читалось бы как недоделка: прежде ответ уходил, и
всякий, кто помнит это, ищет в шаге отправку, а её отсутствие принимает за
потерянную ветку.
Инвариант проекта «Принятая запись не теряется молча» держится теперь опросом
готовности — там остановка видна признаком — и журналом владельца, где у неё
стоит причина. Обязанность при этом сменила направление: прежде об отказе
Инвариант проекта «Принятая запись не теряется молча» держится теперь карточкой
записи — там остановка видна признаком и причиной — и журналом владельца, где у
неё стоит причина. Обязанность при этом сменила направление: прежде об отказе
сообщали, теперь отказ доступен спросившему. Отправитель, который не
спрашивает, об остановке не узнаёт.
Записи, которой этот канал недоступен, не бывает: у каждой записи есть владелец,
и опрос отдаёт ему её исход. Держится это обязательностью владельца в схеме
и карточка отдаёт ему её исход. Держится это обязательностью владельца в схеме
хранилища — норму держит capability `storage`.
#### Scenario: Готовый текст отправителю не уходит
@@ -631,12 +636,12 @@ MUST не быть привязаны к отдельному шагу: кажд
- **GIVEN** запись дошла до конечного рубежа
- **WHEN** шаг конвейера её завершает
- **THEN** ни одного обращения наружу с текстом расшифровки не уходит
- **AND** текст достаётся опросом готовности
- **AND** текст достаётся отдельным адресом текста записи
#### Scenario: Остановка видна опросом, а не сообщением
#### Scenario: Остановка видна карточкой, а не сообщением
- **GIVEN** запись остановлена по исчерпании отказов
- **WHEN** владелец записи спрашивает её рубеж
- **THEN** ответ несёт достигнутый рубеж и признак остановки
- **WHEN** владелец записи спрашивает её карточку
- **THEN** ответ несёт достигнутый рубеж, признак остановки и её причину
- **AND** в журнале владельца сервиса есть запись об остановке с причиной
+53 -2
View File
@@ -370,6 +370,7 @@ MUST получать владельца своей записи. Иного и
- **WHEN** шаг заводит приведённую копию файла
- **THEN** владельцем копии стоит владелец записи
- **AND** шаг завершается без отказа
### Requirement: Учётная запись с записями не удаляется
Хранилище SHALL отвергать удаление учётной записи, у которой остались
@@ -441,12 +442,62 @@ MUST получать владельца своей записи. Иного и
вместе со списком, сотней штук разом. Расшифровка и вычитанный текст MUST лежать
отдельными строками: они читаются по открытию одной записи.
Тем же доводом запись MUST нести своими колонками **имя файла, данное
отправителем, длительность и размер**. Все три показываются в списке. Приём
узнаёт длительность и размер у источника метаданных и так, а имя файла приходит
вместе с записью.
**Имена колонок и единицы измерения нормативны:** `original_filename`,
`duration_ms` (миллисекунды) и `size_bytes` (байты). Единица стоит в самом имени,
а не в комментарии: шаг схемы применённым не переписывается, а расхождение
«секунды против миллисекунд» между колонкой, ответом списка и объявленным
пределом не увидит ни компилятор, ни гейт — оба конца числа. Миллисекунды выбраны
потому, что этой единицей уже названы соседние колонки схемы.
**Различать «неизвестно» и «ноль» эти колонки не обязаны, и это решение, а не
недосмотр.** Числовая колонка хранилища пустого значения не держит вовсе: пустое
кладётся нулём, и норма, требующая отличимости, потребовала бы либо четвёртой
колонки-признака, либо текстового типа у чисел. Платить за это нечем: обе
величины ставит приём, и ставит всегда — запись, метаданные которой прочитать не
удалось, отвергается отказом и не заводится вовсе. Ноль в этих колонках означает
ноль. Решение владельца 2026-08-15.
Имя файла на записи и заголовок MUST лежать **разными колонками**. Заголовок
несёт название, которое дал человек либо посчитала языковая модель; имя файла —
то, по чему человек узнаёт свою запись, пока заголовка нет. Одной колонкой на оба
смысла посчитанное название затирало бы имя, и вернуть затёртое было бы неоткуда.
Величины на записи и на её файле расходятся по смыслу, и **равенство между ними
не поддерживается никем — намеренно**. На записи лежит снимок **принятого**,
взятый приёмом один раз и больше не пересчитываемый; на файле — величины той
копии, которой файл является сейчас. Приведённая копия имеет свой размер, и
записи он не принадлежит.
Отсюда норма, без которой два числа читались бы как копии одного: величины
записи MUST не сверяться со строкой файла и MUST не переписываться ничем после
приёма. Расхождение между ними — не поломка, а разные вопросы: «что человек
прислал» и «что лежит сейчас». Уточнение длительности — перечитали метаданные,
сменили источник, нарезали длинную запись — меняет вторую величину и не трогает
первую.
#### Scenario: Список читается без содержимого
- **GIVEN** у записи есть расшифровка
- **WHEN** читают запись ради её рубежа и заголовка
- **THEN** текст расшифровки при этом не читается
#### Scenario: Длительность и размер читаются без строки файла
- **GIVEN** запись принята
- **WHEN** читают её длительность и размер
- **THEN** строка файла при этом не читается
#### Scenario: Посчитанный заголовок не затирает имя файла
- **GIVEN** запись принята с именем файла отправителя
- **WHEN** записи проставляют заголовок
- **THEN** имя файла остаётся прежним
### Requirement: Содержимое записи закрыто во всех коллекциях, где лежит
Всякая коллекция, куда переезжает содержимое аудиозаписи, SHALL быть закрыта
@@ -527,8 +578,8 @@ MUST быть помечено защищённым.
«какой текст отдавать человеку» стал бы вопросом порядка записи, а не состояния.
Потребитель текста MUST называть **вид**, который берёт, а не брать последний
записанный: иначе исход зависит от порядка записи. Ответ опроса готовности берёт
сырую расшифровку — норму держит capability `intake`.
записанный: иначе исход зависит от порядка записи. Адрес, которым текст уходит
приложению, называет вид запросом — норму держит capability `archive`.
#### Scenario: Расшифровка лежит своей строкой