хранилище переехало с PocketBase на SQLite со своим каталогом файлов
- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose под файловым замком, одна миграция начальной схемы вместо семи прежних - транспорт переписан на net/http: свои слои, свой ограничитель частоты, отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли - по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета читается справа налево, узнавание известного идёт читающим пулом
This commit is contained in:
+406
-284
@@ -3,10 +3,11 @@
|
||||
## Purpose
|
||||
|
||||
Где живут аудиозапись, её приложения и её файлы: раскладка каталога данных,
|
||||
приведение схемы при подъёме, отдача файла ссылкой по токену, собственная
|
||||
поверхность хранилища и панель владельца.
|
||||
версионированный накат схемы при подъёме, правила чтения и записи базы, отдача
|
||||
файла его владельцу с проверкой при каждом обращении.
|
||||
|
||||
Приём и опрос готовности нормирует `intake`, вход и сессию — `access`, попытку
|
||||
Приём записи нормирует `intake`, узнавание пришедшего — `access`, адреса, по
|
||||
которым приложение спрашивает запись и её файл, — `archive`, попытку
|
||||
распознавания у внешнего провайдера — `recognition`.
|
||||
|
||||
Сознательно не описаны: перенос прежних данных — его нет ни по решению задачи
|
||||
@@ -14,6 +15,7 @@
|
||||
удалены вместе с остановкой сервиса; удаление записей и файлов — сервис объявлен
|
||||
архивом 2026-08-11, а удаление приносит задача `delete-record`.
|
||||
## Requirements
|
||||
|
||||
### Requirement: Сервис поднимается на чистом каталоге данных
|
||||
|
||||
Сервис SHALL приводить хранилище в рабочий вид сам: на пустом каталоге данных он
|
||||
@@ -26,7 +28,30 @@ MUST завести свою схему и принимать записи св
|
||||
|
||||
Схема MUST заводиться версионированными шагами, а применённый шаг MUST не
|
||||
переписываться — только новым шагом. Иначе повторный запуск на уже заведённом
|
||||
каталоге разошёлся бы с первым молча.
|
||||
каталоге разошёлся бы с первым молча. Применённые шаги MUST учитываться самой
|
||||
базой, а не порядком файлов на диске.
|
||||
|
||||
Применение шага и запись отметки о нём MUST идти **одной транзакцией**. Процесс,
|
||||
оборванный между ними, оставляет базу со шагом, который применён и не отмечен, а
|
||||
следующий запуск применяет его второй раз — и второе применение отказывает на
|
||||
заведённой таблице, роняя старт на шаге, который на самом деле цел.
|
||||
|
||||
Накат MUST держаться **исключающей блокировкой базы** на всё своё время: второй
|
||||
процесс, поднятый на том же каталоге данных, MUST ждать её освобождения либо
|
||||
отказать, а не применять шаги параллельно. Каталог данных один, а запусков на нём
|
||||
бывает два — старый экземпляр ещё не остановлен, новый уже поднят, — и два
|
||||
наката, разошедшихся на одном шаге, оставляют схему в состоянии, которого не
|
||||
описывает ни один шаг.
|
||||
|
||||
Порядок шагов MUST быть детерминирован и выводиться из **версии самого шага**, а
|
||||
не из порядка чтения каталога: порядка обхода файловая система не обещает, а
|
||||
разошедшийся порядок шагов виден только на чистой базе, которую заводят один раз.
|
||||
Две одинаковых версии MUST давать отказ, а не молчаливый выбор одного из шагов.
|
||||
|
||||
**Схема MUST накатываться до подъёма входов и до старта воркеров**, а отказ шага
|
||||
MUST ронять старт. Сервис, поднявшийся на неприведённой схеме, отвечает отказом
|
||||
на каждый запрос и на каждый прогон воркера — вместо одной строки о причине их
|
||||
становятся сотни, и первопричина в них теряется.
|
||||
|
||||
Каталог данных у сервиса MUST быть один: база и файлы записей лежат под ним
|
||||
вместе, и второго пути к ним не заводится.
|
||||
@@ -42,29 +67,223 @@ MUST завести свою схему и принимать записи св
|
||||
|
||||
- **GIVEN** сервис уже запускался на этом каталоге и завёл хранилище
|
||||
- **WHEN** он запускается снова
|
||||
- **THEN** он не заводит схему второй раз и не теряет прежние записи
|
||||
- **THEN** он не заводит схему второй раз и не теряет прежних записей
|
||||
|
||||
#### Scenario: Схема накатана до первой строки о готовности
|
||||
|
||||
- **GIVEN** каталог данных пуст
|
||||
- **WHEN** сервис запускается
|
||||
- **THEN** до строки журнала о готовности схема приведена целиком
|
||||
- **AND** ни одного отказа в журнале до неё нет
|
||||
|
||||
#### Scenario: Старт, оборванный между шагом и отметкой о нём
|
||||
|
||||
- **GIVEN** запуск оборван после применения шага схемы и до записи отметки о нём
|
||||
- **WHEN** сервис запускается снова
|
||||
- **THEN** исход тот же, что и у необорванного запуска, либо отказ, называющий
|
||||
шаг
|
||||
- **AND** шаг не применяется второй раз
|
||||
|
||||
#### Scenario: Отказ шага схемы роняет старт
|
||||
|
||||
- **GIVEN** шаг схемы не применяется
|
||||
- **WHEN** сервис запускается
|
||||
- **THEN** старт кончается отказом, называющим шаг
|
||||
- **AND** ни один вход не поднят
|
||||
|
||||
### 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
|
||||
адресоваться этой записью, а не путём на диске.
|
||||
Сервис SHALL держать файл записи в своём каталоге данных, и раскладку этого
|
||||
каталога MUST задавать он сам. Файл MUST адресоваться записью, которой
|
||||
принадлежит, а не путём на диске: шаг конвейера просит файл у записи и получает
|
||||
его, ничего не зная о раскладке.
|
||||
|
||||
Раскладку файлов на диске выбирает хранилище. Собственного плоского каталога
|
||||
записей у сервиса MUST не оставаться: файл, лежащий мимо хранилища, не попадёт
|
||||
ни в панель владельца, ни в резервную копию, а ради этих двух вещей перевод и
|
||||
делается.
|
||||
Раскладка MUST держать копии одной записи вместе — под её идентификатором, — и
|
||||
MUST давать убрать запись целиком одним движением, не перебирая имена по маске.
|
||||
Плоского каталога, где копии различаются приставкой в имени, MUST не
|
||||
заводиться.
|
||||
|
||||
Содержимое записи MUST не читаться в память целиком ни при укладке в хранилище,
|
||||
ни при чтении из него: расчётный потолок записи — шесть часов, и такая запись в
|
||||
память не помещается.
|
||||
Содержимое записи MUST не читаться в память целиком ни при укладке, ни при
|
||||
чтении: расчётный потолок записи — шесть часов, и такая запись в память не
|
||||
помещается.
|
||||
|
||||
**Укладка MUST быть атомарной:** содержимое пишется во временное имя **в том же
|
||||
подкаталоге записи** и переименовывается в рабочее только после того, как поток
|
||||
дочитан до конца без отказа. Временное имя берётся в том же каталоге потому, что
|
||||
переименование в его пределах не копирует содержимое и не может оборваться на
|
||||
середине.
|
||||
|
||||
Порядок MUST быть один: строка о файле заводится **после** того, как содержимое
|
||||
лежит целиком под рабочим именем. Обратный порядок оставляет в базе строку,
|
||||
указывающую на файл, которого ещё нет или который короче принятого.
|
||||
|
||||
Средство обнаружить усечение у сервиса одно, и оно снято намеренно: величины
|
||||
записи со строкой файла не сверяются — так требует «Аудиозапись — центральная
|
||||
сущность хранилища», — а другого не заведено. Усечённая запись поэтому уезжает в
|
||||
конвейер, оплачивает распознавание и отдаёт расшифровку половины как готовый
|
||||
результат. Атомарная укладка — единственное, что этого не допускает.
|
||||
|
||||
Отсюда две нормы о неудачах:
|
||||
|
||||
- содержимое легло, а сохранение самой аудиозаписи отказало — уложенный файл MUST
|
||||
быть убран, и строки о нём MUST не остаться. Файл, переживший свою запись, —
|
||||
штатное состояние только у приведённой копии, которую заводит шаг конвейера; у
|
||||
принятой копии это мусор, на который не ссылается ничто и о котором узнать
|
||||
неоткуда;
|
||||
- отмена контекста посреди укладки MUST кончаться тем же исходом, что и отказ
|
||||
источника: временного имени не остаётся, рабочего имени не появляется, строки о
|
||||
файле нет. Записи, наполовину принятой, человек не видит.
|
||||
|
||||
Сохранённый ответ провайдера распознавания MUST лежать **третьим файлом в том же
|
||||
подкаталоге записи**, под именем, которое задаёт сервис. Колонкой строки попытки
|
||||
он ехал бы в память при каждом опросе готовности — этого capability `recognition`
|
||||
избегает намеренно; отдельной таблицей он завёл бы второй путь чтения содержимого
|
||||
и остался бы в базе, которую сервис держит узкой. Третьим файлом он попадает под
|
||||
ту же атомарную укладку и под ту же уборку записи одним движением, что и копии
|
||||
аудио.
|
||||
|
||||
Копией аудио сохранённый ответ при этом MUST не считаться: копий у записи
|
||||
по-прежнему две — принятая и приведённая, — и перечень копий, которые сервис
|
||||
отдаёт адресом приложения, этим не расширяется. Адреса, которым сохранённый ответ
|
||||
читают снаружи, у сервиса нет вовсе.
|
||||
|
||||
**Потолок размера записи MUST быть задан числом, выведенным из этого расчётного
|
||||
потолка**, и задан он MUST быть везде, где иначе действует чужое умолчание: и у
|
||||
поля файла в хранилище, и у тела запроса приёма. Умолчания здесь не «без
|
||||
предела», а величины на два-три порядка меньше нужного, и оставленные как есть
|
||||
они отвергают штатную запись сервиса — приём отказывает, а уже принятая запись
|
||||
исчерпывает попытки на шаге конвертации.
|
||||
потолка**, и задан он MUST быть везде, где иначе действует умолчание: и у тела
|
||||
запроса приёма, и у всякого предела, который сервис ставит сам. Умолчания здесь
|
||||
не «без предела», а величины на два-три порядка меньше нужного, и оставленные как
|
||||
есть они отвергают штатную запись сервиса — приём отказывает, а уже принятая
|
||||
запись исчерпывает попытки на шаге конвертации.
|
||||
|
||||
Отказ по этому потолку MUST быть виден отправителю ответом, а не молчанием.
|
||||
|
||||
@@ -76,20 +295,37 @@ MUST завести свою схему и принимать записи св
|
||||
столько раз, сколько шагов, а забытая копия — это шестичасовая запись,
|
||||
оставшаяся во временном каталоге, и узнать о ней неоткуда.
|
||||
|
||||
#### Scenario: Принятая запись легла в хранилище
|
||||
#### Scenario: Принятая запись легла в каталог данных
|
||||
|
||||
- **WHEN** запись принята любым входом
|
||||
- **THEN** её файл лежит в хранилище и связан со своей записью
|
||||
- **AND** отдельного каталога записей рядом с хранилищем не появляется
|
||||
- **WHEN** запись принята
|
||||
- **THEN** её файл лежит в каталоге данных сервиса и связан со своей записью
|
||||
- **AND** второго каталога записей рядом не появляется
|
||||
|
||||
#### Scenario: Запись длиннее чужого умолчания принимается
|
||||
#### Scenario: Копии одной записи лежат вместе
|
||||
|
||||
- **WHEN** в хранилище кладут запись длиннее умолчания, действующего у поля файла
|
||||
- **THEN** она ложится в хранилище, а не отвергается
|
||||
- **GIVEN** запись принята, приведена к рабочему формату и прошла распознавание
|
||||
- **WHEN** смотрят, где лежат её файлы
|
||||
- **THEN** принятая копия, приведённая копия и сохранённый ответ провайдера лежат
|
||||
под идентификатором этой записи
|
||||
- **AND** второго места, где лежит что-то из них, нет
|
||||
|
||||
#### Scenario: Источник оборвался посреди потока
|
||||
|
||||
- **GIVEN** отправитель шлёт запись и обрывает поток на середине
|
||||
- **WHEN** укладка отказывает
|
||||
- **THEN** строки о файле не заведено
|
||||
- **AND** ни файла под рабочим именем, ни временного имени в подкаталоге записи
|
||||
не остаётся
|
||||
|
||||
#### Scenario: Запись длиннее умолчания принимается
|
||||
|
||||
- **WHEN** сервису отдают запись длиннее всякого умолчания, действующего на пути
|
||||
приёма
|
||||
- **THEN** она ложится в каталог данных, а не отвергается
|
||||
|
||||
#### Scenario: Шаг конвейера берёт файл по записи
|
||||
|
||||
- **GIVEN** запись принята и её файл лежит в хранилище
|
||||
- **GIVEN** запись принята и её файл лежит в каталоге данных
|
||||
- **WHEN** шаг конвейера берётся за эту запись
|
||||
- **THEN** он получает файл по самой записи, а не по пути на диске
|
||||
|
||||
@@ -101,209 +337,92 @@ MUST завести свою схему и принимать записи св
|
||||
|
||||
### Requirement: Файл отдаётся ссылкой
|
||||
|
||||
Сервис SHALL отдавать файл записи ссылкой, которую строит хранилище по самой
|
||||
записи, **и только узнанному отправителю**. Поле файла MUST быть помечено
|
||||
защищённым: без этого ссылка открывает запись любому, кто её знает, и знание
|
||||
ссылки становится правом. Отданный файл MUST совпадать с принятым по длине.
|
||||
Сервис SHALL отдавать файл записи **только её владельцу** и MUST судить владельца
|
||||
сам, при каждом обращении. Отданный файл MUST совпадать с принятым по длине.
|
||||
|
||||
Одной пометки мало: защищённый файл судится **коротким токеном файла**, который
|
||||
узнанный отправитель берёт у хранилища, — и правилом просмотра коллекции.
|
||||
Правило MUST пускать только владельца файла: незаданное означает «только владелец
|
||||
панели», и тогда файла не получит и узнанный, а прежнее «всякий узнанный»
|
||||
отдавало чужое аудио тому, кто знает идентификатор записи.
|
||||
Значения, дающего право пройти по ссылке, сервис MUST не выдавать: ни короткого
|
||||
токена файла, ни подписанной ссылки со сроком. Право даёт узнавание пришедшего и
|
||||
владение записью, и судится оно там же, где отдаётся файл. Отзыв доступа доходит
|
||||
до файла сразу, а не через срок жизни выданного значения.
|
||||
|
||||
Токен файла хранилище выдаёт **на предъявителя**, а не на файл, и о файле при
|
||||
выдаче не спрашивает. Значит владельца судит переход по ссылке, а не выдача
|
||||
токена: отказ наступает там, и требовать его от выдачи значит требовать
|
||||
механизма, которого нет.
|
||||
Обращение к файлу чужой записи MUST быть неотличимо от обращения к
|
||||
несуществующей — тем же кодом и тем же телом. Разница ответов превратила бы
|
||||
чтение в перебор заведённых записей.
|
||||
|
||||
Отсюда порядок для потребителя: узнавание → токен файла → ссылка с этим токеном.
|
||||
Адрес выдачи токена лежит в пространстве хранилища, и узнавание по заголовку MUST
|
||||
на нём работать — иначе файл записи недостижим для браузера вовсе. Одного
|
||||
заголовка при этом мало: без токена ссылка файла не отдаёт, и это свойство
|
||||
хранилища, а не недосмотр.
|
||||
Каким адресом файл уходит и как называется вид копии, нормирует capability
|
||||
`archive`: там живут адреса приложения, и держатель нормы обязан быть один.
|
||||
|
||||
Конвейер расшифровки этим не затронут: он читает файл из файловой системы
|
||||
хранилища, а не по ссылке.
|
||||
Конвейер расшифровки этим не затронут: он читает файл из каталога данных, а не
|
||||
по адресу приложения.
|
||||
|
||||
Ссылка на несуществующую запись MUST отвечать отказом, а не пустым файлом.
|
||||
**Путь, по которому файл лежит на диске, MUST не попадать ни в журнал, ни в
|
||||
метку метрики, ни в ответ отправителю.** Имя, под которым файл лёг в каталог, из
|
||||
журнала выводимо быть не должно: журнал уезжает в собранные логи, откуда строку
|
||||
не убрать.
|
||||
|
||||
**Ссылка сама по себе и есть право пройти по ней**, и потому она MUST не попадать
|
||||
ни в журнал, ни в метку метрики, ни в ответ отправителю. Имя, под которым файл
|
||||
лёг в хранилище, из журнала выводимо быть не должно: журнал уезжает в собранные
|
||||
логи, откуда строку не убрать, и оттуда ссылка на чужую запись работала бы
|
||||
бессрочно.
|
||||
|
||||
Защищённое поле сужает это право, но не отменяет запрета: право пройти теперь
|
||||
требует ещё и узнавания, а строка журнала со ссылкой по-прежнему собирала бы
|
||||
половину ключа.
|
||||
|
||||
Отсюда требование к отказам: сообщение об отказе хранилища MUST не выходить за
|
||||
пределы хранилища дословно. Отказ чтения и отказ укладки называют ключ файла
|
||||
целиком, а отказ выгрузки во внешнее хранилище — полный адрес объекта; и то и
|
||||
другое кончается в журнале и собирает ссылку не хуже успешного пути.
|
||||
Отсюда требование к отказам: сообщение об отказе чтения или укладки MUST не
|
||||
называть ключ файла и путь к нему дословно, а отказ выгрузки во внешнее
|
||||
хранилище MUST не называть полного адреса объекта. И то и другое кончается в
|
||||
журнале и собирает ссылку не хуже успешного пути.
|
||||
|
||||
Что именно журнал приёма пишет ради прослеживаемости, нормирует capability
|
||||
`intake`.
|
||||
|
||||
#### Scenario: Файл забирают по ссылке
|
||||
#### Scenario: Владелец забирает свой файл
|
||||
|
||||
- **GIVEN** запись принята и её файл лежит в хранилище
|
||||
- **AND** забирающий узнан и взял токен файла
|
||||
- **WHEN** ссылку на файл запрашивают с этим токеном
|
||||
- **GIVEN** запись принята и её файл лежит в каталоге данных
|
||||
- **WHEN** владелец записи просит её файл
|
||||
- **THEN** приходит тот же файл, и его длина совпадает с длиной принятого
|
||||
|
||||
#### Scenario: Неузнанному файл не отдаётся
|
||||
|
||||
- **GIVEN** запись принята и её файл лежит в хранилище
|
||||
- **WHEN** ссылку на файл запрашивают неузнанным
|
||||
- **GIVEN** запись принята и её файл лежит в каталоге данных
|
||||
- **WHEN** файл просят неузнанным
|
||||
- **THEN** приходит отказ, а содержимого записи в ответе нет
|
||||
|
||||
#### Scenario: Токен файла выдаётся узнанному по заголовку
|
||||
#### Scenario: Значения на предъявителя сервис не выдаёт
|
||||
|
||||
- **GIVEN** запрос идёт с доверенного адреса с заголовком `Remote-User`
|
||||
- **WHEN** он просит у хранилища токен файла
|
||||
- **THEN** токен выдаётся
|
||||
- **GIVEN** человек узнан
|
||||
- **WHEN** ищут адрес, которым сервис выдаёт значение, открывающее файл
|
||||
- **THEN** такого адреса у сервиса нет
|
||||
|
||||
#### Scenario: Конвейер читает файл без узнавания
|
||||
|
||||
- **GIVEN** запись принята и ждёт расшифровки
|
||||
- **WHEN** шаг конвейера берётся за неё
|
||||
- **THEN** файл читается из файловой системы хранилища и шаг проходит
|
||||
- **THEN** файл читается из каталога данных и шаг проходит
|
||||
|
||||
#### Scenario: Ссылка ведёт в никуда
|
||||
#### Scenario: Файл записи, которой нет
|
||||
|
||||
- **WHEN** запрашивают ссылку на запись, которой нет
|
||||
- **WHEN** просят файл записи с неизвестным идентификатором
|
||||
- **THEN** приходит отказ, а не пустой ответ
|
||||
|
||||
#### Scenario: По журналу ссылку не собрать
|
||||
#### Scenario: По журналу путь к файлу не собрать
|
||||
|
||||
- **GIVEN** запись принята и прошла конвейер
|
||||
- **WHEN** читают журнал сервиса целиком
|
||||
- **THEN** имени, под которым файл лёг в хранилище, в нём нет
|
||||
- **THEN** имени, под которым файл лёг в каталог данных, в нём нет
|
||||
|
||||
#### Scenario: Отказ чтения файла не называет его ключ
|
||||
|
||||
- **GIVEN** файл записи не читается из хранилища
|
||||
- **GIVEN** файл записи не читается с диска
|
||||
- **WHEN** шаг конвейера берётся за эту запись и отказывает
|
||||
- **THEN** отказ называет запись её идентификатором и не несёт имени файла
|
||||
|
||||
### Requirement: Наружу хранилище отдаёт только то, что заказано
|
||||
|
||||
Сервис SHALL держать закрытыми собственные разделы хранилища, которые тот
|
||||
публикует тем же портом. Запрос без прав владельца MUST получать отказ на
|
||||
перечисление и чтение записей коллекций, на служебные разделы хранилища —
|
||||
журналы запросов, резервные копии, настройки, расписание — и на правку чего бы
|
||||
то ни было.
|
||||
|
||||
Требование заводится потому, что порт опубликован в интернет, а вместе с
|
||||
переводом наружу выходит поверхность, которой у сервиса не было. Что API сервиса
|
||||
сегодня открыт всякому — известно и записано моделью угроз; новая поверхность под
|
||||
это знание не подпадает и закрывается здесь.
|
||||
|
||||
Правило доступа, оставленное пустым, значит «только владелец панели». Именно
|
||||
пустым оно MUST и оставаться: непустое правило, поставленное будущей правкой
|
||||
схемы, открыло бы перечисление всех записей анонимному запросу и не нарушило бы
|
||||
при этом ни одного другого требования.
|
||||
|
||||
#### Scenario: Аноним перечисляет записи
|
||||
|
||||
- **WHEN** запрос без прав владельца просит список записей коллекции задач
|
||||
- **THEN** приходит отказ
|
||||
|
||||
#### Scenario: Аноним читает служебный раздел
|
||||
|
||||
- **WHEN** запрос без прав владельца просит журнал запросов или список резервных
|
||||
копий хранилища
|
||||
- **THEN** приходит отказ
|
||||
|
||||
### Requirement: Владелец видит записи в панели
|
||||
|
||||
Сервис SHALL давать владельцу панель, где аудиозапись видна строкой, отбирается
|
||||
по своему идентификатору и правится, а её файлы слушаются и скачиваются.
|
||||
|
||||
Панель MUST отдаваться тем же сервисом по своему адресу и MUST не требовать
|
||||
второго процесса.
|
||||
|
||||
Панель — вход в запись наравне с конвейером, а не окно просмотра. Снятие
|
||||
признака остановки в панели MUST возвращать запись в работу с сохранённого
|
||||
рубежа и MUST очищать служебные поля прошлого захвата — признак захвата, срок
|
||||
его протухания, паузу, число отказов — и MUST заново ставить время входа в
|
||||
рубеж. Правка рубежа руками MUST делать то же самое. Иначе владелец, вернувший
|
||||
запись в работу, получит запись, которая не выдаётся захвату до конца прежнего
|
||||
срока, останавливается от первого же отказа или останавливается снова первым же
|
||||
захватом по пределу времени, — и не узнает об этом.
|
||||
|
||||
Запись, заведённая в панели руками, MUST не уносить сервис: поля, без которых
|
||||
шаг конвейера не может работать, MUST быть обязательными в самой схеме, а
|
||||
перечень рубежей — закрытым.
|
||||
|
||||
Панель разграничению доступа сервиса не подчиняется: вошедший в неё видит все
|
||||
записи, все файлы и всех пользователей разом. Закрывает её контур выкладки, а не
|
||||
сервис — это записано моделью угроз проекта.
|
||||
|
||||
#### Scenario: Принятая запись видна владельцу
|
||||
|
||||
- **GIVEN** запись принята и заведена
|
||||
- **WHEN** владелец отбирает записи по идентификатору принятой
|
||||
- **THEN** он видит её строкой со своим рубежом
|
||||
- **AND** её файл скачивается из той же строки
|
||||
|
||||
#### Scenario: Остановленную запись вернули в работу правкой в панели
|
||||
|
||||
- **GIVEN** запись остановлена признаком, с накопленными отказами и признаком
|
||||
прежнего захвата
|
||||
- **AND** остановленной она простояла дольше предела времени в рубеже
|
||||
- **WHEN** владелец снимает признак остановки
|
||||
- **THEN** признак захвата, срок его протухания, пауза и число отказов очищены
|
||||
- **AND** время входа в рубеж поставлено заново
|
||||
- **AND** ближайший захват выдаёт запись с сохранённого рубежа
|
||||
|
||||
### Requirement: Пароль владельца от панели не лежит в конфигурации
|
||||
|
||||
Сервис SHALL не заводить в конфигурации ключа под пароль владельца от панели.
|
||||
Пароль MUST задаваться самим владельцем, а хранилище MUST держать только его
|
||||
отпечаток.
|
||||
|
||||
Требование стоит на инварианте проекта «Секрет не покидает конфиг» с другой
|
||||
стороны: секрет, которого в конфигурации нет, не утекает вместе с ней и не
|
||||
уезжает в выкладку третьим путём. Пароль от панели открывает все записи и все
|
||||
файлы разом — это самое чувствительное, что есть у сервиса.
|
||||
|
||||
Приглашение завести владельца сервис MUST печатать только пока владельца нет, и
|
||||
оно MUST истекать по времени. Приглашение равносильно паролю от панели, а
|
||||
печатается оно в журнал контейнера, откуда строку не убрать: бессрочное отдало бы
|
||||
панель всякому читателю логов навсегда.
|
||||
|
||||
Пока владелец пароля не задал, сервис MUST принимать записи: панель без владельца
|
||||
приёму не мешает.
|
||||
|
||||
#### Scenario: Владелец пароля ещё не задал
|
||||
|
||||
- **GIVEN** каталог данных пуст и владелец панели не заведён
|
||||
- **WHEN** сервис запускается
|
||||
- **THEN** он принимает записи
|
||||
- **AND** ни один ключ конфигурации не несёт пароля от панели
|
||||
|
||||
#### Scenario: Владелец заведён, приглашение больше не печатается
|
||||
|
||||
- **GIVEN** владелец панели заведён
|
||||
- **WHEN** сервис запускается снова
|
||||
- **THEN** приглашения завести владельца в журнале нет
|
||||
|
||||
### Requirement: Владелец задачи лежит связью с учётной записью
|
||||
|
||||
Хранилище SHALL держать владельца аудиозаписи отдельной колонкой — связью с
|
||||
учётной записью, — и эта колонка MUST не иметь умолчания: запись, чей владелец
|
||||
не назван, не достаётся никому по недосмотру схемы.
|
||||
|
||||
Колонка MUST не допускать пустого значения. Прежде допускала, и цену платили за
|
||||
записи, принятые ботом: связи чата с учётной записью сервис не вёл. С убранным
|
||||
входом заводить ничью запись стало некому, и обязательность переезжает из одного
|
||||
лишь приёма в схему — туда, где её держит хранилище, а не договорённость. Разница
|
||||
не косметическая: пока обязательность жила в приёме, ничью запись заводили руками
|
||||
в панели, и она уходила в конвейер, стоила денег на распознавание и не доставалась
|
||||
потом никому.
|
||||
Колонка MUST не допускать пустого значения, и MUST это держать сама схема: связь
|
||||
объявлена внешним ключом на учётную запись и обязательна. Пока обязательность
|
||||
жила в одном приёме, ничью запись заводили руками мимо него, она уходила в
|
||||
конвейер, стоила денег на распознавание и не доставалась потом никому.
|
||||
|
||||
Правку записи мимо адресов приложения сервис ничем не предоставляет: панели у
|
||||
него нет. Обязательность от этого не отменяется — она перестала зависеть от того,
|
||||
кто пишет, и стала свойством схемы.
|
||||
|
||||
Владелец MUST не назначаться и не меняться конвейером.
|
||||
|
||||
@@ -318,8 +437,15 @@ MUST завести свою схему и принимать записи св
|
||||
|
||||
- **GIVEN** сервис поднят
|
||||
- **WHEN** аудиозапись пытаются сохранить с пустым владельцем — приёмом,
|
||||
конвейером или руками в панели
|
||||
- **THEN** хранилище её не сохраняет
|
||||
конвейером или запросом к базе
|
||||
- **THEN** база её не сохраняет
|
||||
|
||||
#### Scenario: Владелец, которого нет, не принимается
|
||||
|
||||
- **GIVEN** сервис поднят
|
||||
- **WHEN** аудиозапись пытаются сохранить с владельцем, которому не отвечает ни
|
||||
одна учётная запись
|
||||
- **THEN** база её не сохраняет
|
||||
|
||||
#### Scenario: Конвейер владельца не назначает
|
||||
|
||||
@@ -330,13 +456,11 @@ MUST завести свою схему и принимать записи св
|
||||
### Requirement: Файл записи сужается владельцем наравне с задачей
|
||||
|
||||
Хранилище SHALL держать владельца и у файла записи — той же связью с учётной
|
||||
записью, — и правило просмотра файлов MUST пускать к файлу только его владельца.
|
||||
записью, — а отдача файла MUST пускать к нему только его владельца.
|
||||
|
||||
Владелец файла MUST назначаться при приёме, из узнанного предъявителя, а колонка
|
||||
файла MUST не допускать пустого значения наравне с колонкой записи. Прежде пустое
|
||||
значение оставалось у файлов, заведённых конвейером для записи без владельца;
|
||||
таких записей больше не заводится, и разное правило у записи и у её файла
|
||||
читалось бы как недосмотр.
|
||||
файла MUST не допускать пустого значения наравне с колонкой записи. Разное
|
||||
правило у записи и у её файла читалось бы как недосмотр.
|
||||
|
||||
Файл, заведённый шагом конвейера, — приведённую копию заводит именно он —
|
||||
MUST получать владельца своей записи. Иного источника владельца у файла нет, и
|
||||
@@ -348,29 +472,29 @@ MUST получать владельца своей записи. Иного и
|
||||
выводиться через запись: файл переживает свою запись, и заведённый шагом до
|
||||
сохранения записи он остаётся с владельцем и без ссылки.
|
||||
|
||||
Отказ наступает **на переходе по ссылке**, а не на выдаче токена файла: токен
|
||||
хранилище выдаёт на предъявителя, а не на файл, и о файле при выдаче не
|
||||
спрашивает вовсе. Требовать отказа при выдаче значит требовать механизма,
|
||||
которого нет, — а проверка, написанная под такое требование, зеленела бы, не
|
||||
касаясь пути, по которому аудио и уходит.
|
||||
Отказ наступает **на самом обращении за файлом**: другого места, где он мог бы
|
||||
наступить, у сервиса не осталось — значений на предъявителя он не выдаёт.
|
||||
Проверка, судящая владельца где-то ещё, зеленела бы, не касаясь пути, по
|
||||
которому аудио и уходит.
|
||||
|
||||
#### Scenario: Чужой файл не отдаётся
|
||||
|
||||
- **GIVEN** запись принята одним узнанным
|
||||
- **WHEN** другой узнанный идёт по ссылке на файл этой записи со своим токеном
|
||||
- **WHEN** другой узнанный просит файл этой записи
|
||||
- **THEN** содержимого он не получает
|
||||
- **AND** ответ тот же, что и на неизвестный идентификатор записи
|
||||
|
||||
#### Scenario: Свой файл отдаётся
|
||||
|
||||
- **GIVEN** человек принял запись
|
||||
- **WHEN** он идёт по ссылке на файл своей записи со своим токеном
|
||||
- **WHEN** он просит файл своей записи
|
||||
- **THEN** содержимое отдаётся
|
||||
|
||||
#### Scenario: Файл без владельца не сохраняется
|
||||
|
||||
- **GIVEN** сервис поднят
|
||||
- **WHEN** файл записи пытаются сохранить с пустым владельцем
|
||||
- **THEN** хранилище его не сохраняет
|
||||
- **THEN** база его не сохраняет
|
||||
|
||||
#### Scenario: Приведённая копия получает владельца записи
|
||||
|
||||
@@ -382,69 +506,63 @@ MUST получать владельца своей записи. Иного и
|
||||
### Requirement: Учётная запись с записями не удаляется
|
||||
|
||||
Хранилище SHALL отвергать удаление учётной записи, у которой остались
|
||||
аудиозаписи **либо файлы**. Отказ MUST называть причину, и MUST доезжать до
|
||||
спрашивающего: хранилище пропускает наружу только свою ошибку роутера, а всякую
|
||||
другую подменяет сообщением про обязательную связь — подсказкой, по которой
|
||||
владелец панели пойдёт удалять записи руками.
|
||||
аудиозаписи, файлы **либо темы словаря**, и MUST держать этот запрет самой
|
||||
схемой — обязательной связью, которая не даёт убрать строку, пока на неё
|
||||
ссылаются.
|
||||
|
||||
Считаются **все** коллекции с колонкой владельца, и перечень их MUST жить одним
|
||||
местом: коллекция, пропущенная в счёте, пропускает удаление вперёд, и наружу
|
||||
приезжает не наш отказ с причиной, а подсказка библиотеки про обязательную связь
|
||||
— та самая, по которой владелец панели пойдёт удалять записи руками. Сегодня их
|
||||
три: аудиозаписи, файлы и словарь тем.
|
||||
Считаются **все** таблицы с колонкой владельца, и перечень их MUST жить одним
|
||||
местом — шагом схемы, который эти связи объявляет. Таблица, пропущенная в счёте,
|
||||
пропускает удаление вперёд и оставляет за собой строки, чей владелец больше не
|
||||
существует.
|
||||
|
||||
Запрет схемой, а не проверкой вызывающего, — потому что вызывающих у удаления
|
||||
может стать больше одного, а проверка, записанная у одного, у остальных читалась
|
||||
бы как снятая. Сборка, забывшая позвать проверку, теряет защиту молча — и теряла.
|
||||
|
||||
Файл переживает свою запись: шаг конвейера заводит его до сохранения записи, и
|
||||
потерянный захват оставляет файл с владельцем и без ссылки. Тема переживает её
|
||||
так же: словарь принадлежит человеку, а не записи.
|
||||
|
||||
Запрет MUST ставить сама сборка хранилища, а не вызывающий: сборка, забывшая его
|
||||
позвать, теряет защиту молча — и теряла, пока запрет вешался отдельной строкой
|
||||
запуска, а окружение проверок его не ставило вовсе.
|
||||
|
||||
Удаление при этом не только панельное: умолчание библиотеки разрешает вошедшему
|
||||
удалить **свою** учётную запись запросом, так что запрет закрывает и публичную
|
||||
поверхность.
|
||||
|
||||
Цена требования названа прямо: владелец панели упирается в отказ, а способа
|
||||
удалить записи в сервисе пока нет вовсе — его приносит задача про удаление
|
||||
записи. До неё удаление учётной записи с записями невозможно, и это осознанный
|
||||
тупик, а не недосмотр.
|
||||
Цена требования названа прямо: способа удалить записи в сервисе пока нет вовсе —
|
||||
его приносит задача про удаление записи. До неё удаление учётной записи с
|
||||
записями невозможно, и это осознанный тупик, а не недосмотр. Адреса, которым
|
||||
учётную запись удаляют, у сервиса при этом нет: запрет закрывает удаление
|
||||
запросом к базе.
|
||||
|
||||
#### Scenario: Удаление учётной записи с записями отвергается
|
||||
|
||||
- **GIVEN** у учётной записи есть аудиозаписи
|
||||
- **WHEN** её удаляют
|
||||
- **THEN** удаление не проходит, а отказ называет причину
|
||||
- **WHEN** её строку удаляют
|
||||
- **THEN** удаление не проходит
|
||||
- **AND** записи и их владелец остаются прежними
|
||||
|
||||
#### Scenario: Учётная запись с одними файлами тоже не удаляется
|
||||
|
||||
- **GIVEN** у учётной записи остались файлы, но записей нет
|
||||
- **WHEN** её удаляют
|
||||
- **WHEN** её строку удаляют
|
||||
- **THEN** удаление не проходит, а владелец файлов остаётся прежним
|
||||
|
||||
#### Scenario: Учётная запись с одними темами тоже не удаляется
|
||||
|
||||
- **GIVEN** у учётной записи остались темы словаря, но ни записей, ни файлов нет
|
||||
- **WHEN** её удаляют
|
||||
- **THEN** удаление не проходит, а отказ называет причину нашими словами
|
||||
- **WHEN** её строку удаляют
|
||||
- **THEN** удаление не проходит
|
||||
|
||||
#### Scenario: Учётная запись без записей удаляется
|
||||
|
||||
- **GIVEN** у учётной записи нет ни аудиозаписей, ни файлов, ни тем
|
||||
- **WHEN** её удаляют
|
||||
- **WHEN** её строку удаляют
|
||||
- **THEN** удаление проходит
|
||||
|
||||
### Requirement: Аудиозапись — центральная сущность хранилища
|
||||
|
||||
Хранилище SHALL держать аудиозапись отдельной сущностью, а всё, что к ней
|
||||
приложено, — отдельными строками со ссылками с записи. Приложениями считаются
|
||||
Хранилище SHALL держать аудиозапись отдельной таблицей, а всё, что к ней
|
||||
приложено, — отдельными строками со ссылками на запись. Приложениями считаются
|
||||
файлы, тексты, структура реплик, темы, журнал событий и попытка распознавания.
|
||||
|
||||
Поля, которыми распоряжается очередь — признак захвата, срок его протухания,
|
||||
пауза, число отказов, время входа в рубеж, — MUST не соседствовать с содержимым
|
||||
записи в одной строке настолько, чтобы чтение очереди тянуло содержимое: сегодня
|
||||
расшифровка лежит колонкой той же строки и читается при каждом захвате.
|
||||
записи в одной строке настолько, чтобы чтение очереди тянуло содержимое.
|
||||
|
||||
Запись MUST нести заголовок и краткое описание своими колонками: они читаются
|
||||
вместе со списком, сотней штук разом. Расшифровка и вычитанный текст MUST лежать
|
||||
@@ -463,12 +581,11 @@ MUST получать владельца своей записи. Иного и
|
||||
потому, что этой единицей уже названы соседние колонки схемы.
|
||||
|
||||
**Различать «неизвестно» и «ноль» эти колонки не обязаны, и это решение, а не
|
||||
недосмотр.** Числовая колонка хранилища пустого значения не держит вовсе: пустое
|
||||
кладётся нулём, и норма, требующая отличимости, потребовала бы либо четвёртой
|
||||
колонки-признака, либо текстового типа у чисел. Платить за это нечем: обе
|
||||
величины ставит приём, и ставит всегда — запись, метаданные которой прочитать не
|
||||
удалось, отвергается отказом и не заводится вовсе. Ноль в этих колонках означает
|
||||
ноль. Решение владельца 2026-08-15.
|
||||
недосмотр.** Обе величины ставит приём, и ставит всегда — запись, метаданные
|
||||
которой прочитать не удалось, отвергается отказом и не заводится вовсе. Ноль в
|
||||
этих колонках означает ноль, и колонки MUST быть объявлены обязательными: пустое
|
||||
значение, которое схема теперь допустить может, завело бы третий смысл, которого
|
||||
никто не читает. Решение владельца 2026-08-15, и смена хранилища его не отменяет.
|
||||
|
||||
Имя файла на записи и заголовок MUST лежать **разными колонками**. Заголовок
|
||||
несёт название, которое дал человек либо посчитала языковая модель; имя файла —
|
||||
@@ -484,9 +601,7 @@ MUST получать владельца своей записи. Иного и
|
||||
Отсюда норма, без которой два числа читались бы как копии одного: величины
|
||||
записи MUST не сверяться со строкой файла и MUST не переписываться ничем после
|
||||
приёма. Расхождение между ними — не поломка, а разные вопросы: «что человек
|
||||
прислал» и «что лежит сейчас». Уточнение длительности — перечитали метаданные,
|
||||
сменили источник, нарезали длинную запись — меняет вторую величину и не трогает
|
||||
первую.
|
||||
прислал» и «что лежит сейчас».
|
||||
|
||||
#### Scenario: Список читается без содержимого
|
||||
|
||||
@@ -500,55 +615,61 @@ MUST получать владельца своей записи. Иного и
|
||||
- **WHEN** читают её длительность и размер
|
||||
- **THEN** строка файла при этом не читается
|
||||
|
||||
#### Scenario: Пустая длительность в схему не ложится
|
||||
|
||||
- **GIVEN** сервис поднят
|
||||
- **WHEN** аудиозапись пытаются сохранить с пустой длительностью или пустым
|
||||
размером
|
||||
- **THEN** база её не сохраняет
|
||||
|
||||
#### Scenario: Посчитанный заголовок не затирает имя файла
|
||||
|
||||
- **GIVEN** запись принята с именем файла отправителя
|
||||
- **WHEN** записи проставляют заголовок
|
||||
- **THEN** имя файла остаётся прежним
|
||||
|
||||
### Requirement: Содержимое записи закрыто во всех коллекциях, где лежит
|
||||
### Requirement: Содержимое записи закрыто везде, где лежит
|
||||
|
||||
Всякая коллекция, куда переезжает содержимое аудиозаписи, SHALL быть закрыта
|
||||
наравне с самой записью: её правило просмотра MUST не открывать содержимое
|
||||
никому, кроме владельца связанной записи, а поле, хранящее файл или вложение,
|
||||
MUST быть помечено защищённым.
|
||||
Всякая таблица, куда переезжает содержимое аудиозаписи, SHALL быть закрыта
|
||||
наравне с самой записью: сервис MUST не заводить ни одного адреса, которым её
|
||||
строки перечисляются или читаются мимо проверки владельца связанной записи.
|
||||
|
||||
Пока содержимое отдаётся собственным адресом сервиса, а не поверхностью
|
||||
хранилища, правило просмотра MUST оставаться незаданным — то есть «только
|
||||
владелец панели». Непустое правило открывает перечисление коллекции, и заводить
|
||||
его раньше, чем появится потребитель, значит открывать поверхность впрок:
|
||||
норму держит требование «Наружу хранилище отдаёт только то, что заказано».
|
||||
Требование распространяется на все приложения записи — тексты, структуру реплик,
|
||||
попытку распознавания с её сохранённым ответом, журнал событий и темы — и
|
||||
заводится потому, что содержимое лежит не в одной строке, а в нескольких.
|
||||
Правило одно на все: записанное у одного хранителя, у остальных оно читалось бы
|
||||
как снятое.
|
||||
|
||||
Требование распространяется на все коллекции приложений — тексты, структуру
|
||||
реплик, попытку распознавания с её сохранённым ответом, журнал событий и темы, —
|
||||
и заводится потому, что содержимое **переезжает** из одной строки в шесть. Норма
|
||||
о защищённом поле файла сегодня написана про файл записи, а сырой ответ
|
||||
распознавателя — это полный текст речи в другой коллекции: реализация, следующая
|
||||
только прежней норме, завела бы поле с умолчанием библиотеки, и ссылка на него
|
||||
отдавала бы расшифровку любому, кто её знает, без сессии.
|
||||
Сохранённый ответ провайдера — это полный текст речи, и он MUST быть закрыт
|
||||
наравне с расшифровкой, а не считаться служебным вложением. Где именно он лежит,
|
||||
нормирует требование «Файл записи живёт в хранилище»: третьим файлом в
|
||||
подкаталоге записи.
|
||||
|
||||
Ссылка на такое вложение MUST не попадать ни в журнал, ни в метку метрики, ни в
|
||||
ответ отправителю — теми же словами, какими это нормировано для файла записи.
|
||||
Ссылка или путь, по которому содержимое лежит на диске, MUST не попадать ни в
|
||||
журнал, ни в метку метрики, ни в ответ отправителю — теми же словами, какими это
|
||||
нормировано для файла записи.
|
||||
|
||||
Умолчание библиотеки здесь не годится ни в одном месте: незаданное правило
|
||||
просмотра значит «только владелец панели» и отнимает содержимое у самого
|
||||
владельца записи, а незащищённое поле файла отдаёт его всем.
|
||||
Требование заменяет прежнее «Содержимое записи закрыто во всех коллекциях, где
|
||||
лежит»: правил доступа у коллекций и защищённых полей больше нет, а закрытость
|
||||
держится тем, что адреса чтения содержимого пишет сервис и каждый из них судит
|
||||
владельца.
|
||||
|
||||
#### Scenario: Чужой сохранённый ответ не отдаётся
|
||||
|
||||
- **GIVEN** запись принята одним вошедшим и прошла распознавание
|
||||
- **WHEN** другой вошедший идёт по ссылке на сохранённый ответ провайдера
|
||||
- **GIVEN** запись принята одним узнанным и прошла распознавание
|
||||
- **WHEN** другой узнанный просит сохранённый ответ провайдера по этой записи
|
||||
- **THEN** содержимого он не получает
|
||||
|
||||
#### Scenario: Без сессии содержимое не отдаётся
|
||||
#### Scenario: Неузнанному содержимое не отдаётся
|
||||
|
||||
- **WHEN** ссылку на сохранённый ответ провайдера запрашивают без сессии
|
||||
- **WHEN** содержимое записи запрашивают неузнанным
|
||||
- **THEN** приходит отказ, а содержимого в ответе нет
|
||||
|
||||
#### Scenario: Перечисление приложений закрыто
|
||||
#### Scenario: Перечисления приложений записи не существует
|
||||
|
||||
- **WHEN** запрос без прав владельца просит список записей коллекции текстов
|
||||
- **THEN** приходит отказ
|
||||
- **WHEN** ищут адрес, которым перечисляются строки текстов, реплик или попыток
|
||||
распознавания
|
||||
- **THEN** такого адреса у сервиса нет
|
||||
|
||||
### Requirement: Ссылки на исходник и приведённую копию живут порознь
|
||||
|
||||
@@ -603,25 +724,27 @@ MUST быть помечено защищённым.
|
||||
|
||||
### Requirement: Словарь тем ведётся по владельцу
|
||||
|
||||
Хранилище SHALL держать темы отдельной коллекцией, и тема MUST быть уникальна в
|
||||
Хранилище SHALL держать темы отдельной таблицей, и тема MUST быть уникальна в
|
||||
паре «владелец и название»: словарь тем свой у каждого человека. У записи MUST
|
||||
быть не больше пяти тем.
|
||||
|
||||
Коллекцией, а не набором строк в записи, — потому что перечень тем человека
|
||||
нужен целиком перед каждым обращением к модели, а собрать его из наборов строк
|
||||
можно только перебором всех его записей.
|
||||
Отдельной таблицей, а не набором строк в записи, — потому что перечень тем
|
||||
человека нужен целиком перед каждым обращением к модели, а собрать его из
|
||||
наборов строк можно только перебором всех его записей.
|
||||
|
||||
Потолок в пять тем MUST быть у самой записи: без него часовой разговор даёт два
|
||||
десятка тем, и словарь распухает за неделю.
|
||||
Потолок в пять тем MUST держаться самой схемой: без него часовой разговор даёт
|
||||
два десятка тем, и словарь распухает за неделю. То же число сервис объявляет
|
||||
приложению — норму держит capability `archive`, — и второй константы рядом MUST
|
||||
не заводиться.
|
||||
|
||||
Название темы выведено из содержимого записи, а перечень тем человека — слепок
|
||||
того, о чём он вообще говорит. В журнал сервиса темы MUST не попадать наравне с
|
||||
текстом расшифровки.
|
||||
|
||||
Ни один шаг этого изменения тем не пишет и не читает: место заводится вперёд,
|
||||
Ни один шаг этого изменения тем не пишет и не читает: место заведено вперёд,
|
||||
чтобы задача, считающая темы языковой моделью, не платила вторым необратимым
|
||||
шагом схемы. Цена решения названа прямо — имена коллекции и её колонок
|
||||
закрепляются раньше, чем известен их потребитель.
|
||||
шагом схемы. Цена решения названа прямо — имена таблицы и её колонок закрепляются
|
||||
раньше, чем известен их потребитель.
|
||||
|
||||
#### Scenario: Тема одного человека не мешает теме другого
|
||||
|
||||
@@ -642,7 +765,7 @@ MUST быть помечено защищённым.
|
||||
|
||||
Требование стоит на повторном опросе одной и той же операции распознавания.
|
||||
Повтор — обычное дело: держатель захвата умер, сохранение рубежа отказало,
|
||||
человек снял признак остановки в панели. Провайдер при этом вправе ответить
|
||||
человек снял признак остановки подкомандой оснастки. Провайдер при этом вправе ответить
|
||||
пустым потоком, отказом это не считается, и безусловная замена стирала бы
|
||||
расшифровку живого человека — без следа и без возврата, потому что сервис
|
||||
объявлен архивом и удаления по требованию не знает.
|
||||
@@ -661,4 +784,3 @@ MUST быть помечено защищённым.
|
||||
- **WHEN** ту же операцию опрашивают снова, и провайдер отвечает пустым
|
||||
- **THEN** сохранённая расшифровка остаётся прежней
|
||||
- **AND** шаг завершается без отказа
|
||||
|
||||
|
||||
Reference in New Issue
Block a user