хранилище переехало с PocketBase на SQLite со своим каталогом файлов
- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose под файловым замком, одна миграция начальной схемы вместо семи прежних - транспорт переписан на net/http: свои слои, свой ограничитель частоты, отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли - по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета читается справа налево, узнавание известного идёт читающим пулом
This commit is contained in:
@@ -0,0 +1,389 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Значение, дающее доступ, не печатается
|
||||
|
||||
Сервис SHALL не писать в журнал, в ответ и в метку метрики ни значение заголовка,
|
||||
которым назван пришедший, ни адрес почты пользователя.
|
||||
|
||||
Записанное значение MUST читаться как ключ к чужому доступу: заголовок целиком
|
||||
задаёт тот, кто шлёт запрос, и строка журнала уезжает в собранные логи, откуда её
|
||||
не убрать. Требование того же рода, что и запрет писать имя файла на диске: там
|
||||
строка журнала собирала бы путь к чужой записи, здесь — имя, которым довольно
|
||||
назваться, чтобы стать этим человеком.
|
||||
|
||||
Короткий токен файла из перечня ушёл вместе с самим токеном: значений на
|
||||
предъявителя сервис больше не выдаёт, и запрет остался бы правилом без предмета.
|
||||
|
||||
Адрес почты приходит от провайдера и принадлежит человеку, а не сервису. Имя из
|
||||
заголовка — тоже: это логин человека у провайдера.
|
||||
|
||||
Идентификатор учётной записи в журнал писать можно и нужно: он выдан сервисом,
|
||||
доступа сам по себе не даёт и без него путь запроса не прослеживается.
|
||||
|
||||
#### Scenario: Значения заголовка нет в журнале
|
||||
|
||||
- **WHEN** запрос с заголовком проходит через сервис
|
||||
- **THEN** значение заголовка не встречается ни в одной журнальной записи
|
||||
|
||||
#### Scenario: Адреса почты нет в журнале
|
||||
|
||||
- **WHEN** приходит первое обращение и учётная запись заводится
|
||||
- **THEN** адрес почты не встречается ни в одной журнальной записи
|
||||
|
||||
### Requirement: У записи есть владелец, и чужую ей не отдают
|
||||
|
||||
Сервис SHALL заводить у каждой принятой записи владельца — учётную запись, от
|
||||
имени которой запись принята, — и MUST отдавать данные такой записи только её
|
||||
владельцу. Запись без владельца MUST не заводиться ничем — ни приёмом, ни
|
||||
конвейером, ни запросом к базе: колонка владельца пустого значения не принимает,
|
||||
и норму эту держит capability `storage`.
|
||||
|
||||
Владелец назначается один раз, при приёме, и MUST не меняться: совместного
|
||||
доступа, ролей и передачи записи другому сервис не знает.
|
||||
|
||||
Владелец MUST браться из узнанного предъявителя и ниоткуда больше. Владелец,
|
||||
пришедший полем запроса, дал бы всякому узнанному право завести запись на чужое
|
||||
имя.
|
||||
|
||||
Обращение к чужой записи MUST быть неотличимо от обращения к несуществующей — и
|
||||
к её карточке, и к её тексту, и к её файлу. Отдельный отказ «доступ запрещён»
|
||||
превращает чтение в перебор: по разнице ответов считывается, какие записи
|
||||
заведены, а идентификатор записи и есть то, что разграничение прячет. Каким
|
||||
именно ответом это выражено, нормирует capability `archive`: там живут адреса
|
||||
чтения записи, и держатель нормы обязан быть один.
|
||||
|
||||
Пустой владелец MUST не совпадать ни с одной записью. Правило записано со стороны
|
||||
**спрашивающего** и остаётся в силе, хотя записей без владельца в базе не бывает:
|
||||
спрашивающий с пустым владельцем — это вызов, у которого нет учётной записи, и
|
||||
отвечать ему надо отказом, а не выборкой. Держится оно отдельно от схемы
|
||||
намеренно: схема запрещает **заводить** ничью запись, а это правило запрещает
|
||||
**спрашивать** ничьим именем, и одно другое не заменяет.
|
||||
|
||||
#### Scenario: Своя запись доступна
|
||||
|
||||
- **GIVEN** человек узнан и принял запись
|
||||
- **WHEN** он спрашивает карточку этой записи
|
||||
- **THEN** ответ несёт данные записи
|
||||
|
||||
#### Scenario: Чужая запись неотличима от несуществующей
|
||||
|
||||
- **GIVEN** запись принята одним узнанным
|
||||
- **WHEN** её карточку спрашивает другой узнанный
|
||||
- **THEN** ответ тот же, что и на неизвестный идентификатор, — и кодом, и телом
|
||||
|
||||
#### Scenario: Чужой файл неотличим от несуществующего
|
||||
|
||||
- **GIVEN** запись принята одним узнанным
|
||||
- **WHEN** её файл просит другой узнанный
|
||||
- **THEN** ответ тот же, что и на неизвестный идентификатор, — и кодом, и телом
|
||||
|
||||
#### Scenario: Владельца не задают запросом
|
||||
|
||||
- **WHEN** запрос на приём записи несёт своё значение владельца
|
||||
- **THEN** владельцем принятой записи становится узнанный предъявитель
|
||||
|
||||
#### Scenario: Ничью запись завести нечем
|
||||
|
||||
- **WHEN** запись пытаются завести с пустым владельцем
|
||||
- **THEN** база её не сохраняет
|
||||
|
||||
#### Scenario: Пустой владелец не открывает ничего
|
||||
|
||||
- **GIVEN** заведены две записи: своя и чужая
|
||||
- **WHEN** карточку каждой спрашивают с пустым владельцем
|
||||
- **THEN** ответ на обе тот же, что и на неизвестный идентификатор
|
||||
|
||||
### Requirement: Пришедшего называет доверенный источник
|
||||
|
||||
Сервис SHALL узнавать пришедшего по заголовку `Remote-User`, который ставит
|
||||
обратный прокси, сходивший к провайдеру, и MUST не вести собственного входа: ни
|
||||
адреса, уводящего к провайдеру, ни адреса возврата, ни куки, ни выхода у сервиса
|
||||
не остаётся.
|
||||
|
||||
Заголовку сервис MUST верить только тогда, когда запрос пришёл с адреса из
|
||||
объявленного перечня доверенных, и адрес этот MUST браться у самого соединения,
|
||||
а не из пересылаемого заголовка: значением пересылаемого распоряжается тот, кто
|
||||
шлёт запрос, и барьер, подделываемый той же строкой, которой он обходится, не
|
||||
барьер вовсе.
|
||||
|
||||
Заголовок, пришедший с недоверенного адреса, MUST не узнавать никого. Отказа при
|
||||
этом MUST не наступать в самом узнавании: проба здоровья, метрики и разметка
|
||||
приложения открыты неузнанному, и отказ на них закрыл бы наблюдение за сервисом
|
||||
всякому, кто пришлёт заголовок. Отказ приходит там, где приходил и раньше, —
|
||||
требованием учётной записи на адресах приложения.
|
||||
|
||||
**Узнавание идёт после ограничителя частоты, а не до него.** Оно читает базу, а
|
||||
на новом имени ещё и пишет в неё; выполненное раньше ограничителя, оно работало
|
||||
бы на запросах, которые тот уже отверг, и поток отвергнутых обращений заводил бы
|
||||
учётные записи, которые потом не убираются ничем.
|
||||
|
||||
**Узнавание действует на объявленной области, а не на всей поверхности сервиса.**
|
||||
Область — корень приложения; она MUST выводиться из объявленного адресного
|
||||
пространства сервиса, а не перечисляться вторым списком. Прежде область была
|
||||
шире на один адрес — тот, которым хранилище выдавало короткий токен файла; ни
|
||||
адреса, ни токена не осталось. Прежде область была и уже: собственную поверхность
|
||||
хранилища требовалось из неё вычитать, потому что ключ учётной записи лежал в
|
||||
коллекции обычной колонкой, а правило правки было библиотечным. Поверхности этой
|
||||
нет, и вычитать больше нечего.
|
||||
|
||||
Сужение области закрывает вещь, которая от смены хранилища не зависит: узнавание
|
||||
MUST не срабатывать на пробе здоровья, на метриках и на ресурсах приложения.
|
||||
Иначе запрос за каждой картинкой стоил бы обращения к базе, а первый такой запрос
|
||||
с новым именем — записи в неё.
|
||||
|
||||
**Значение заголовка принимается, а не берётся как есть.** Пустое значение и
|
||||
значение из одних пробельных знаков MUST не узнавать никого и MUST не заводить
|
||||
учётной записи: прокси штатно шлёт пустой заголовок там, где никого не назвал, и
|
||||
без этой нормы все неназванные собрались бы в одну учётную запись с общим
|
||||
архивом. Запрос, несущий **более одного** значения `Remote-User`, MUST не
|
||||
узнавать никого: прокси, настроенный добавлять заголовок вместо замены, оставляет
|
||||
рядом со своим значением присланное анонимом, и выбор «первое попавшееся» отдал
|
||||
бы вход анониму. Значение сверх объявленного предела длины и значение с
|
||||
управляющими знаками MUST не узнавать никого. Сравнение при поиске MUST быть
|
||||
точным, знак в знак: приведение регистра склеило бы двух разных людей по правилу,
|
||||
которого у провайдера нет. Обрамляющие пробелы при этом MUST срезаться до
|
||||
сравнения: они не часть имени, и заголовок с ведущим пробелом называет того же
|
||||
человека. Предел длины MUST считаться в **знаках** — той же единицей, что
|
||||
считает колонка.
|
||||
|
||||
Отказ базы при узнавании MUST кончаться отказом сервиса, а не молчаливым
|
||||
проходом неузнанным: иначе человек увидит отказ входа там, где легла база.
|
||||
|
||||
Исход узнавания MUST оставлять строку журнала — и когда заголовок пришёл с
|
||||
недоверенного адреса, и когда заголовок пришёл **более чем одним значением**, и
|
||||
когда учётная запись заведена. Уровень первых двух MUST быть виден при боевой
|
||||
настройке журнала: обе строки означают поломку контура, а поломка, записанная
|
||||
уровнем, который в бою выключен, не записана вовсе. Без неё владелец, у
|
||||
которого никто не может войти, не отличит своей поломки (перечень доверенных
|
||||
адресов) от поломки контура (прокси заголовка не ставит), а это разные поломки в
|
||||
разных местах. Строка несёт адрес пира и идентификатор учётной записи и MUST не
|
||||
нести значения заголовка.
|
||||
|
||||
Имя, пригодное к показу, сервис SHALL брать из заголовка `Remote-Name`, адрес
|
||||
почты — из `Remote-Email`. Имена всех трёх заголовков нормативны: смена имени
|
||||
молча перестаёт узнавать всех, а проверка, которая сама ставит и сама читает своё
|
||||
имя, этого не замечает. Контур уже пишет эти имена соседним сервисам.
|
||||
|
||||
Узнавание MUST идти на каждом запросе, и значения, переживающего запрос, сервис
|
||||
MUST не выдавать вовсе — ни куки, ни токена сессии, ни короткого токена файла.
|
||||
Исключений у этого правила больше нет: файл записи отдаётся тому же узнаванию,
|
||||
что и всё прочее, и отзыв доступа доходит до него сразу.
|
||||
|
||||
Смысл именно таков: отзыв доступа судит провайдер на каждом обращении, а не
|
||||
однажды выданный срок.
|
||||
|
||||
Собственных токенов сервис не принимает: значения, предъявленного запросом и
|
||||
дающего доступ помимо заголовка, у него не существует. Прежде такое значение
|
||||
било заголовок — им пользовался владелец панели; панели нет, и правило приоритета
|
||||
осталось бы правилом без предмета.
|
||||
|
||||
Значение заголовка MUST не попадать ни в журнал, ни в ответ, ни в метку метрики.
|
||||
Оно приходит строкой запроса и целиком задаётся тем, кто её шлёт, а с
|
||||
недоверенного адреса — анонимом; сверх того имя принадлежит человеку наравне с
|
||||
адресом его почты.
|
||||
|
||||
#### Scenario: Заголовок с доверенного адреса узнаёт человека
|
||||
|
||||
- **GIVEN** адрес источника стоит в перечне доверенных
|
||||
- **WHEN** запрос к адресу приложения приходит с заголовком `Remote-User`
|
||||
- **THEN** запрос идёт от имени учётной записи с этим значением
|
||||
|
||||
#### Scenario: Заголовок с недоверенного адреса не узнаёт никого
|
||||
|
||||
- **GIVEN** адреса источника в перечне доверенных нет
|
||||
- **WHEN** запрос к адресу приложения приходит с тем же заголовком
|
||||
- **THEN** ответ имеет код `401`
|
||||
- **AND** учётной записи с этим значением не появляется
|
||||
|
||||
#### Scenario: Предъявленного значения сервис не признаёт
|
||||
|
||||
- **GIVEN** запрос несёт заголовок `Remote-User` и постороннее значение доступа
|
||||
в заголовке или в параметре
|
||||
- **WHEN** сервис решает, кто пришёл
|
||||
- **THEN** пришедшим считается названный заголовком
|
||||
|
||||
#### Scenario: Пустой заголовок не узнаёт никого
|
||||
|
||||
- **GIVEN** адрес источника стоит в перечне доверенных
|
||||
- **WHEN** запрос к адресу приложения приходит с пустым `Remote-User`
|
||||
- **THEN** ответ имеет код `401`
|
||||
- **AND** учётной записи не появляется
|
||||
|
||||
#### Scenario: Два значения одного заголовка не узнают никого
|
||||
|
||||
- **GIVEN** адрес источника стоит в перечне доверенных
|
||||
- **WHEN** запрос к адресу приложения несёт два значения `Remote-User`
|
||||
- **THEN** ответ имеет код `401`
|
||||
- **AND** учётной записи не появляется
|
||||
|
||||
#### Scenario: Значение сверх предела длины не узнаёт никого
|
||||
|
||||
- **GIVEN** адрес источника стоит в перечне доверенных
|
||||
- **WHEN** запрос несёт `Remote-User` длиннее объявленного предела
|
||||
- **THEN** ответ имеет код `401`
|
||||
- **AND** учётной записи не появляется
|
||||
|
||||
#### Scenario: Область узнавания — корень приложения
|
||||
|
||||
- **GIVEN** сервис поднялся
|
||||
- **WHEN** смотрят, на каких адресах срабатывает узнавание
|
||||
- **THEN** это адреса под корнем приложения, и второго списка адресов нет
|
||||
|
||||
#### Scenario: Проба здоровья учётной записи не заводит
|
||||
|
||||
- **GIVEN** учётной записи с этим значением ещё нет
|
||||
- **WHEN** запрос с заголовком приходит на `GET /health` с доверенного адреса
|
||||
- **THEN** учётной записи не появляется
|
||||
|
||||
#### Scenario: Недоверенный источник виден в журнале
|
||||
|
||||
- **WHEN** запрос с заголовком приходит с недоверенного адреса
|
||||
- **THEN** журнал несёт строку об этом исходе с адресом пира
|
||||
- **AND** значения заголовка в ней нет
|
||||
|
||||
#### Scenario: Сервис не ставит браузеру куки
|
||||
|
||||
- **GIVEN** адрес источника стоит в перечне доверенных
|
||||
- **WHEN** запрос к адресу приложения проходит с заголовком
|
||||
- **THEN** ответ не ставит браузеру ни куки сессии, ни иного значения доступа
|
||||
|
||||
#### Scenario: Значения заголовка нет в журнале
|
||||
|
||||
- **WHEN** запрос с заголовком `Remote-User` проходит через сервис
|
||||
- **THEN** значение заголовка не встречается ни в одной журнальной записи
|
||||
|
||||
### Requirement: Учётная запись заводится первым обращением
|
||||
|
||||
Сервис SHALL заводить учётную запись при первом обращении с новым значением
|
||||
`Remote-User` и MUST находить её по тому же значению при каждом следующем.
|
||||
Значение MUST быть ключом учётной записи — уникальным и хранимым своей колонкой
|
||||
таблицы пользователей.
|
||||
|
||||
Имя и адрес почты MUST браться из заголовков того же запроса, и только при
|
||||
заведении. Оба MUST **приниматься**, а не браться как есть: имя обрезается по
|
||||
пределу колонки и чистится от управляющих знаков, негодный адрес почты
|
||||
отбрасывается. Негодное значение необязательного поля MUST не отменять
|
||||
заведения записи — иначе человек с длинным именем у провайдера не завёлся бы
|
||||
никогда, получая отказ сервиса на каждом запросе. Найденную запись повторное
|
||||
обращение MUST не переписывать: иначе всякий запрос был бы записью в базу, а
|
||||
правка имени у провайдера меняла бы карточку человека молча, посреди его работы.
|
||||
|
||||
Адрес почты MUST быть необязательным: провайдер не обязан его приносить, а ключом
|
||||
он не служит. Ключом его брать нельзя вовсе — адрес меняется, и первое обращение
|
||||
с чужим адресом досталось бы чужой записи.
|
||||
|
||||
**Ключ учётной записи MUST не правиться ничем, кроме заведения самим сервисом.**
|
||||
Переписанный ключ отдаёт архив следующему, кто придёт с этим именем, а вернуть
|
||||
его будет нечем — владелец записи назначается один раз и не меняется. Держится
|
||||
это тем, что адреса, которым учётная запись правится снаружи, у сервиса нет
|
||||
вовсе: своих экранов профиля он не заводит, а поверхности хранилища, правившей
|
||||
запись библиотечным правилом, не осталось. Правку остаётся сделать запросом к
|
||||
базе, и это работа владельца сервиса, а не спрашивающего.
|
||||
|
||||
Одновременные первые обращения одним значением MUST кончаться одной учётной
|
||||
записью: уникальность держит схема, а не порядок обращений.
|
||||
|
||||
**Два отказа уникальности различаются, и исход у них разный.** Отказ по ключевой
|
||||
колонке — это гонка двух первых обращений одним именем, и он MUST кончаться
|
||||
повторным поиском и продолжением работы. Отказ по любой другой колонке — адрес
|
||||
почты, пришедший от провайдера, уже занят другой учётной записью — MUST кончаться
|
||||
заведением записи **без почты**: она необязательна. Без этого разреза второй
|
||||
человек с общим почтовым ящиком не завёлся бы никогда, потому что повторный поиск
|
||||
по имени снова ничего не находит.
|
||||
|
||||
Цена ключа называется целиком, обеими сторонами. Переименование пользователя у
|
||||
провайдера заводит **новую** учётную запись, и записи прежней остаются у прежней;
|
||||
слить их или убрать нечем — владелец записи не меняется, а учётная запись с
|
||||
записями не удаляется по норме `storage`. **Логин же переиспользуем**: человек,
|
||||
которому провайдер выдал логин ушедшего, при первом обращении попадает в
|
||||
существующую запись и получает весь её архив. Не допускать переиспользования —
|
||||
работа провайдера; сервису неизменяемого признака заголовок не приносит, и эта
|
||||
цена принимается, а не обходится.
|
||||
|
||||
#### Scenario: Первое обращение заводит запись
|
||||
|
||||
- **GIVEN** учётной записи с этим значением ещё нет
|
||||
- **WHEN** приходит запрос с заголовком `Remote-User`
|
||||
- **THEN** учётная запись появляется
|
||||
- **AND** запрос идёт от её имени
|
||||
|
||||
#### Scenario: Повторное обращение попадает в ту же запись
|
||||
|
||||
- **GIVEN** учётная запись заведена первым обращением
|
||||
- **WHEN** приходит второй запрос с тем же значением заголовка
|
||||
- **THEN** новой учётной записи не появляется
|
||||
- **AND** запрос идёт от имени прежней
|
||||
|
||||
#### Scenario: Разным значениям — разные записи
|
||||
|
||||
- **WHEN** приходят запросы с двумя разными значениями заголовка
|
||||
- **THEN** заводятся две учётные записи
|
||||
- **AND** записи одного не видны другому
|
||||
|
||||
#### Scenario: Имя не переписывается вторым обращением
|
||||
|
||||
- **GIVEN** учётная запись заведена с одним значением `Remote-Name`
|
||||
- **WHEN** приходит запрос с тем же `Remote-User` и другим `Remote-Name`
|
||||
- **THEN** имя учётной записи остаётся прежним
|
||||
|
||||
#### Scenario: Два одновременных первых обращения дают одну запись
|
||||
|
||||
- **GIVEN** учётной записи с этим значением ещё нет
|
||||
- **WHEN** два запроса с одним значением заголовка приходят одновременно
|
||||
- **THEN** в таблице пользователей появляется ровно одна строка
|
||||
- **AND** оба запроса идут от её имени
|
||||
|
||||
#### Scenario: Занятая почта не мешает завести запись
|
||||
|
||||
- **GIVEN** учётная запись с этим адресом почты уже заведена
|
||||
- **WHEN** приходит первое обращение с другим `Remote-User` и тем же
|
||||
`Remote-Email`
|
||||
- **THEN** заводится своя учётная запись
|
||||
- **AND** адреса почты у неё нет
|
||||
|
||||
#### Scenario: Адреса правки учётной записи у сервиса нет
|
||||
|
||||
- **GIVEN** человек узнан и его учётная запись заведена
|
||||
- **WHEN** ищут адрес сервиса, которым он правит свою учётную запись
|
||||
- **THEN** такого адреса нет
|
||||
|
||||
#### Scenario: Негодное имя не отменяет заведения
|
||||
|
||||
- **GIVEN** учётной записи с этим значением ещё нет
|
||||
- **WHEN** приходит обращение с именем длиннее предела колонки
|
||||
- **THEN** учётная запись заводится, а имя обрезано по пределу
|
||||
|
||||
#### Scenario: Негодная почта отбрасывается, а не отменяет заведение
|
||||
|
||||
- **GIVEN** учётной записи с этим значением ещё нет
|
||||
- **WHEN** приходит обращение с адресом почты, не похожим на адрес
|
||||
- **THEN** учётная запись заводится без почты
|
||||
|
||||
#### Scenario: Отвергнутый ограничителем запрос учётной записи не заводит
|
||||
|
||||
- **GIVEN** бюджет ограничителя частоты выбран
|
||||
- **WHEN** приходит обращение с новым значением заголовка
|
||||
- **THEN** ответ несёт отказ ограничителя
|
||||
- **AND** учётной записи не появляется
|
||||
|
||||
#### Scenario: Заведение учётной записи видно в журнале
|
||||
|
||||
- **WHEN** приходит первое обращение с новым значением заголовка
|
||||
- **THEN** журнал несёт строку о заведении с идентификатором записи
|
||||
- **AND** значения заголовка в ней нет
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
### Requirement: Иных способов открыть сессию нет
|
||||
|
||||
**Reason**: Требование выключало собственные входы встроенного хранилища —
|
||||
создание записи в коллекции пользователей, вход по паролю, вход по одноразовому
|
||||
коду, обмен кода у внешнего провайдера, восстановление доступа — и закрывало
|
||||
правилами доступа правку этой коллекции. Хранилище уходит из проекта целиком:
|
||||
ни одного из этих адресов не существует, и выключать больше нечего.
|
||||
|
||||
**Migration**: Единственный способ представиться остаётся прежним — заголовок
|
||||
доверенного источника на каждом запросе, требование «Пришедшего называет
|
||||
доверенный источник». Что учётную запись заводит только код сервиса и что её ключ
|
||||
не правится снаружи, нормирует требование «Учётная запись заводится первым
|
||||
обращением»: адреса правки у сервиса нет вовсе.
|
||||
@@ -0,0 +1,335 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Файл записи отдаётся адресом приложения
|
||||
|
||||
Сервис SHALL отдавать файл записи адресом под корнем приложения — `GET
|
||||
/app/audiorecords/{id}/file` — и MUST отдавать **копию, названную
|
||||
спрашивающим**. Отдача «какой-нибудь» копии сделала бы ответ функцией того, что
|
||||
успел записать конвейер, а не состояния записи.
|
||||
|
||||
**Копию называет параметр запроса `copy`.** Имя параметра нормативно наравне со
|
||||
значениями: разбирает его каждый экран, и выбранное кодом оно стало бы публичным
|
||||
контрактом молча.
|
||||
|
||||
**Перечень значений закрыт, и каждое называет ровно одну хранимую вещь:**
|
||||
|
||||
- `original` — файл, принятый от отправителя;
|
||||
- `normalized` — копия, приведённая к рабочему формату.
|
||||
|
||||
Копию, которой у записи ещё нет, сервис MUST отдавать отказом состояния — тем же
|
||||
кодом, каким отвечает ненаписанный текст: `409`. Пустой ответ читался бы как
|
||||
пустой файл, а `404` слился бы с ответом на чужую и неизвестную запись, и человек
|
||||
увидел бы «не найдено» на своей записи, загруженной минуту назад.
|
||||
|
||||
Копия, которой сервис не знает, и незаданная копия MUST давать отказ по негодному
|
||||
вводу — но только у **своей** записи.
|
||||
|
||||
Порядок проверок MUST быть один: владение записью судится **до** разбора значения
|
||||
копии. Неизвестное либо незаданное значение копии у чужой и у несуществующей
|
||||
записи MUST давать тот же ответ, что и неизвестный идентификатор, — и кодом, и
|
||||
телом. Неотличимость чужой записи от несуществующей главнее формы ответа на
|
||||
негодный ввод: разбор параметра, выполненный раньше, отвечал бы одинаково на
|
||||
чужую и на неизвестную только случайно, а стоило бы ответам разойтись — по этой
|
||||
разнице перебирался бы список заведённых записей одним негодным параметром.
|
||||
|
||||
Ответ MUST нести длину файла и тип содержимого и MUST допускать выдачу по частям:
|
||||
запись расчётного потолка — шесть часов, и проигрыватель в браузере перематывает
|
||||
её запросом диапазона, а не повторной загрузкой целиком.
|
||||
|
||||
**Негодный диапазон MUST приводиться к обычному отказу сервиса** — телом той же
|
||||
формы и кодом из закрытого перечня, — а не отвечать кодом `416` и телом
|
||||
библиотеки. Негодных диапазонов два вида, и оба ведут себя одинаково:
|
||||
неудовлетворимый (начало за концом файла) и множественный (в запросе назван
|
||||
больше чем один диапазон). Второй сервис не отдаёт намеренно: ответ из нескольких
|
||||
частей — это отдельный тип содержимого со своими границами, а просит его один
|
||||
только самодельный запрос, потому что проигрыватель в браузере шлёт один
|
||||
диапазон.
|
||||
|
||||
Причина у требования общая с прочими отказами, рождающимися не в обработчике:
|
||||
форма тела на адресах приложения одна, и код отказа принадлежит закрытому
|
||||
перечню. Ответ `416` с телом библиотеки приходит без полей `error_code` и
|
||||
`message`, и приложение разбирает его отдельной веткой — единственной такой на
|
||||
все адреса.
|
||||
|
||||
Файл чужой записи MUST быть недоступен наравне с её карточкой и отвечать тем же,
|
||||
чем неизвестный идентификатор. Кто владелец файла и почему право пройти по адресу
|
||||
даёт узнавание, а не выданное значение, нормирует capability `storage`.
|
||||
|
||||
Имя файла на диске MUST в ответ не попадать: имя, предлагаемое браузеру при
|
||||
сохранении, строится из имени, данного отправителем, и лежит оно колонкой записи.
|
||||
|
||||
**Перечня доступных копий карточка записи не объявляет** — до задачи об экране
|
||||
прослушивания его в ответе MUST не быть, и об отсутствующей копии спрашивающий
|
||||
узнаёт отказом состояния на самом обращении за файлом.
|
||||
|
||||
Довод, которым перечень доступных видов текста объявляется карточкой всегда, к
|
||||
копиям файла не относится, и это разные случаи. Видов текста несколько, шаг
|
||||
завершения пишет их несколькими операциями, поэтому состояние «сплошной текст
|
||||
есть, реплик ещё нет» достижимо, а из состояния записи не выводится: приложение
|
||||
обязано узнать перечень, иначе пойдёт за текстом, которого нет. Копий же две, и
|
||||
каждая выводится из рубежа записи, который карточка несёт и так: принятая копия
|
||||
есть у всякой заведённой записи, приведённая — у всякой, прошедшей приведение.
|
||||
Второе поле повторяло бы рубеж и разошлось бы с ним молча.
|
||||
|
||||
Перечень появится тогда, когда у него появится потребитель: экран прослушивания
|
||||
приносит задача `play-recording-in-app`. Объявлять его раньше — закреплять
|
||||
контракт, которого никто не разбирает.
|
||||
|
||||
#### Scenario: Владелец забирает принятую копию
|
||||
|
||||
- **GIVEN** запись принята
|
||||
- **WHEN** владелец просит её файл копией `original`
|
||||
- **THEN** ответ несёт содержимое принятого файла и его длину
|
||||
|
||||
#### Scenario: Приведённой копии ещё нет
|
||||
|
||||
- **GIVEN** запись принята и не дошла до приведения
|
||||
- **WHEN** владелец просит её файл копией `normalized`
|
||||
- **THEN** ответ имеет код `409`
|
||||
- **AND** он отличается от ответа на неизвестный идентификатор
|
||||
|
||||
#### Scenario: Копия неизвестна или не названа
|
||||
|
||||
- **WHEN** владелец просит файл копией, которой сервис не знает, либо не
|
||||
называет копии вовсе
|
||||
- **THEN** ответ имеет код `400`
|
||||
|
||||
#### Scenario: Чужой файл неотличим от неизвестной записи
|
||||
|
||||
- **GIVEN** запись принята одним узнанным
|
||||
- **WHEN** её файл просит другой узнанный
|
||||
- **THEN** ответ тот же, что и на неизвестный идентификатор, — и кодом, и телом
|
||||
|
||||
#### Scenario: Негодная копия у чужой записи неотличима от неизвестной записи
|
||||
|
||||
- **GIVEN** запись принята одним узнанным
|
||||
- **WHEN** другой узнанный просит её файл копией, которой сервис не знает
|
||||
- **THEN** ответ тот же, что и на неизвестный идентификатор, — и кодом, и телом
|
||||
- **AND** он не отличается от ответа на ту же просьбу к несуществующей записи
|
||||
|
||||
#### Scenario: Перечня копий в карточке нет
|
||||
|
||||
- **GIVEN** запись принята и приведена к рабочему формату
|
||||
- **WHEN** владелец спрашивает её карточку
|
||||
- **THEN** поля с перечнем доступных копий файла в ответе нет
|
||||
|
||||
#### Scenario: Проигрыватель просит кусок записи
|
||||
|
||||
- **GIVEN** запись принята
|
||||
- **WHEN** владелец просит её файл с указанием диапазона
|
||||
- **THEN** ответ несёт запрошенный кусок, а не файл целиком
|
||||
|
||||
#### Scenario: Неудовлетворимый диапазон отвечает обычным отказом
|
||||
|
||||
- **GIVEN** запись принята, и её файл короче запрошенного начала
|
||||
- **WHEN** владелец шлёт настоящий HTTP-запрос за файлом с заголовком `Range:
|
||||
bytes=99999999-`
|
||||
- **THEN** ответ имеет код `400`, а не `416`
|
||||
- **AND** тело несёт поля `error_code` и `message`
|
||||
|
||||
#### Scenario: Двух диапазонов в одном запросе сервис не отдаёт
|
||||
|
||||
- **GIVEN** запись принята
|
||||
- **WHEN** владелец шлёт настоящий HTTP-запрос за файлом с заголовком,
|
||||
называющим два диапазона
|
||||
- **THEN** ответ имеет код `400`, а не `416`
|
||||
- **AND** тело несёт поля `error_code` и `message`
|
||||
- **AND** ответа из нескольких частей сервис не отдаёт
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Адреса приложения живут своим пространством
|
||||
|
||||
Сервис SHALL вешать собственные адреса приложения под корнем `/app/` и MUST не
|
||||
заводить второго адресного пространства рядом. Пространство `/api/`, прежде
|
||||
принадлежавшее встроенному хранилищу, и адрес панели `/_/` перестают
|
||||
существовать: сервис их не занимает и отвечает на них тем же, чем отвечает
|
||||
всякий неизвестный путь, — норму держит capability `webapp`.
|
||||
|
||||
Корень приложения остаётся прежним, и это решение подтверждается, а не
|
||||
принимается заново: формы запросов и ответов приложения смена хранилища не
|
||||
трогает ни одним полем.
|
||||
|
||||
Соседство, ради которого корень был выбран, кончилось вместе с соседом: чужого
|
||||
обновления, вправе занять новое имя рядом с нашим, больше нет.
|
||||
|
||||
Ограничитель частоты под корнем приложения MUST быть своим: он считает бюджет по
|
||||
адресу спрашивающего и MUST не зависеть от настроек чужой поверхности. Прежде
|
||||
включение нашего правила вводило в действие и чужие правила на чужих адресах;
|
||||
платить за это больше нечем — чужих адресов нет.
|
||||
|
||||
**Адрес спрашивающего ограничитель MUST брать из заголовка пересылки — и только
|
||||
тогда, когда соединение пришло с адреса из объявленного перечня доверенных.** Во
|
||||
всяком другом случае адресом MUST считаться адрес пира, а пришедший заголовок
|
||||
MUST не влиять на ключ бюджета ничем.
|
||||
|
||||
**Цепочку пересылки ограничитель MUST читать справа налево, отбрасывая адреса из
|
||||
перечня доверенных, и брать первый недоверенный.** Читаются при этом **все**
|
||||
строки заголовка, а не первая: цепочка законно приходит несколькими строками.
|
||||
Значение, оставшееся слева, ключа бюджета MUST не задавать: прокси заголовок
|
||||
дописывает, а не заменяет, поэтому слева стоит то, что прислал спрашивающий, — и
|
||||
ключ, взятый оттуда, меняется у него на каждом запросе, то есть бюджет
|
||||
обходится с первого. Цепочка, где недоверенного адреса не нашлось вовсе, MUST
|
||||
падать обратно на адрес пира.
|
||||
|
||||
Обе половины правила закрывают свою поломку. Сервис стоит за обратным прокси, и
|
||||
пир у него один на всех: бюджет, посчитанный по пиру, становится общим на весь
|
||||
сервис, и один спрашивающий исчерпывает его остальным. Обратная ошибка — верить
|
||||
заголовку без сверки пира — отдаёт обход ограничителя ровно тому, кого он
|
||||
ограничивает: значением пересылаемого заголовка распоряжается тот, кто шлёт
|
||||
запрос, и меняет он его на каждом запросе.
|
||||
|
||||
**Узнавание и ограничитель берут адрес разными способами, и это намеренно.**
|
||||
Узнаванию нужен адрес самого соединения: им оно решает, верить ли заголовку
|
||||
вообще, и взятый из пересылаемого заголовка он сделал бы барьер подделываемым той
|
||||
же строкой, которой обходится, — норму держит capability `access`. Ограничителю
|
||||
нужен адрес того, кого он ограничивает, а тот за прокси в адресе соединения не
|
||||
виден вовсе. Вопросы разные — «кому верить» и «кого считать», — и один ответ на
|
||||
оба ломает либо барьер, либо бюджет.
|
||||
|
||||
#### Scenario: Адрес приложения отвечает под своим корнем
|
||||
|
||||
- **GIVEN** человек узнан
|
||||
- **WHEN** он спрашивает список своих записей под корнем приложения
|
||||
- **THEN** ответ приходит от сервиса
|
||||
|
||||
#### Scenario: Пространства хранилища не существует
|
||||
|
||||
- **GIVEN** сервис поднялся
|
||||
- **WHEN** запрос приходит на путь под прежним корнем хранилища
|
||||
- **THEN** ответ тот же, что и на всякий неизвестный путь вне корней сервиса
|
||||
|
||||
#### Scenario: Адреса панели не существует
|
||||
|
||||
- **GIVEN** сервис поднялся
|
||||
- **WHEN** запрос приходит на прежний адрес панели — и записанный знаком, и
|
||||
записанный его кодом
|
||||
- **THEN** ответ тот же, что и на всякий неизвестный путь вне корней сервиса
|
||||
|
||||
#### Scenario: Ограничитель частоты покрывает адреса приложения
|
||||
|
||||
- **GIVEN** сервис поднялся
|
||||
- **WHEN** запросы с одного адреса идут чаще бюджета под корнем приложения
|
||||
- **THEN** лишние получают отказ ограничителя
|
||||
|
||||
#### Scenario: Два клиентских адреса через один прокси расходуют разные бюджеты
|
||||
|
||||
- **GIVEN** запросы идут с доверенного адреса, и адрес пира у них один
|
||||
- **WHEN** два разных клиентских адреса шлют запросы под корнем приложения
|
||||
- **THEN** бюджет каждого считается отдельно
|
||||
- **AND** исчерпание бюджета одним не отказывает другому
|
||||
|
||||
#### Scenario: Заголовок с недоверенного адреса на ключ бюджета не влияет
|
||||
|
||||
- **GIVEN** запросы приходят с адреса вне перечня доверенных
|
||||
- **WHEN** они несут заголовок пересылки с разными значениями адреса
|
||||
- **THEN** бюджет у них общий и считается по адресу пира
|
||||
|
||||
#### Scenario: Значение, приписанное спрашивающим, ключа бюджета не задаёт
|
||||
|
||||
- **GIVEN** запросы приходят с доверенного адреса
|
||||
- **WHEN** они несут цепочку пересылки, где слева стоит меняющееся значение
|
||||
спрашивающего, а справа — адрес, приписанный прокси
|
||||
- **THEN** бюджет считается по правому значению
|
||||
- **AND** запросы чаще бюджета получают отказ ограничителя
|
||||
|
||||
#### Scenario: Цепочка читается всеми строками заголовка
|
||||
|
||||
- **GIVEN** запросы приходят с доверенного адреса
|
||||
- **WHEN** цепочка пересылки приходит несколькими строками заголовка
|
||||
- **THEN** ключ бюджета берётся из последней строки, а не из первой
|
||||
|
||||
### Requirement: Отказ называет причину, а не место
|
||||
|
||||
Сервис SHALL отвечать на адресах приложения кодом, который отвечает **причине**
|
||||
отказа, а не месту, где он случился. Перечень закрыт и назван поимённо:
|
||||
|
||||
- пришедший не узнан — `401`, и он MUST наступать **до всякого чтения записи**,
|
||||
одинаково для заведённой записи и для неизвестного идентификатора: иначе по
|
||||
разнице кодов перебирается список заведённых записей;
|
||||
- неизвестный идентификатор — `404`, и **тем же кодом с тем же телом** MUST
|
||||
отвечать чужая и ничья запись;
|
||||
- негодный ввод — `400`: нечитаемая запись, неизвестное значение параметра,
|
||||
негодный размер страницы, негодный диапазон в запросе файла;
|
||||
- запись сверх потолка размера — `413`, и тело MUST нести предел числом;
|
||||
- состояние, в котором действие недоступно, — `409`: текста или копии файла
|
||||
запрошенного вида у записи ещё нет;
|
||||
- отказ базы и всякая неназванная причина — `500`.
|
||||
|
||||
Ветвь «узнанный предъявитель без учётной записи пользователя» из перечня ушла
|
||||
вместе со своим единственным случаем: им был владелец панели, предъявивший
|
||||
собственный токен хранилища. Ни панели, ни токенов у сервиса не осталось, а
|
||||
узнавание по заголовку учётную запись заводит само, и предъявителя без неё не
|
||||
бывает. Ветвь, у которой нет достижимого случая, не проверяется ничем и остаётся
|
||||
в коде мёртвой.
|
||||
|
||||
Отображение доменной ошибки в код и сообщение MUST жить **одним местом** на все
|
||||
адреса, и у него MUST быть определённая ветвь по умолчанию.
|
||||
|
||||
Тело отказа MUST быть одной формы на всех адресах приложения и MUST нести **два**
|
||||
поля: машиночитаемый код отказа из закрытого перечня и сообщение, пригодное
|
||||
человеку, на русском языке. Одного сообщения мало: кода HTTP не хватает, чтобы
|
||||
различить «файл негоден», «поля записи нет» и «неизвестное значение параметра» —
|
||||
все три `400`, — а приложению надо решать, предлагать ли повтор и что показать
|
||||
человеку. Разбор русской фразы был бы единственным оставшимся путём, и первая же
|
||||
задача экрана переписала бы контракт, согласованный здесь один раз.
|
||||
|
||||
Имена полей и перечень кодов нормативны — их разбирает каждый экран, и
|
||||
выбранные кодом они стали бы контрактом молча:
|
||||
|
||||
- поля тела: `error_code` и `message`;
|
||||
- перечень `error_code`: `unauthorized`, `not_found`, `bad_request`,
|
||||
`too_large`, `too_many_requests`, `not_ready`, `internal`.
|
||||
|
||||
Часть отказов рождается **не в обработчике** — предел тела, ограничитель частоты,
|
||||
неизвестный путь под корнем приложения, — и до отображения доменной ошибки не
|
||||
доходит вовсе. Такие отказы MUST приводиться к той же форме: иначе форм на
|
||||
адресах приложения две, а самый частый отказ у человека на мобильной сети —
|
||||
«запись больше потолка» — приходит телом библиотеки, без кода и без предела
|
||||
числом.
|
||||
|
||||
Перечень закрыт и объявляется **одним местом**. Новая штатная ветвь отказа
|
||||
заводится добавлением в него, а не строкой в обработчике: иначе ветвь по
|
||||
умолчанию отдаст `internal` на обычный конфликт, и владелец сервиса увидит в
|
||||
журнале аварию там, где её нет.
|
||||
|
||||
Сырой текст ошибки MUST в тело не попадать — ни текст отказа драйвера, ни детали
|
||||
устройства: имена внешних сервисов, пути на диске, имена файлов. Полная ошибка
|
||||
остаётся в журнале владельца сервиса.
|
||||
|
||||
#### Scenario: Сбой базы виден как сбой
|
||||
|
||||
- **GIVEN** база отвечает отказом драйвера на чтение записи
|
||||
- **WHEN** владелец спрашивает свою запись
|
||||
- **THEN** ответ имеет код `500`
|
||||
- **AND** тела записи в ответе нет
|
||||
|
||||
#### Scenario: Негодная запись видна как негодная
|
||||
|
||||
- **GIVEN** источник метаданных не может прочитать присланную запись
|
||||
- **WHEN** отправитель шлёт её приёмом
|
||||
- **THEN** ответ имеет код `400` и несёт сообщение, пригодное человеку
|
||||
- **AND** причина отказа в тело ответа не попадает
|
||||
|
||||
#### Scenario: Форма тела одна на всех ветвях отказа
|
||||
|
||||
- **WHEN** сервис отказывает по ненайденной записи, по негодному вводу, по
|
||||
недоступному состоянию и по сбою базы
|
||||
- **THEN** тело каждого ответа несёт код отказа и сообщение одними и теми же
|
||||
полями
|
||||
- **AND** код отказа принадлежит закрытому перечню
|
||||
- **AND** ни одно из них не содержит сырого текста ошибки
|
||||
|
||||
#### Scenario: Неузнанному неизвестная запись неотличима от заведённой
|
||||
|
||||
- **GIVEN** заведена запись
|
||||
- **WHEN** её карточку спрашивают неузнанным, а затем спрашивают карточку по
|
||||
неизвестному идентификатору
|
||||
- **THEN** оба ответа имеют код `401` и одно тело
|
||||
|
||||
#### Scenario: Запись сверх потолка размера
|
||||
|
||||
- **GIVEN** отправитель узнан
|
||||
- **WHEN** он шлёт запись длиннее потолка размера
|
||||
- **THEN** ответ имеет код `413`, а тело несёт предел числом
|
||||
- **AND** ни файла, ни аудиозаписи не заводится
|
||||
@@ -0,0 +1,128 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Приём записи по HTTP
|
||||
|
||||
Сервис SHALL принимать запись запросом `POST /app/audiorecords` с телом
|
||||
`multipart/form-data` и полем `audio` **только от узнанного отправителя**.
|
||||
Запрос от неузнанного MUST получать код `401`, и по нему MUST не заводиться ни файл,
|
||||
ни аудиозапись. Принятая запись от узнанного отправителя MUST быть сохранена и
|
||||
получить заведённую под неё аудиозапись на рубеже `uploaded`.
|
||||
|
||||
Приём стоит тем же адресом, что и список записей, и отличается от него только
|
||||
методом: он **заводит аудиозапись**, а не кладёт файл.
|
||||
|
||||
Ответ MUST нести **список** заведённых записей и место под признак повторного
|
||||
файла у каждой, даже когда файл в запросе один. Форма согласована один раз и
|
||||
вперёд: приём, отдающий одну запись, пришлось бы переписывать вместе с приёмом
|
||||
нескольких файлов и с распознаванием повтора по содержимому, а экран загрузки —
|
||||
переделывать под вторую форму. Число файлов в запросе при этом остаётся прежним:
|
||||
меняется форма ответа, не число файлов.
|
||||
|
||||
Элемент списка MUST нести те же поля, что и карточка записи, плюс признак
|
||||
повторного файла полем `duplicate`: две формы одной вещи разошлись бы молча.
|
||||
Состав карточки нормирует capability `archive`.
|
||||
|
||||
Прежние имена полей ответа — `job_id` и `status` — MUST не употребляться:
|
||||
идентификатор записи зовётся `id`.
|
||||
|
||||
Значение рубежа в ответе MUST принадлежать перечню рубежей конвейера и MUST не
|
||||
перечисляться этой нормой порознь: рубеж объявлен одним дескриптором, и
|
||||
перечисленный здесь второй раз он разошёлся бы с ним молча. Рубеж называет
|
||||
достигнутое, а не предстоящее, и `created` в перечне отсутствует вовсе.
|
||||
|
||||
Запись сверх потолка размера MUST отвергаться до заведения файла и аудиозаписи,
|
||||
и код с телом такого отказа нормирует capability `archive` наравне с прочими
|
||||
ветвями.
|
||||
|
||||
Отказ неузнанному наступает **раньше** чтения тела: запись, за которую
|
||||
не заплатит узнанный отправитель, не должна попасть даже в память.
|
||||
|
||||
Приём не судит о годности записи сам: расширение он берёт из имени файла, а
|
||||
пригодность содержимого узнаёт у источника метаданных.
|
||||
|
||||
Куда именно ложится принятая запись, приёму не принадлежит: раскладку каталога
|
||||
данных нормирует capability `storage`.
|
||||
|
||||
Владельцем принятой записи приём SHALL назначать узнанного предъявителя.
|
||||
Обязательность владельца при этом MUST держаться и схемой: колонка владельца
|
||||
пустого значения не принимает вовсе, и норму эту держит capability `storage`.
|
||||
Проверка в приёме от этого не лишняя — она отвечает отправителю понятным отказом
|
||||
до того, как запись попадёт в память, а схема отвечала бы отказом сохранения
|
||||
после укладки файла.
|
||||
|
||||
Отдельной ветви «узнан, а учётной записи нет» у приёма больше нет: узнавание
|
||||
заводит учётную запись само, а предъявителя с собственным токеном хранилища не
|
||||
существует — токенов сервис не выдаёт и не принимает. Ветвь ушла вместе со своим
|
||||
единственным случаем.
|
||||
|
||||
Отказ **после** укладки записи потребовал бы убрать уже сохранённый файл, а
|
||||
уборки файлов сервис не умеет вовсе: норма, обязывающая к недостижимому, не
|
||||
пишется.
|
||||
|
||||
#### Scenario: Запись принята
|
||||
|
||||
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
|
||||
- **AND** отправитель узнан
|
||||
- **WHEN** программа шлёт `POST /app/audiorecords` с полем `audio`
|
||||
- **THEN** ответ имеет код `201`, а в теле лежит список из одного элемента
|
||||
- **AND** элемент несёт непустой `id`, поле `state` со значением `uploaded` и
|
||||
место под признак повторного файла
|
||||
- **AND** содержимое записи целиком лежит в каталоге данных одним файлом
|
||||
- **AND** владельцем заведённой аудиозаписи стоит узнанный предъявитель
|
||||
|
||||
#### Scenario: Пришедший не узнан
|
||||
|
||||
- **WHEN** программа шлёт `POST /app/audiorecords` с полем `audio` неузнанной
|
||||
- **THEN** ответ имеет код `401`
|
||||
- **AND** ни файла, ни аудиозаписи не заводится
|
||||
- **AND** тело ответа не несёт данных записи
|
||||
|
||||
#### Scenario: Поля с записью нет
|
||||
|
||||
- **GIVEN** отправитель узнан
|
||||
- **WHEN** программа шлёт `POST /app/audiorecords` без поля `audio`
|
||||
- **THEN** ответ имеет код `400` и сообщение об отсутствии записи
|
||||
- **AND** ни файла, ни аудиозаписи не заводится
|
||||
|
||||
#### Scenario: Размеру записи приём не судья
|
||||
|
||||
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
|
||||
- **AND** отправитель узнан
|
||||
- **WHEN** программа шлёт запись нулевой длины
|
||||
- **THEN** ответ имеет код `201`: собственного порога по размеру у приёма нет
|
||||
|
||||
### Requirement: Имя файла в хранилище
|
||||
|
||||
Сервис SHALL сохранять принятую запись под собственным именем — идентификатором,
|
||||
к которому приписано расширение из имени файла отправителя. Имя, данное
|
||||
отправителем, MUST не попадать **ни в имя файла на диске, ни в путь к нему**: оно
|
||||
приходит извне и содержимым своим приёму не подконтрольно.
|
||||
|
||||
Норма сужена: имя отправителя доходит теперь до самой аудиозаписи собственной
|
||||
колонкой — по нему человек узнаёт свою запись, — но не до имени файла и не до
|
||||
журнала. Что с ним делает приём, нормирует требование «Имя файла отправителя
|
||||
подписывает запись».
|
||||
|
||||
Расширения в присланном имени нет — сервис MUST подставить `.audio`, чтобы у
|
||||
файла на диске расширение было всегда.
|
||||
|
||||
Требование пережило смену раскладки: имя задаёт сервис, а не умолчание чужой
|
||||
библиотеки, строившее его из имени отправителя. Умолчания этого больше нет, и
|
||||
правило перестало быть отменой чужого поведения — оно стало прямым описанием
|
||||
своего.
|
||||
|
||||
#### Scenario: Расширение взято из имени отправителя
|
||||
|
||||
- **WHEN** программа шлёт запись с именем `test.mp3`
|
||||
- **THEN** имя файла на диске оканчивается на `.mp3`
|
||||
|
||||
#### Scenario: Имени без расширения назначено своё
|
||||
|
||||
- **WHEN** программа шлёт запись с именем `test` без расширения
|
||||
- **THEN** имя файла на диске оканчивается на `.audio`
|
||||
|
||||
#### Scenario: Имя отправителя в имя файла не попало
|
||||
|
||||
- **WHEN** программа шлёт запись с именем `секретное-слово.mp3`
|
||||
- **THEN** имя файла на диске не содержит `секретное-слово`
|
||||
- **AND** путь к этому файлу не содержит его тоже
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Захват задачи неделим
|
||||
|
||||
Захват записи воркером SHALL быть одним неделимым запросом к базе: выбор
|
||||
подходящей записи и пометка её захваченной MUST происходить вместе, одним
|
||||
оператором с возвратом.
|
||||
|
||||
Захват MUST возвращать **идентификатор записи и признак этого захвата**, а не
|
||||
перечень её колонок. Колонки записи шаг читает сам, обычным чтением. Иначе
|
||||
всякая новая колонка аудиозаписи попадала бы под инвариант проекта о колонках
|
||||
очереди, и забытая в захвате колонка приезжала бы нулевой, а первое же
|
||||
сохранение писало бы этот ноль поверх сохранённого значения.
|
||||
|
||||
**Признак захвата MUST быть значением, уникальным для каждого захвата**, а не
|
||||
признаком занятости. Условие записи результата сверяет именно это значение:
|
||||
захват, перевыданный другому — по протуханию срока или после того, как человек
|
||||
снял признак остановки подкомандой оснастки, — обязан обращать запись первого в
|
||||
отказ.
|
||||
Условие, проверяющее лишь непустоту признака или срок, пропустило бы обоих, и
|
||||
два шага записали бы в одну запись по очереди, испортив её результат.
|
||||
|
||||
Одна и та же запись MUST доставаться ровно одному захватившему. Двум вызывающим,
|
||||
пришедшим за работой одновременно, запись MUST достаться одному, а второй MUST
|
||||
получить признак «работы сейчас нет».
|
||||
|
||||
Срок протухания захвата MUST ехать с рубежом записи, а не с воркером: воркер не
|
||||
привязан к шагу и не знает заранее, что вытянет. Срок MUST записываться числом
|
||||
при самом захвате.
|
||||
|
||||
Порядок выборки MUST быть определён однозначно: сравнения по неуникальному
|
||||
значению для этого мало, и к нему MUST добавляться ключ записи. Иначе порядок
|
||||
обработки невоспроизводим, а проверка, опирающаяся на «следующую» запись, зелена
|
||||
через раз.
|
||||
|
||||
Требование стоит на инварианте проекта «Принятая запись не теряется молча»:
|
||||
захват, разделённый на два шага, отдаёт одну запись двум воркерам, и работа
|
||||
одного из них теряется без следа.
|
||||
|
||||
Признак «работы нет» этим требованием не переопределяется — его нормирует
|
||||
требование «Пустой прогон воркера — не отказ».
|
||||
|
||||
#### Scenario: За работой пришли трое разом
|
||||
|
||||
- **GIVEN** к работе пригодна ровно одна запись
|
||||
- **WHEN** три захвата идут одновременно
|
||||
- **THEN** запись получает ровно один из них
|
||||
- **AND** двое остальных получают признак «работы сейчас нет»
|
||||
|
||||
#### Scenario: Захваченная запись не выдаётся второй раз
|
||||
|
||||
- **GIVEN** запись захвачена и срок захвата не истёк
|
||||
- **WHEN** приходит следующий захват
|
||||
- **THEN** эта запись ему не выдаётся
|
||||
|
||||
#### Scenario: Захват отдаёт идентификатор и свой признак
|
||||
|
||||
- **GIVEN** к работе пригодна запись
|
||||
- **WHEN** воркер её захватывает
|
||||
- **THEN** захват возвращает идентификатор записи и признак этого захвата
|
||||
- **AND** колонки записи шаг читает отдельным чтением
|
||||
|
||||
#### Scenario: Признак перевыданного захвата отличается от прежнего
|
||||
|
||||
- **GIVEN** запись захвачена, и признак первого захвата известен
|
||||
- **WHEN** человек снимает признак остановки, и запись захватывает другой воркер
|
||||
- **THEN** признак нового захвата отличается от признака первого
|
||||
|
||||
### Requirement: Результат пишет только держатель захвата
|
||||
|
||||
Шаг конвейера SHALL записывать свой результат только тогда, когда захват записи
|
||||
всё ещё принадлежит ему. Запись MUST быть условна по **признаку этого захвата** —
|
||||
значению, уникальному для каждого захвата, — а не по занятости записи вообще.
|
||||
Шаг, чей захват за время работы достался другому, MUST завершиться без записи
|
||||
результата.
|
||||
|
||||
Требование закрывает то, чего неделимость захвата не закрывает: захват протухает
|
||||
не только у мёртвого воркера, но и у живого — шаг, идущий дольше своего срока,
|
||||
теряет запись, продолжая работать. Снять захват может и человек, вернувший
|
||||
остановленную запись в работу. Без условия по уникальному признаку два воркера
|
||||
пишут в одну запись по очереди, а счётчик отказов сбрасывает тот, кто уже не
|
||||
владелец.
|
||||
|
||||
Довод про два ответа отправителю из требования ушёл вместе с доставкой: обращений
|
||||
наружу шаг не делает. Требование от этого не ослабло — порча записи двумя
|
||||
пишущими остаётся его предметом целиком.
|
||||
|
||||
Шаг MUST записывать только те поля, которыми распоряжается сам. Запись он держит
|
||||
снимком с момента захвата и до записи — это часы, — и безусловная запись снимка
|
||||
стёрла бы всё, что владелец правил за это время: молча, без строки в журнале и
|
||||
без отказа тому, кто правил. Владелец записи, заголовок, краткое описание и темы
|
||||
конвейер MUST не трогать.
|
||||
|
||||
Возвращает остановленную запись в работу сегодня владелец сервиса подкомандой
|
||||
оснастки — своего экрана для этого у сервиса нет. Норму это не меняет: она
|
||||
написана про поля, а не про того, чьей рукой правка сделана, и переживёт
|
||||
появление экранов.
|
||||
|
||||
#### Scenario: Правка владельца пережила сохранение шага
|
||||
|
||||
- **GIVEN** шаг держит захваченную запись
|
||||
- **AND** владелец за это время изменил поле, которого шаг не касается
|
||||
- **WHEN** шаг записывает свой результат
|
||||
- **THEN** результат шага записан
|
||||
- **AND** правка владельца на месте
|
||||
|
||||
#### Scenario: Захват ушёл под работающим шагом
|
||||
|
||||
- **GIVEN** шаг работает над захваченной записью
|
||||
- **AND** за это время та же запись досталась другому захвату
|
||||
- **WHEN** первый шаг доходит до записи результата
|
||||
- **THEN** результат не записывается
|
||||
|
||||
#### Scenario: Человек снял остановку под работающим шагом
|
||||
|
||||
- **GIVEN** шаг работает над захваченной записью
|
||||
- **AND** человек за это время снял с неё признак остановки, освободив захват
|
||||
- **AND** запись досталась другому воркеру
|
||||
- **WHEN** первый шаг доходит до записи результата
|
||||
- **THEN** результат не записывается
|
||||
|
||||
### Requirement: Остановка записи — признак, а не рубеж
|
||||
|
||||
Сервис SHALL останавливать запись отдельным признаком с причиной и MUST не
|
||||
стирать при этом достигнутый рубеж. Признак MUST нести время остановки, причину
|
||||
и машинный текст отказа.
|
||||
|
||||
Снятие признака SHALL возвращать запись в работу **с того рубежа, где она
|
||||
стояла**, и MUST сбрасывать **всё, чем прошлый прогон её удерживал**:
|
||||
|
||||
- признак остановки — время остановки, причину и машинный текст отказа;
|
||||
- признак захвата и срок его протухания;
|
||||
- число отказов;
|
||||
- паузу перед повтором;
|
||||
- время входа в рубеж.
|
||||
|
||||
Перечень назван целиком и в одном месте, потому что забытое поле не даёт ни
|
||||
отказа, ни строки в журнале. Оставленный признак захвата держит запись занятой до
|
||||
протухания срока и отдаёт её потом чужому шагу — тому, чей результат условен по
|
||||
прежнему значению. Оставленное время входа в рубеж останавливает запись снова
|
||||
первым же захватом, если остановленной она простояла дольше предела, и перезапуск
|
||||
не работает вовсе. Оставленные отказы и пауза откладывают первый же прогон на
|
||||
накопленный срок.
|
||||
|
||||
Возврат в работу MUST идти **через домен**: тот, кто его делает, называет запись,
|
||||
а поля выше сбрасывает домен одним действием. Правка колонок мимо домена
|
||||
повторяет перечень вторым местом, и второе место расходится с первым молча.
|
||||
|
||||
Возврат в работу MUST писать событие журнала записи с происхождением «человек».
|
||||
Иначе запись, побывавшая остановленной и вернувшаяся в работу, неотличима в
|
||||
журнале от записи, которую конвейер вёл без остановок, а происхождение события
|
||||
перестаёт различать что-либо: другого писателя, кроме конвейера, у журнала не
|
||||
остаётся.
|
||||
|
||||
Инструментом возврата сегодня служит подкоманда набора инструментов
|
||||
разработчика: панели у сервиса нет, а экраны владельца приносят отдельные задачи.
|
||||
Норма написана про поля и про домен, а не про инструмент, и появление экрана её
|
||||
не трогает.
|
||||
|
||||
Прежние состояния отказа и смерти MUST не заводиться заново: обе причины
|
||||
восстанавливаются одинаково — снятием признака, — и различие между ними
|
||||
перестаёт быть структурным, оставаясь причиной остановки. Состояние, называющее
|
||||
отказ, стирает достигнутый рубеж, и продолжение с места остановки становится
|
||||
невозможным.
|
||||
|
||||
**Способ вывести запись из выборки MUST быть один — этот признак.** Второго
|
||||
признака, исключающего запись из работы помимо рубежа и паузы, MUST не
|
||||
заводиться: два способа расходятся, и молчаливо теряется тот, который забыли
|
||||
проверить. Условие отбора MUST не выводить запись из выборки молча — запись,
|
||||
переставшая браться в работу, обязана нести признак остановки с причиной.
|
||||
|
||||
Остановку MUST ставить тот, кто запись захватил. Перевод принадлежит одному
|
||||
месту: условие отбора, молча пропускающее запись мимо выборки, оставило бы её
|
||||
без следа.
|
||||
|
||||
Остановленная запись MUST не выдаваться захвату.
|
||||
|
||||
#### Scenario: Остановленная запись продолжает с места остановки
|
||||
|
||||
- **GIVEN** шаг остановил запись на рубеже приведения
|
||||
- **WHEN** признак остановки снимают
|
||||
- **THEN** следующим идёт отправка на распознавание, а не повторное приведение
|
||||
|
||||
#### Scenario: Остановленная запись не выдаётся захвату
|
||||
|
||||
- **GIVEN** у записи стоит признак остановки
|
||||
- **WHEN** за её рубежом приходит захват
|
||||
- **THEN** запись ему не выдаётся
|
||||
|
||||
#### Scenario: Снятие признака сбрасывает всех сторожей
|
||||
|
||||
- **GIVEN** запись остановлена с накопленными отказами и паузой
|
||||
- **AND** остановленной она простояла дольше предела времени в рубеже
|
||||
- **WHEN** признак остановки снимают
|
||||
- **THEN** число отказов, пауза и время входа в рубеж сброшены
|
||||
- **AND** ближайший захват выдаёт запись, а не останавливает её снова
|
||||
|
||||
#### Scenario: Возврат в работу освобождает захват
|
||||
|
||||
- **GIVEN** запись остановлена, и признак прошлого захвата на ней стоит
|
||||
- **WHEN** признак остановки снимают
|
||||
- **THEN** признака захвата и срока его протухания на записи нет
|
||||
- **AND** ближайший захват выдаёт запись, не дожидаясь протухания прежнего срока
|
||||
|
||||
#### Scenario: Возврат в работу виден в журнале записи
|
||||
|
||||
- **GIVEN** запись остановлена
|
||||
- **WHEN** её возвращают в работу
|
||||
- **THEN** в журнале событий записи есть событие с происхождением «человек»
|
||||
|
||||
### Requirement: Конвейер ответа отправителю не шлёт
|
||||
|
||||
Шаг конвейера SHALL доводить запись до достигнутого рубежа и MUST не обращаться
|
||||
к отправителю вовсе — ни с готовым текстом, ни с сообщением о неудаче. Исход
|
||||
своей записи владелец узнаёт **карточкой записи**; адрес карточки и содержимое
|
||||
ответа нормирует capability `archive`.
|
||||
|
||||
Владелец сервиса узнаёт исход журналом и журналом событий записи. Панели, где он
|
||||
видел бы то же строкой таблицы, у сервиса нет — она ушла вместе со встроенным
|
||||
хранилищем, и второго канала наблюдения это не отняло: журнал событий пишется
|
||||
по-прежнему, а читается запросом к базе, пока экрана нет.
|
||||
|
||||
Держатель нормы сменился вместе с убранным опросом готовности: прежде исход
|
||||
отдавал адрес опроса, нормированный capability `intake`, и адреса этого больше
|
||||
нет. Обязанность при этом не изменилась — изменилось только то, каким адресом
|
||||
она исполняется.
|
||||
|
||||
Требование заведено взамен доставки в чат, убранной вместе с входом Telegram.
|
||||
Без него молчание конвейера читалось бы как недоделка: прежде ответ уходил, и
|
||||
всякий, кто помнит это, ищет в шаге отправку, а её отсутствие принимает за
|
||||
потерянную ветку.
|
||||
|
||||
Инвариант проекта «Принятая запись не теряется молча» держится теперь карточкой
|
||||
записи — там остановка видна признаком и причиной — и журналом владельца, где у
|
||||
неё стоит причина. Обязанность при этом сменила направление: прежде об отказе
|
||||
сообщали, теперь отказ доступен спросившему. Отправитель, который не
|
||||
спрашивает, об остановке не узнаёт.
|
||||
|
||||
Записи, которой этот канал недоступен, не бывает: у каждой записи есть владелец,
|
||||
и карточка отдаёт ему её исход. Держится это обязательностью владельца в схеме —
|
||||
норму держит capability `storage`.
|
||||
|
||||
#### Scenario: Готовый текст отправителю не уходит
|
||||
|
||||
- **GIVEN** запись дошла до конечного рубежа
|
||||
- **WHEN** шаг конвейера её завершает
|
||||
- **THEN** ни одного обращения наружу с текстом расшифровки не уходит
|
||||
- **AND** текст достаётся отдельным адресом текста записи
|
||||
|
||||
#### Scenario: Остановка видна карточкой, а не сообщением
|
||||
|
||||
- **GIVEN** запись остановлена по исчерпании отказов
|
||||
- **WHEN** владелец записи спрашивает её карточку
|
||||
- **THEN** ответ несёт достигнутый рубеж, признак остановки и её причину
|
||||
- **AND** в журнале владельца сервиса есть запись об остановке с причиной
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Сырой ответ провайдера сохраняется целиком
|
||||
|
||||
Сервис SHALL сохранять ответ распознавателя целиком, в том виде, в каком он
|
||||
пришёл, и MUST хранить его **отдельным файлом в каталоге данных**, а не колонкой
|
||||
строки попытки.
|
||||
|
||||
Хранится он потому, что **результат операции у провайдера не переспрашивается**:
|
||||
связь реплики с говорящим сервис строить пока не умеет, и когда научится, архив
|
||||
пересчитается из сохранённого без повторной оплаты.
|
||||
|
||||
Файлом, а не колонкой, — потому что шаг опроса читает строку попытки часто, а
|
||||
репозиторий читает строку целиком: ответ на многочасовую запись, положенный
|
||||
колонкой, ехал бы в память при каждом опросе. Где именно этот файл лежит,
|
||||
нормирует capability `storage`, требование «Файл записи живёт в хранилище»:
|
||||
третьим файлом в подкаталоге записи, наравне с копиями аудио. Копией аудио он при
|
||||
этом не считается — их у записи по-прежнему две.
|
||||
|
||||
Чтение строки попытки шагом опроса MUST не тянуть за собой сохранённый ответ.
|
||||
|
||||
Сохранённый ответ — это полный текст речи, и закрыт он MUST быть наравне с самой
|
||||
записью. Закрытость MUST держаться **проверкой владельца в обработчике сервиса**:
|
||||
адреса, которым сохранённый ответ читают снаружи, сервис MUST не заводить вовсе, а
|
||||
всякий адрес, отдающий содержимое записи, MUST судить владельца связанной
|
||||
аудиозаписи сам, при каждом обращении. Пометка поля защищённым и правило
|
||||
просмотра коллекции, которыми закрытость держалась прежде, — механизмы
|
||||
встроенного хранилища, и их не остаётся; норма от этого не ослабла, а перестала
|
||||
зависеть от настройки, которую мы не писали.
|
||||
|
||||
Путь к файлу сохранённого ответа MUST не попадать ни в журнал, ни в метку
|
||||
метрики, ни в ответ отправителю.
|
||||
|
||||
Норму держит capability `storage`, требование «Содержимое записи закрыто везде,
|
||||
где лежит»; здесь она названа потому, что попытка распознавания — то место, куда
|
||||
содержимое приезжает впервые.
|
||||
|
||||
#### Scenario: Ответ сохранён и читается позже
|
||||
|
||||
- **GIVEN** распознавание завершилось и ответ провайдера получен
|
||||
- **WHEN** запись доходит до конечного рубежа
|
||||
- **THEN** сохранённый ответ доступен по строке попытки целиком
|
||||
|
||||
#### Scenario: Опрос не тянет сохранённый ответ
|
||||
|
||||
- **GIVEN** у попытки распознавания есть сохранённый ответ
|
||||
- **WHEN** шаг опроса читает строку попытки
|
||||
- **THEN** сохранённый ответ в память при этом не читается
|
||||
|
||||
#### Scenario: Адреса чтения сохранённого ответа у сервиса нет
|
||||
|
||||
- **GIVEN** запись принята одним узнанным и прошла распознавание
|
||||
- **WHEN** другой узнанный ищет адрес, которым читается сохранённый ответ этой
|
||||
записи
|
||||
- **THEN** такого адреса у сервиса нет
|
||||
- **AND** содержимого он не получает
|
||||
@@ -0,0 +1,743 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: База принимает одного писателя
|
||||
|
||||
Сервис SHALL держать у базы **одно** соединение для записи, а чтение MUST вести
|
||||
отдельно от него. Журнал упреждающей записи MUST быть включён, принудительное
|
||||
соблюдение внешних ключей MUST быть включено, а ожидание занятой базы MUST
|
||||
задаваться числом, а не оставаться умолчанием драйвера.
|
||||
|
||||
Все три настройки MUST задаваться **строкой подключения обоих пулов** — и
|
||||
пишущего, и читающего, — а не отдельным запросом после открытия. Соблюдение
|
||||
внешних ключей в SQLite — настройка соединения, а не базы, и по умолчанию она
|
||||
выключена: `PRAGMA foreign_keys` на свежем соединении отвечает `0`. Пул раздаёт
|
||||
соединения и заводит новые по мере надобности, поэтому запрос, выполненный один
|
||||
раз после открытия, настраивает одно соединение из многих, а остальные остаются с
|
||||
умолчанием — молча. На включённых внешних ключах держатся требования «Учётная
|
||||
запись с записями не удаляется», «Владелец, которого нет, не принимается» и «Файл
|
||||
без владельца не сохраняется»: с выключенными все три зеленеют на том соединении,
|
||||
где настройку успели поставить, и не работают на соседнем.
|
||||
|
||||
Требование заводится потому, что эту настройку прежде держала за нас чужая
|
||||
библиотека двумя пулами. Драйвер пишет единственным соединением: несколько
|
||||
воркеров, пишущих разом мимо этого правила, получают отказ «база занята» — и
|
||||
получают его на записи результата шага, то есть после оплаченной работы.
|
||||
|
||||
**Всякая операция, которая читает и следом пишет, MUST идти целиком на пишущем
|
||||
соединении** — и чтение, и запись, и объемлющая их транзакция. Транзакция,
|
||||
начатая на читающем соединении и позже пытающаяся писать, получает отказ по
|
||||
занятости **немедленно**: повысить начатую читающую транзакцию до пишущей SQLite
|
||||
не даёт, и заданное числом ожидание такой отказ не лечит — ждать там нечего. Под
|
||||
правило подпадают захват записи и накат шага схемы: каждый читает состояние,
|
||||
которое сам же меняет.
|
||||
|
||||
**Узнавание под правило не подпадает, и устроено оно двумя соединениями.**
|
||||
Поиск учётной записи по логину у провайдера MUST идти читающим пулом, а пишущая
|
||||
транзакция MUST открываться только тогда, когда запись не нашлась. Слой
|
||||
узнавания одет на весь узнанный поток — опрос карточки и каждый запрос диапазона
|
||||
при проигрывании, — а заводится учётная запись один раз за жизнь человека:
|
||||
пишущая транзакция, взятая до поиска, ставила бы весь этот поток в очередь к
|
||||
единственному пишущему соединению. Очередь эта ожиданием занятой базы не
|
||||
ограничена и отказом не кончается — обращение просто ждёт, и сотни миллисекунд
|
||||
ожидания видны только замером.
|
||||
|
||||
Окно между двумя соединениями MUST закрываться **повторным поиском внутри
|
||||
транзакции**: пока её ждали, запись успевает завести сосед, и найденную надо
|
||||
взять, а не заводить вторую. Уникальность ключа при этом держит схема, а не
|
||||
порядок обращений.
|
||||
|
||||
Значения ожидания и числа соединений MUST жить там, где проект держит числовые
|
||||
настройки, и MUST не повторяться второй константой рядом.
|
||||
|
||||
#### Scenario: Несколько воркеров пишут разом
|
||||
|
||||
- **GIVEN** число рабочих потоков конвейера больше одного
|
||||
- **AND** все они дошли до записи своего результата одновременно
|
||||
- **WHEN** результаты записываются
|
||||
- **THEN** каждый записан, и ни один не отказал по занятости базы
|
||||
|
||||
#### Scenario: Настройки базы применены при подъёме
|
||||
|
||||
- **WHEN** сервис поднялся на чистом каталоге данных
|
||||
- **THEN** у базы включён журнал упреждающей записи
|
||||
- **AND** ожидание занятой базы равно объявленному числу
|
||||
|
||||
#### Scenario: Внешние ключи включены на соединении читающего пула
|
||||
|
||||
- **GIVEN** сервис поднялся на чистом каталоге данных
|
||||
- **WHEN** соединение берут из читающего пула и спрашивают у него `PRAGMA
|
||||
foreign_keys`
|
||||
- **THEN** ответ — `1`
|
||||
|
||||
#### Scenario: Узнавание известного не ждёт писателя
|
||||
|
||||
- **GIVEN** учётная запись с этим логином уже заведена
|
||||
- **AND** пишущее соединение занято открытой транзакцией
|
||||
- **WHEN** приходит следующее обращение тем же логином
|
||||
- **THEN** учётная запись узнана, и обращение не ждёт освобождения писателя
|
||||
- **AND** второй учётной записи не заведено
|
||||
|
||||
#### Scenario: Составная операция не отказывает по занятости
|
||||
|
||||
- **GIVEN** число рабочих потоков конвейера больше одного
|
||||
- **AND** каждый выполняет операцию, которая читает состояние записи и следом его
|
||||
пишет
|
||||
- **WHEN** операции идут одновременно
|
||||
- **THEN** каждая завершена, и ни одна не отказала по занятости базы
|
||||
|
||||
### Requirement: Время и идентификаторы приходят из одного места
|
||||
|
||||
Хранилище SHALL держать **все** колонки времени одним представлением: `TEXT` в
|
||||
RFC 3339, UTC, с суффиксом `Z` и секундной точностью — `2006-01-02T15:04:05Z`.
|
||||
Второго вида времени в схеме MUST не заводиться, включая колонки, которые пишет
|
||||
только сам сервис.
|
||||
|
||||
Ширина такой записи постоянная, поэтому лексикографический порядок `TEXT`
|
||||
совпадает с хронологией, и отбор по колонке времени работает без разбора
|
||||
значения. Своего типа времени у SQLite нет: колонка хранит то, что в неё
|
||||
положили, а отбор захвата сравнивает строки — колонка, заполненная то одним
|
||||
видом, то другим, обращает условие срока протухания в постоянную истину или ложь
|
||||
молча, и запись не выдаётся ни одному воркеру никогда.
|
||||
|
||||
**Время ставит приложение, а не умолчание шага схемы**, и берёт оно его из единой
|
||||
точки чтения времени, которую держит линтер проекта. Умолчаний вида
|
||||
`CURRENT_TIMESTAMP` в схеме MUST не заводиться. Выбрано так по двум причинам:
|
||||
умолчание схемы пишет свой вид времени, отличный от объявленного выше, и вставка,
|
||||
забывшая проставить время, при умолчании проходит молча, а без него падает
|
||||
громко. Прежнее расхождение — вид времени задавало встроенное хранилище своим
|
||||
форматом с пробелом и долями секунды — уходит вместе с ним, и правило остаётся
|
||||
одно.
|
||||
|
||||
Идентификатор строки SHALL быть **ULID в нижнем регистре, колонкой `TEXT`**, и
|
||||
ставить его MUST приложение единой точкой при заведении строки. Это то, что
|
||||
конвенция проекта объявляет нормой; расхождение, при котором идентификаторы
|
||||
выдавало встроенное хранилище собственным алфавитом, уходит вместе с ним.
|
||||
Идентификатор, пришедший снаружи, MUST разбираться на границе — разбор проверяет
|
||||
вид и приводит регистр, — а каким кодом отвечает негодный, нормирует capability
|
||||
`archive`.
|
||||
|
||||
#### Scenario: Вид времени один на все колонки
|
||||
|
||||
- **GIVEN** сервис поднялся на чистом каталоге данных
|
||||
- **WHEN** смотрят колонки времени в применённой схеме
|
||||
- **THEN** все они объявлены одним типом и несут время одним видом
|
||||
- **AND** умолчания времени ни у одной из них нет
|
||||
|
||||
#### Scenario: Строка из приёма и строка из запроса к базе отбираются одинаково
|
||||
|
||||
- **GIVEN** одна аудиозапись заведена приёмом, а вторая — запросом к базе руками
|
||||
- **AND** обе стоят на одном рубеже и пригодны к захвату
|
||||
- **WHEN** воркеры разбирают очередь
|
||||
- **THEN** захвату выдаются обе
|
||||
- **AND** ни одна не остаётся в очереди навсегда
|
||||
|
||||
|
||||
### Requirement: Содержимое записи закрыто везде, где лежит
|
||||
|
||||
Всякая таблица, куда переезжает содержимое аудиозаписи, SHALL быть закрыта
|
||||
наравне с самой записью: сервис MUST не заводить ни одного адреса, которым её
|
||||
строки перечисляются или читаются мимо проверки владельца связанной записи.
|
||||
|
||||
Требование распространяется на все приложения записи — тексты, структуру реплик,
|
||||
попытку распознавания с её сохранённым ответом, журнал событий и темы — и
|
||||
заводится потому, что содержимое лежит не в одной строке, а в нескольких.
|
||||
Правило одно на все: записанное у одного хранителя, у остальных оно читалось бы
|
||||
как снятое.
|
||||
|
||||
Сохранённый ответ провайдера — это полный текст речи, и он MUST быть закрыт
|
||||
наравне с расшифровкой, а не считаться служебным вложением. Где именно он лежит,
|
||||
нормирует требование «Файл записи живёт в хранилище»: третьим файлом в
|
||||
подкаталоге записи.
|
||||
|
||||
Ссылка или путь, по которому содержимое лежит на диске, MUST не попадать ни в
|
||||
журнал, ни в метку метрики, ни в ответ отправителю — теми же словами, какими это
|
||||
нормировано для файла записи.
|
||||
|
||||
Требование заменяет прежнее «Содержимое записи закрыто во всех коллекциях, где
|
||||
лежит»: правил доступа у коллекций и защищённых полей больше нет, а закрытость
|
||||
держится тем, что адреса чтения содержимого пишет сервис и каждый из них судит
|
||||
владельца.
|
||||
|
||||
#### Scenario: Чужой сохранённый ответ не отдаётся
|
||||
|
||||
- **GIVEN** запись принята одним узнанным и прошла распознавание
|
||||
- **WHEN** другой узнанный просит сохранённый ответ провайдера по этой записи
|
||||
- **THEN** содержимого он не получает
|
||||
|
||||
#### Scenario: Неузнанному содержимое не отдаётся
|
||||
|
||||
- **WHEN** содержимое записи запрашивают неузнанным
|
||||
- **THEN** приходит отказ, а содержимого в ответе нет
|
||||
|
||||
#### Scenario: Перечисления приложений записи не существует
|
||||
|
||||
- **WHEN** ищут адрес, которым перечисляются строки текстов, реплик или попыток
|
||||
распознавания
|
||||
- **THEN** такого адреса у сервиса нет
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Сервис поднимается на чистом каталоге данных
|
||||
|
||||
Сервис SHALL приводить хранилище в рабочий вид сам: на пустом каталоге данных он
|
||||
MUST завести свою схему и принимать записи своим входом — приёмом по HTTP — без
|
||||
единого ручного шага до первого запуска.
|
||||
|
||||
Прежние данные не переносятся. Каталог, оставшийся от прежней раскладки, MUST не
|
||||
читаться и не считаться источником: сервис начинает с чистого листа, и это
|
||||
решение задачи, а не следствие отказа.
|
||||
|
||||
Схема MUST заводиться версионированными шагами, а применённый шаг MUST не
|
||||
переписываться — только новым шагом. Иначе повторный запуск на уже заведённом
|
||||
каталоге разошёлся бы с первым молча. Применённые шаги MUST учитываться самой
|
||||
базой, а не порядком файлов на диске.
|
||||
|
||||
Применение шага и запись отметки о нём MUST идти **одной транзакцией**. Процесс,
|
||||
оборванный между ними, оставляет базу со шагом, который применён и не отмечен, а
|
||||
следующий запуск применяет его второй раз — и второе применение отказывает на
|
||||
заведённой таблице, роняя старт на шаге, который на самом деле цел.
|
||||
|
||||
Накат MUST держаться **исключающей блокировкой базы** на всё своё время: второй
|
||||
процесс, поднятый на том же каталоге данных, MUST ждать её освобождения либо
|
||||
отказать, а не применять шаги параллельно. Каталог данных один, а запусков на нём
|
||||
бывает два — старый экземпляр ещё не остановлен, новый уже поднят, — и два
|
||||
наката, разошедшихся на одном шаге, оставляют схему в состоянии, которого не
|
||||
описывает ни один шаг.
|
||||
|
||||
Порядок шагов MUST быть детерминирован и выводиться из **версии самого шага**, а
|
||||
не из порядка чтения каталога: порядка обхода файловая система не обещает, а
|
||||
разошедшийся порядок шагов виден только на чистой базе, которую заводят один раз.
|
||||
Две одинаковых версии MUST давать отказ, а не молчаливый выбор одного из шагов.
|
||||
|
||||
**Схема MUST накатываться до подъёма входов и до старта воркеров**, а отказ шага
|
||||
MUST ронять старт. Сервис, поднявшийся на неприведённой схеме, отвечает отказом
|
||||
на каждый запрос и на каждый прогон воркера — вместо одной строки о причине их
|
||||
становятся сотни, и первопричина в них теряется.
|
||||
|
||||
Каталог данных у сервиса MUST быть один: база и файлы записей лежат под ним
|
||||
вместе, и второго пути к ним не заводится.
|
||||
|
||||
#### Scenario: Первый запуск на пустом каталоге
|
||||
|
||||
- **GIVEN** каталог данных пуст
|
||||
- **WHEN** сервис запускается
|
||||
- **THEN** он заводит своё хранилище и продолжает работу
|
||||
- **AND** принятая следом запись доходит до состояния `done`
|
||||
|
||||
#### Scenario: Повторный запуск на заведённом каталоге
|
||||
|
||||
- **GIVEN** сервис уже запускался на этом каталоге и завёл хранилище
|
||||
- **WHEN** он запускается снова
|
||||
- **THEN** он не заводит схему второй раз и не теряет прежних записей
|
||||
|
||||
#### Scenario: Схема накатана до первой строки о готовности
|
||||
|
||||
- **GIVEN** каталог данных пуст
|
||||
- **WHEN** сервис запускается
|
||||
- **THEN** до строки журнала о готовности схема приведена целиком
|
||||
- **AND** ни одного отказа в журнале до неё нет
|
||||
|
||||
#### Scenario: Старт, оборванный между шагом и отметкой о нём
|
||||
|
||||
- **GIVEN** запуск оборван после применения шага схемы и до записи отметки о нём
|
||||
- **WHEN** сервис запускается снова
|
||||
- **THEN** исход тот же, что и у необорванного запуска, либо отказ, называющий
|
||||
шаг
|
||||
- **AND** шаг не применяется второй раз
|
||||
|
||||
#### Scenario: Отказ шага схемы роняет старт
|
||||
|
||||
- **GIVEN** шаг схемы не применяется
|
||||
- **WHEN** сервис запускается
|
||||
- **THEN** старт кончается отказом, называющим шаг
|
||||
- **AND** ни один вход не поднят
|
||||
|
||||
### Requirement: Файл записи живёт в хранилище
|
||||
|
||||
Сервис SHALL держать файл записи в своём каталоге данных, и раскладку этого
|
||||
каталога MUST задавать он сам. Файл MUST адресоваться записью, которой
|
||||
принадлежит, а не путём на диске: шаг конвейера просит файл у записи и получает
|
||||
его, ничего не зная о раскладке.
|
||||
|
||||
Раскладка MUST держать копии одной записи вместе — под её идентификатором, — и
|
||||
MUST давать убрать запись целиком одним движением, не перебирая имена по маске.
|
||||
Плоского каталога, где копии различаются приставкой в имени, MUST не
|
||||
заводиться.
|
||||
|
||||
Содержимое записи MUST не читаться в память целиком ни при укладке, ни при
|
||||
чтении: расчётный потолок записи — шесть часов, и такая запись в память не
|
||||
помещается.
|
||||
|
||||
**Укладка MUST быть атомарной:** содержимое пишется во временное имя **в том же
|
||||
подкаталоге записи** и переименовывается в рабочее только после того, как поток
|
||||
дочитан до конца без отказа. Временное имя берётся в том же каталоге потому, что
|
||||
переименование в его пределах не копирует содержимое и не может оборваться на
|
||||
середине.
|
||||
|
||||
Порядок MUST быть один: строка о файле заводится **после** того, как содержимое
|
||||
лежит целиком под рабочим именем. Обратный порядок оставляет в базе строку,
|
||||
указывающую на файл, которого ещё нет или который короче принятого.
|
||||
|
||||
Средство обнаружить усечение у сервиса одно, и оно снято намеренно: величины
|
||||
записи со строкой файла не сверяются — так требует «Аудиозапись — центральная
|
||||
сущность хранилища», — а другого не заведено. Усечённая запись поэтому уезжает в
|
||||
конвейер, оплачивает распознавание и отдаёт расшифровку половины как готовый
|
||||
результат. Атомарная укладка — единственное, что этого не допускает.
|
||||
|
||||
Отсюда две нормы о неудачах:
|
||||
|
||||
- содержимое легло, а сохранение самой аудиозаписи отказало — уложенный файл MUST
|
||||
быть убран, и строки о нём MUST не остаться. Файл, переживший свою запись, —
|
||||
штатное состояние только у приведённой копии, которую заводит шаг конвейера; у
|
||||
принятой копии это мусор, на который не ссылается ничто и о котором узнать
|
||||
неоткуда;
|
||||
- отмена контекста посреди укладки MUST кончаться тем же исходом, что и отказ
|
||||
источника: временного имени не остаётся, рабочего имени не появляется, строки о
|
||||
файле нет. Записи, наполовину принятой, человек не видит.
|
||||
|
||||
Сохранённый ответ провайдера распознавания MUST лежать **третьим файлом в том же
|
||||
подкаталоге записи**, под именем, которое задаёт сервис. Колонкой строки попытки
|
||||
он ехал бы в память при каждом опросе готовности — этого capability `recognition`
|
||||
избегает намеренно; отдельной таблицей он завёл бы второй путь чтения содержимого
|
||||
и остался бы в базе, которую сервис держит узкой. Третьим файлом он попадает под
|
||||
ту же атомарную укладку и под ту же уборку записи одним движением, что и копии
|
||||
аудио.
|
||||
|
||||
Копией аудио сохранённый ответ при этом MUST не считаться: копий у записи
|
||||
по-прежнему две — принятая и приведённая, — и перечень копий, которые сервис
|
||||
отдаёт адресом приложения, этим не расширяется. Адреса, которым сохранённый ответ
|
||||
читают снаружи, у сервиса нет вовсе.
|
||||
|
||||
**Потолок размера записи MUST быть задан числом, выведенным из этого расчётного
|
||||
потолка**, и задан он MUST быть везде, где иначе действует умолчание: и у тела
|
||||
запроса приёма, и у всякого предела, который сервис ставит сам. Умолчания здесь
|
||||
не «без предела», а величины на два-три порядка меньше нужного, и оставленные как
|
||||
есть они отвергают штатную запись сервиса — приём отказывает, а уже принятая
|
||||
запись исчерпывает попытки на шаге конвертации.
|
||||
|
||||
Отказ по этому потолку MUST быть виден отправителю ответом, а не молчанием.
|
||||
|
||||
Шаги, которым нужен файл именем на диске — конвертация и чтение метаданных
|
||||
отдают его внешней программе, — MUST получать рабочую копию **одним общим
|
||||
способом**, и у этого способа MUST быть единственный способ её убрать. Уборку
|
||||
зовёт шаг, и звать её он MUST на любом исходе, включая отказ. Заводить копию по
|
||||
месту шагам MUST не приходиться: иначе обязанность прибрать переписывается
|
||||
столько раз, сколько шагов, а забытая копия — это шестичасовая запись,
|
||||
оставшаяся во временном каталоге, и узнать о ней неоткуда.
|
||||
|
||||
#### Scenario: Принятая запись легла в каталог данных
|
||||
|
||||
- **WHEN** запись принята
|
||||
- **THEN** её файл лежит в каталоге данных сервиса и связан со своей записью
|
||||
- **AND** второго каталога записей рядом не появляется
|
||||
|
||||
#### Scenario: Копии одной записи лежат вместе
|
||||
|
||||
- **GIVEN** запись принята, приведена к рабочему формату и прошла распознавание
|
||||
- **WHEN** смотрят, где лежат её файлы
|
||||
- **THEN** принятая копия, приведённая копия и сохранённый ответ провайдера лежат
|
||||
под идентификатором этой записи
|
||||
- **AND** второго места, где лежит что-то из них, нет
|
||||
|
||||
#### Scenario: Источник оборвался посреди потока
|
||||
|
||||
- **GIVEN** отправитель шлёт запись и обрывает поток на середине
|
||||
- **WHEN** укладка отказывает
|
||||
- **THEN** строки о файле не заведено
|
||||
- **AND** ни файла под рабочим именем, ни временного имени в подкаталоге записи
|
||||
не остаётся
|
||||
|
||||
#### Scenario: Запись длиннее умолчания принимается
|
||||
|
||||
- **WHEN** сервису отдают запись длиннее всякого умолчания, действующего на пути
|
||||
приёма
|
||||
- **THEN** она ложится в каталог данных, а не отвергается
|
||||
|
||||
#### Scenario: Шаг конвейера берёт файл по записи
|
||||
|
||||
- **GIVEN** запись принята и её файл лежит в каталоге данных
|
||||
- **WHEN** шаг конвейера берётся за эту запись
|
||||
- **THEN** он получает файл по самой записи, а не по пути на диске
|
||||
|
||||
#### Scenario: Рабочая копия убрана после отказа шага
|
||||
|
||||
- **GIVEN** шагу выдана рабочая копия файла
|
||||
- **WHEN** шаг завершается отказом
|
||||
- **THEN** рабочей копии во временном каталоге не остаётся
|
||||
|
||||
### Requirement: Файл отдаётся ссылкой
|
||||
|
||||
Сервис SHALL отдавать файл записи **только её владельцу** и MUST судить владельца
|
||||
сам, при каждом обращении. Отданный файл MUST совпадать с принятым по длине.
|
||||
|
||||
Значения, дающего право пройти по ссылке, сервис MUST не выдавать: ни короткого
|
||||
токена файла, ни подписанной ссылки со сроком. Право даёт узнавание пришедшего и
|
||||
владение записью, и судится оно там же, где отдаётся файл. Отзыв доступа доходит
|
||||
до файла сразу, а не через срок жизни выданного значения.
|
||||
|
||||
Обращение к файлу чужой записи MUST быть неотличимо от обращения к
|
||||
несуществующей — тем же кодом и тем же телом. Разница ответов превратила бы
|
||||
чтение в перебор заведённых записей.
|
||||
|
||||
Каким адресом файл уходит и как называется вид копии, нормирует capability
|
||||
`archive`: там живут адреса приложения, и держатель нормы обязан быть один.
|
||||
|
||||
Конвейер расшифровки этим не затронут: он читает файл из каталога данных, а не
|
||||
по адресу приложения.
|
||||
|
||||
**Путь, по которому файл лежит на диске, MUST не попадать ни в журнал, ни в
|
||||
метку метрики, ни в ответ отправителю.** Имя, под которым файл лёг в каталог, из
|
||||
журнала выводимо быть не должно: журнал уезжает в собранные логи, откуда строку
|
||||
не убрать.
|
||||
|
||||
Отсюда требование к отказам: сообщение об отказе чтения или укладки MUST не
|
||||
называть ключ файла и путь к нему дословно, а отказ выгрузки во внешнее
|
||||
хранилище MUST не называть полного адреса объекта. И то и другое кончается в
|
||||
журнале и собирает ссылку не хуже успешного пути.
|
||||
|
||||
Что именно журнал приёма пишет ради прослеживаемости, нормирует capability
|
||||
`intake`.
|
||||
|
||||
#### Scenario: Владелец забирает свой файл
|
||||
|
||||
- **GIVEN** запись принята и её файл лежит в каталоге данных
|
||||
- **WHEN** владелец записи просит её файл
|
||||
- **THEN** приходит тот же файл, и его длина совпадает с длиной принятого
|
||||
|
||||
#### Scenario: Неузнанному файл не отдаётся
|
||||
|
||||
- **GIVEN** запись принята и её файл лежит в каталоге данных
|
||||
- **WHEN** файл просят неузнанным
|
||||
- **THEN** приходит отказ, а содержимого записи в ответе нет
|
||||
|
||||
#### Scenario: Значения на предъявителя сервис не выдаёт
|
||||
|
||||
- **GIVEN** человек узнан
|
||||
- **WHEN** ищут адрес, которым сервис выдаёт значение, открывающее файл
|
||||
- **THEN** такого адреса у сервиса нет
|
||||
|
||||
#### Scenario: Конвейер читает файл без узнавания
|
||||
|
||||
- **GIVEN** запись принята и ждёт расшифровки
|
||||
- **WHEN** шаг конвейера берётся за неё
|
||||
- **THEN** файл читается из каталога данных и шаг проходит
|
||||
|
||||
#### Scenario: Файл записи, которой нет
|
||||
|
||||
- **WHEN** просят файл записи с неизвестным идентификатором
|
||||
- **THEN** приходит отказ, а не пустой ответ
|
||||
|
||||
#### Scenario: По журналу путь к файлу не собрать
|
||||
|
||||
- **GIVEN** запись принята и прошла конвейер
|
||||
- **WHEN** читают журнал сервиса целиком
|
||||
- **THEN** имени, под которым файл лёг в каталог данных, в нём нет
|
||||
|
||||
#### Scenario: Отказ чтения файла не называет его ключ
|
||||
|
||||
- **GIVEN** файл записи не читается с диска
|
||||
- **WHEN** шаг конвейера берётся за эту запись и отказывает
|
||||
- **THEN** отказ называет запись её идентификатором и не несёт имени файла
|
||||
|
||||
### Requirement: Владелец задачи лежит связью с учётной записью
|
||||
|
||||
Хранилище SHALL держать владельца аудиозаписи отдельной колонкой — связью с
|
||||
учётной записью, — и эта колонка MUST не иметь умолчания: запись, чей владелец
|
||||
не назван, не достаётся никому по недосмотру схемы.
|
||||
|
||||
Колонка MUST не допускать пустого значения, и MUST это держать сама схема: связь
|
||||
объявлена внешним ключом на учётную запись и обязательна. Пока обязательность
|
||||
жила в одном приёме, ничью запись заводили руками мимо него, она уходила в
|
||||
конвейер, стоила денег на распознавание и не доставалась потом никому.
|
||||
|
||||
Правку записи мимо адресов приложения сервис ничем не предоставляет: панели у
|
||||
него нет. Обязательность от этого не отменяется — она перестала зависеть от того,
|
||||
кто пишет, и стала свойством схемы.
|
||||
|
||||
Владелец MUST не назначаться и не меняться конвейером.
|
||||
|
||||
#### Scenario: Колонка появляется на пустой базе
|
||||
|
||||
- **WHEN** сервис поднимается на чистом каталоге данных
|
||||
- **THEN** у аудиозаписи есть колонка владельца
|
||||
- **AND** умолчания у неё нет
|
||||
- **AND** пустого значения она не принимает
|
||||
|
||||
#### Scenario: Запись без владельца не сохраняется
|
||||
|
||||
- **GIVEN** сервис поднят
|
||||
- **WHEN** аудиозапись пытаются сохранить с пустым владельцем — приёмом,
|
||||
конвейером или запросом к базе
|
||||
- **THEN** база её не сохраняет
|
||||
|
||||
#### Scenario: Владелец, которого нет, не принимается
|
||||
|
||||
- **GIVEN** сервис поднят
|
||||
- **WHEN** аудиозапись пытаются сохранить с владельцем, которому не отвечает ни
|
||||
одна учётная запись
|
||||
- **THEN** база её не сохраняет
|
||||
|
||||
#### Scenario: Конвейер владельца не назначает
|
||||
|
||||
- **GIVEN** запись с владельцем прошла шаг конвейера
|
||||
- **WHEN** смотрят её владельца
|
||||
- **THEN** он прежний
|
||||
|
||||
### Requirement: Файл записи сужается владельцем наравне с задачей
|
||||
|
||||
Хранилище SHALL держать владельца и у файла записи — той же связью с учётной
|
||||
записью, — а отдача файла MUST пускать к нему только его владельца.
|
||||
|
||||
Владелец файла MUST назначаться при приёме, из узнанного предъявителя, а колонка
|
||||
файла MUST не допускать пустого значения наравне с колонкой записи. Разное
|
||||
правило у записи и у её файла читалось бы как недосмотр.
|
||||
|
||||
Файл, заведённый шагом конвейера, — приведённую копию заводит именно он —
|
||||
MUST получать владельца своей записи. Иного источника владельца у файла нет, и
|
||||
шаг, оставивший его пустым, упрётся в отказ сохранения: запись накопит отказы и
|
||||
остановится признаком на первом же приведении.
|
||||
|
||||
Ссылки на файлы у записи две — на принятую копию и на приведённую, — и обе живут
|
||||
до конца, но владелец файла MUST по-прежнему лежать своей колонкой, а не
|
||||
выводиться через запись: файл переживает свою запись, и заведённый шагом до
|
||||
сохранения записи он остаётся с владельцем и без ссылки.
|
||||
|
||||
Отказ наступает **на самом обращении за файлом**: другого места, где он мог бы
|
||||
наступить, у сервиса не осталось — значений на предъявителя он не выдаёт.
|
||||
Проверка, судящая владельца где-то ещё, зеленела бы, не касаясь пути, по
|
||||
которому аудио и уходит.
|
||||
|
||||
#### Scenario: Чужой файл не отдаётся
|
||||
|
||||
- **GIVEN** запись принята одним узнанным
|
||||
- **WHEN** другой узнанный просит файл этой записи
|
||||
- **THEN** содержимого он не получает
|
||||
- **AND** ответ тот же, что и на неизвестный идентификатор записи
|
||||
|
||||
#### Scenario: Свой файл отдаётся
|
||||
|
||||
- **GIVEN** человек принял запись
|
||||
- **WHEN** он просит файл своей записи
|
||||
- **THEN** содержимое отдаётся
|
||||
|
||||
#### Scenario: Файл без владельца не сохраняется
|
||||
|
||||
- **GIVEN** сервис поднят
|
||||
- **WHEN** файл записи пытаются сохранить с пустым владельцем
|
||||
- **THEN** база его не сохраняет
|
||||
|
||||
#### Scenario: Приведённая копия получает владельца записи
|
||||
|
||||
- **GIVEN** запись с владельцем дошла до приведения
|
||||
- **WHEN** шаг заводит приведённую копию файла
|
||||
- **THEN** владельцем копии стоит владелец записи
|
||||
- **AND** шаг завершается без отказа
|
||||
|
||||
### Requirement: Учётная запись с записями не удаляется
|
||||
|
||||
Хранилище SHALL отвергать удаление учётной записи, у которой остались
|
||||
аудиозаписи, файлы **либо темы словаря**, и MUST держать этот запрет самой
|
||||
схемой — обязательной связью, которая не даёт убрать строку, пока на неё
|
||||
ссылаются.
|
||||
|
||||
Считаются **все** таблицы с колонкой владельца, и перечень их MUST жить одним
|
||||
местом — шагом схемы, который эти связи объявляет. Таблица, пропущенная в счёте,
|
||||
пропускает удаление вперёд и оставляет за собой строки, чей владелец больше не
|
||||
существует.
|
||||
|
||||
Запрет схемой, а не проверкой вызывающего, — потому что вызывающих у удаления
|
||||
может стать больше одного, а проверка, записанная у одного, у остальных читалась
|
||||
бы как снятая. Сборка, забывшая позвать проверку, теряет защиту молча — и теряла.
|
||||
|
||||
Файл переживает свою запись: шаг конвейера заводит его до сохранения записи, и
|
||||
потерянный захват оставляет файл с владельцем и без ссылки. Тема переживает её
|
||||
так же: словарь принадлежит человеку, а не записи.
|
||||
|
||||
Цена требования названа прямо: способа удалить записи в сервисе пока нет вовсе —
|
||||
его приносит задача про удаление записи. До неё удаление учётной записи с
|
||||
записями невозможно, и это осознанный тупик, а не недосмотр. Адреса, которым
|
||||
учётную запись удаляют, у сервиса при этом нет: запрет закрывает удаление
|
||||
запросом к базе.
|
||||
|
||||
#### Scenario: Удаление учётной записи с записями отвергается
|
||||
|
||||
- **GIVEN** у учётной записи есть аудиозаписи
|
||||
- **WHEN** её строку удаляют
|
||||
- **THEN** удаление не проходит
|
||||
- **AND** записи и их владелец остаются прежними
|
||||
|
||||
#### Scenario: Учётная запись с одними файлами тоже не удаляется
|
||||
|
||||
- **GIVEN** у учётной записи остались файлы, но записей нет
|
||||
- **WHEN** её строку удаляют
|
||||
- **THEN** удаление не проходит, а владелец файлов остаётся прежним
|
||||
|
||||
#### Scenario: Учётная запись с одними темами тоже не удаляется
|
||||
|
||||
- **GIVEN** у учётной записи остались темы словаря, но ни записей, ни файлов нет
|
||||
- **WHEN** её строку удаляют
|
||||
- **THEN** удаление не проходит
|
||||
|
||||
#### Scenario: Учётная запись без записей удаляется
|
||||
|
||||
- **GIVEN** у учётной записи нет ни аудиозаписей, ни файлов, ни тем
|
||||
- **WHEN** её строку удаляют
|
||||
- **THEN** удаление проходит
|
||||
|
||||
### Requirement: Аудиозапись — центральная сущность хранилища
|
||||
|
||||
Хранилище SHALL держать аудиозапись отдельной таблицей, а всё, что к ней
|
||||
приложено, — отдельными строками со ссылками на запись. Приложениями считаются
|
||||
файлы, тексты, структура реплик, темы, журнал событий и попытка распознавания.
|
||||
|
||||
Поля, которыми распоряжается очередь — признак захвата, срок его протухания,
|
||||
пауза, число отказов, время входа в рубеж, — MUST не соседствовать с содержимым
|
||||
записи в одной строке настолько, чтобы чтение очереди тянуло содержимое.
|
||||
|
||||
Запись MUST нести заголовок и краткое описание своими колонками: они читаются
|
||||
вместе со списком, сотней штук разом. Расшифровка и вычитанный текст MUST лежать
|
||||
отдельными строками: они читаются по открытию одной записи.
|
||||
|
||||
Тем же доводом запись MUST нести своими колонками **имя файла, данное
|
||||
отправителем, длительность и размер**. Все три показываются в списке. Приём
|
||||
узнаёт длительность и размер у источника метаданных и так, а имя файла приходит
|
||||
вместе с записью.
|
||||
|
||||
**Имена колонок и единицы измерения нормативны:** `original_filename`,
|
||||
`duration_ms` (миллисекунды) и `size_bytes` (байты). Единица стоит в самом имени,
|
||||
а не в комментарии: шаг схемы применённым не переписывается, а расхождение
|
||||
«секунды против миллисекунд» между колонкой, ответом списка и объявленным
|
||||
пределом не увидит ни компилятор, ни гейт — оба конца числа. Миллисекунды выбраны
|
||||
потому, что этой единицей уже названы соседние колонки схемы.
|
||||
|
||||
**Различать «неизвестно» и «ноль» эти колонки не обязаны, и это решение, а не
|
||||
недосмотр.** Обе величины ставит приём, и ставит всегда — запись, метаданные
|
||||
которой прочитать не удалось, отвергается отказом и не заводится вовсе. Ноль в
|
||||
этих колонках означает ноль, и колонки MUST быть объявлены обязательными: пустое
|
||||
значение, которое схема теперь допустить может, завело бы третий смысл, которого
|
||||
никто не читает. Решение владельца 2026-08-15, и смена хранилища его не отменяет.
|
||||
|
||||
Имя файла на записи и заголовок MUST лежать **разными колонками**. Заголовок
|
||||
несёт название, которое дал человек либо посчитала языковая модель; имя файла —
|
||||
то, по чему человек узнаёт свою запись, пока заголовка нет. Одной колонкой на оба
|
||||
смысла посчитанное название затирало бы имя, и вернуть затёртое было бы неоткуда.
|
||||
|
||||
Величины на записи и на её файле расходятся по смыслу, и **равенство между ними
|
||||
не поддерживается никем — намеренно**. На записи лежит снимок **принятого**,
|
||||
взятый приёмом один раз и больше не пересчитываемый; на файле — величины той
|
||||
копии, которой файл является сейчас. Приведённая копия имеет свой размер, и
|
||||
записи он не принадлежит.
|
||||
|
||||
Отсюда норма, без которой два числа читались бы как копии одного: величины
|
||||
записи MUST не сверяться со строкой файла и MUST не переписываться ничем после
|
||||
приёма. Расхождение между ними — не поломка, а разные вопросы: «что человек
|
||||
прислал» и «что лежит сейчас».
|
||||
|
||||
#### Scenario: Список читается без содержимого
|
||||
|
||||
- **GIVEN** у записи есть расшифровка
|
||||
- **WHEN** читают запись ради её рубежа и заголовка
|
||||
- **THEN** текст расшифровки при этом не читается
|
||||
|
||||
#### Scenario: Длительность и размер читаются без строки файла
|
||||
|
||||
- **GIVEN** запись принята
|
||||
- **WHEN** читают её длительность и размер
|
||||
- **THEN** строка файла при этом не читается
|
||||
|
||||
#### Scenario: Пустая длительность в схему не ложится
|
||||
|
||||
- **GIVEN** сервис поднят
|
||||
- **WHEN** аудиозапись пытаются сохранить с пустой длительностью или пустым
|
||||
размером
|
||||
- **THEN** база её не сохраняет
|
||||
|
||||
#### Scenario: Посчитанный заголовок не затирает имя файла
|
||||
|
||||
- **GIVEN** запись принята с именем файла отправителя
|
||||
- **WHEN** записи проставляют заголовок
|
||||
- **THEN** имя файла остаётся прежним
|
||||
|
||||
### Requirement: Словарь тем ведётся по владельцу
|
||||
|
||||
Хранилище SHALL держать темы отдельной таблицей, и тема MUST быть уникальна в
|
||||
паре «владелец и название»: словарь тем свой у каждого человека. У записи MUST
|
||||
быть не больше пяти тем.
|
||||
|
||||
Отдельной таблицей, а не набором строк в записи, — потому что перечень тем
|
||||
человека нужен целиком перед каждым обращением к модели, а собрать его из
|
||||
наборов строк можно только перебором всех его записей.
|
||||
|
||||
Потолок в пять тем MUST держаться самой схемой: без него часовой разговор даёт
|
||||
два десятка тем, и словарь распухает за неделю. То же число сервис объявляет
|
||||
приложению — норму держит capability `archive`, — и второй константы рядом MUST
|
||||
не заводиться.
|
||||
|
||||
Название темы выведено из содержимого записи, а перечень тем человека — слепок
|
||||
того, о чём он вообще говорит. В журнал сервиса темы MUST не попадать наравне с
|
||||
текстом расшифровки.
|
||||
|
||||
Ни один шаг этого изменения тем не пишет и не читает: место заведено вперёд,
|
||||
чтобы задача, считающая темы языковой моделью, не платила вторым необратимым
|
||||
шагом схемы. Цена решения названа прямо — имена таблицы и её колонок закрепляются
|
||||
раньше, чем известен их потребитель.
|
||||
|
||||
#### Scenario: Тема одного человека не мешает теме другого
|
||||
|
||||
- **GIVEN** у двух владельцев заведена тема с одинаковым названием
|
||||
- **WHEN** смотрят словарь тем
|
||||
- **THEN** это две разные темы, каждая своего владельца
|
||||
|
||||
#### Scenario: Шестая тема не заводится
|
||||
|
||||
- **WHEN** записи назначают шестую тему
|
||||
- **THEN** назначение не проходит
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
### Requirement: Наружу хранилище отдаёт только то, что заказано
|
||||
|
||||
**Reason**: Требование закрывало собственную поверхность встроенного хранилища —
|
||||
перечисление записей коллекций, журналы запросов, резервные копии, настройки,
|
||||
расписание, — которую тот публиковал тем же портом. Хранилище уходит из проекта
|
||||
целиком, и поверхности этой не существует: закрывать больше нечего.
|
||||
|
||||
**Migration**: Отвечает сервис теперь только своими адресами, а всё, что не
|
||||
принадлежит корню приложения и не является отдельным адресом наблюдения, идёт
|
||||
общим правилом неизвестного пути — норму держит capability `webapp`. Что
|
||||
содержимое записи закрыто везде, где лежит, нормирует требование «Содержимое
|
||||
записи закрыто везде, где лежит».
|
||||
|
||||
### Requirement: Владелец видит записи в панели
|
||||
|
||||
**Reason**: Панель администратора уходит вместе с хранилищем и **не заменяется
|
||||
ничем** — решение владельца 2026-08-22. Требовать поведения от инструмента,
|
||||
которого нет, нельзя.
|
||||
|
||||
**Migration**: Возврат остановленной записи в работу делает подкоманда набора
|
||||
инструментов разработчика — норму держит capability `pipeline`, требование
|
||||
«Остановка записи — признак, а не рубеж»: там названы и поля, которые возврат
|
||||
обязан сбросить, и обязанность писать событие журнала записи с происхождением
|
||||
«человек». Прочая правка записи ждёт экранов владельца, а их приносят задачи
|
||||
`audiorecord-actions` и `play-recording-in-app`.
|
||||
|
||||
### Requirement: Пароль владельца от панели не лежит в конфигурации
|
||||
|
||||
**Reason**: Пароля от панели больше нет — вместе с панелью. Секрет, появившийся
|
||||
только ради перевода на встроенное хранилище, пропадает, и ключа под него в
|
||||
конфигурации не заводится по той простой причине, что заводить нечего.
|
||||
|
||||
**Migration**: Инвариант проекта «Секрет не покидает конфиг» остаётся в силе для
|
||||
оставшихся секретов — ключа распознавания и пары ключей внешнего хранилища.
|
||||
Приглашения завести владельца сервис больше не печатает.
|
||||
|
||||
### Requirement: Содержимое записи закрыто во всех коллекциях, где лежит
|
||||
|
||||
**Reason**: Требование написано словарём коллекций, правил доступа и защищённых
|
||||
полей — механизмов встроенного хранилища. Механизмов этих не остаётся, а норма
|
||||
остаётся: она переписана требованием «Содержимое записи закрыто везде, где
|
||||
лежит».
|
||||
|
||||
**Migration**: Читать норму по новому требованию. Закрытость держится тем, что
|
||||
адреса чтения содержимого пишет сервис и каждый судит владельца связанной
|
||||
записи, а не правилом просмотра коллекции и не пометкой поля.
|
||||
@@ -0,0 +1,178 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Неизвестный путь вне корней открывает приложение
|
||||
|
||||
Сервис SHALL отдавать разметку приложения на всяком пути, который не принадлежит
|
||||
ни одному корню сервиса и не совпадает с отдельным адресом наблюдения. Корень у
|
||||
сервиса остался **один** — `/app` у приложения; отдельными адресами стоят
|
||||
`/health` и `/metrics`.
|
||||
|
||||
Корней `/api` и `/_` в перечне больше нет: встроенного хранилища с его
|
||||
собственным пространством и панели администратора у сервиса не осталось, и
|
||||
адресов под этими именами не существует. Прежние пути хранилища и панели поэтому
|
||||
отвечают тем же, чем отвечает всякий путь вне корней, — разметкой приложения.
|
||||
Резервировать имя за отказом сервис не берётся: имя, за которым ничего не стоит,
|
||||
ничем не отличается от любого другого свободного имени, а второй перечень
|
||||
«когда-то занятых корней» разошёлся бы с первым молча.
|
||||
|
||||
Этим же снимается дефект подменённого знака: путь панели, записанный кодом знака,
|
||||
раскодируется в тот же путь и попадает в то же правило — правило одно, и особого
|
||||
случая у него нет.
|
||||
|
||||
Корня `/auth` в перечне нет тоже: собственного входа у сервиса не осталось.
|
||||
|
||||
Путь принадлежит корню, когда **совпадает с ним точно либо начинается им вместе с
|
||||
косой чертой**. Оба условия обязательны: по одному лишь префиксу корню `/app`
|
||||
достался бы посторонний `/apple`, а по одному лишь префиксу с косой чертой голый
|
||||
`/app` не достался бы никому и уехал бы разметкой.
|
||||
|
||||
Чем отвечает голый `/app`, названо прямо: он принадлежит корню приложения,
|
||||
адресом приложения при этом не является и потому MUST отвечать как **неизвестный
|
||||
путь под корнем приложения** — узнанному `404` телом отказа приложения,
|
||||
неузнанному `401` тем же телом, каким отвечают прочие адреса под этим корнем.
|
||||
Разметки в ответе нет ни в одном из двух случаев. Без этой строки «не разметка
|
||||
приложения» читается как «что-нибудь ещё», и код ответа выбрала бы за нас первая
|
||||
же сборка.
|
||||
|
||||
Путь, принадлежащий корню, MUST не проваливаться в приложение никогда: отказ
|
||||
контракта остаётся отказом контракта и уходит той формой, которой этот корень
|
||||
отвечает и сегодня. Иначе программа, ошибшаяся адресом под корнем приложения,
|
||||
получила бы разметку с кодом `200` вместо отказа с машиночитаемым кодом — и
|
||||
приняла бы её за ответ.
|
||||
|
||||
Путь **под каталогом ресурсов** — тем, который наполняет сборщик, — разметкой не
|
||||
подменяется: не совпавший с файлом, он MUST отвечать `404`. Иначе разметка
|
||||
прежней сборки, назвавшая ресурс, которого в новой сборке уже нет, получает на
|
||||
него `200` и разметку вместо ресурса: браузер отвергнет её по типу содержимого,
|
||||
человек увидит пустой экран, а в кодах ответов сервиса не останется ничего.
|
||||
|
||||
Открывающими страницу считаются `GET` и `HEAD`, и только они; прочие методы MUST
|
||||
отвечать `405`.
|
||||
|
||||
#### Scenario: Обновление страницы посреди приложения открывает тот же экран
|
||||
|
||||
- **GIVEN** приложение открыто на своём маршруте
|
||||
- **WHEN** браузер спрашивает этот путь заново
|
||||
- **THEN** ответ имеет код `200`
|
||||
- **AND** тело ответа — разметка приложения
|
||||
|
||||
#### Scenario: Голый корень разметкой не подменяется
|
||||
|
||||
- **GIVEN** запрос идёт с заголовком, поставленным прокси
|
||||
- **WHEN** запрос приходит на путь, совпадающий с корнем приложения точно и без
|
||||
косой черты
|
||||
- **THEN** ответ имеет код `404`
|
||||
- **AND** тело ответа — отказ приложения с машиночитаемым кодом, а не разметка
|
||||
|
||||
#### Scenario: Голый корень неузнанному отвечает как прочие адреса под корнем
|
||||
|
||||
- **WHEN** запрос приходит без заголовка на путь, совпадающий с корнем приложения
|
||||
точно и без косой черты
|
||||
- **THEN** ответ имеет код `401`
|
||||
- **AND** тело ответа — не разметка приложения
|
||||
|
||||
#### Scenario: Посторонний путь, начинающийся именем корня, открывает приложение
|
||||
|
||||
- **WHEN** запрос приходит на путь, который начинается именем корня, но не
|
||||
отделён от него косой чертой
|
||||
- **THEN** ответ имеет код `200`
|
||||
- **AND** тело ответа — разметка приложения
|
||||
|
||||
#### Scenario: Прежний адрес входа открывает приложение
|
||||
|
||||
- **WHEN** запрос приходит на путь под прежним корнем входа
|
||||
- **THEN** ответ имеет код `200`
|
||||
- **AND** тело ответа — разметка приложения
|
||||
|
||||
#### Scenario: Прежний путь хранилища открывает приложение
|
||||
|
||||
- **WHEN** запрос приходит на путь под прежним корнем хранилища
|
||||
- **THEN** ответ имеет код `200`
|
||||
- **AND** тело ответа — разметка приложения
|
||||
|
||||
#### Scenario: Прежний адрес панели открывает приложение
|
||||
|
||||
- **WHEN** запрос приходит на прежний адрес панели — и записанный знаком, и
|
||||
записанный кодом этого знака
|
||||
- **THEN** оба ответа имеют код `200`
|
||||
- **AND** тело каждого — разметка приложения
|
||||
|
||||
#### Scenario: Неизвестный путь под корнем приложения отвечает отказом
|
||||
|
||||
- **GIVEN** запрос идёт с заголовком, поставленным прокси
|
||||
- **WHEN** он спрашивает неизвестный путь под корнем приложения
|
||||
- **THEN** ответ имеет код `404`
|
||||
- **AND** тело ответа — отказ приложения с машиночитаемым кодом, а не разметка
|
||||
|
||||
#### Scenario: Неизвестный путь под корнем приложения неузнанному отвечает как все прочие его адреса
|
||||
|
||||
- **WHEN** запрос приходит на неизвестный путь под корнем приложения без
|
||||
заголовка
|
||||
- **THEN** ответ имеет код `401`
|
||||
- **AND** тело ответа — не разметка приложения
|
||||
|
||||
#### Scenario: Несуществующий ресурс отвечает отсутствием, а не разметкой
|
||||
|
||||
- **WHEN** браузер спрашивает под каталогом ресурсов файл, которого в сборке нет
|
||||
- **THEN** ответ имеет код `404`
|
||||
- **AND** тело ответа — не разметка приложения
|
||||
|
||||
### Requirement: Путь, отданный приложению, в журнал не идёт
|
||||
|
||||
Сервис SHALL записывать о запросе **маршрут из закрытого перечня** и длину пути,
|
||||
а самого запрошенного пути MUST не записывать ни в один свой журнал. То же
|
||||
относится к меткам метрик. Перечень маршрутов закрыт и назван: точные адреса
|
||||
наблюдения и объявленные образцы адресов приложения; всё, что ни одному из них
|
||||
не отвечает, MUST обозначаться одним общим значением. Для запроса, отданного
|
||||
приложению, к строке добавляется **исход из закрытого перечня** — разметка,
|
||||
ресурс, отказ.
|
||||
|
||||
Правило MUST накрывать обе половины адресного пространства — и путь вне корней
|
||||
сервиса, и путь под корнем приложения. Принадлежность пути сервису тут ничего не
|
||||
меняет: `/app/<произвольный текст>` принадлежит сервису и отвечает отказом, но
|
||||
множеством значений под корнем распоряжается спрашивающий ровно так же, как и
|
||||
вне его.
|
||||
|
||||
Причина в том, кто этот путь выбирает. До появления раздачи путь вне корней
|
||||
ловил отказ маршрутизатора; теперь он успешный ответ, и множеством его значений
|
||||
распоряжается спрашивающий — дословная запись сделала бы журнал местом, куда
|
||||
аноним пишет свой текст произвольной длины. Правило того же рода у сервиса уже
|
||||
есть: причина отказа, пришедшая от провайдера строкой запроса, приводится к
|
||||
перечню известных.
|
||||
|
||||
Идентификатор записи от этого не пропадает: его пишет обработчик своим полем, и
|
||||
пишет он прочитанный идентификатор, а не тот, что стоял в запросе.
|
||||
|
||||
Журнал у сервиса теперь **один**: второй, куда чужая библиотека клала путь целиком
|
||||
вместе с адресом отправителя, ушёл вместе с ней. Правило от этого не ослабло, а
|
||||
перестало зависеть от настройки чужого журнала, которую мы не писали.
|
||||
|
||||
#### Scenario: Путь не доезжает до журнала
|
||||
|
||||
- **WHEN** приходит запрос на путь вне корней сервиса
|
||||
- **THEN** записи о нём не несут этого пути
|
||||
- **AND** несут исход и длину пути
|
||||
|
||||
#### Scenario: Длинный путь журнал не наполняет
|
||||
|
||||
- **WHEN** приходит запрос на путь длиной в тысячу знаков
|
||||
- **THEN** записи о нём не растут вместе с длиной пути
|
||||
|
||||
#### Scenario: Путь под корнем приложения журнал не пишет
|
||||
|
||||
- **GIVEN** пришедший не узнан
|
||||
- **WHEN** он спрашивает под корнем приложения путь, не отвечающий ни одному
|
||||
объявленному образцу адреса
|
||||
- **THEN** записи о нём не несут этого пути
|
||||
- **AND** не растут вместе с его длиной
|
||||
|
||||
#### Scenario: Объявленный образец адреса приложения в журнале различим
|
||||
|
||||
- **WHEN** приходит запрос на объявленный адрес приложения
|
||||
- **THEN** запись о нём несёт образец этого адреса, а не запрошенный путь
|
||||
|
||||
#### Scenario: Второго журнала у сервиса нет
|
||||
|
||||
- **GIVEN** сервис поднялся
|
||||
- **WHEN** приходит запрос на путь вне корней сервиса
|
||||
- **THEN** запись о нём появляется только в журнале сервиса
|
||||
Reference in New Issue
Block a user