- пришедшего называет заголовок Remote-User от прокси, и верят ему только с адреса из перечня trusted_proxies; своего входа у сервиса не осталось — ни корня /auth, ни кук, ни срока сессии, ни секрета клиента в конфиге и в базе - учётная запись заводится первым обращением: EnsureUser в пакете хранилища, шаг схемы 202608220001 с колонкой provider_login и снятыми правилами users - cmd/oidcstub заменён на cmd/devtools с подкомандой proxy; заодно закрыт унаследованный DL3066 — пользователь образа назван числом
665 lines
51 KiB
Markdown
665 lines
51 KiB
Markdown
# storage Specification
|
||
|
||
## Purpose
|
||
|
||
Где живут аудиозапись, её приложения и её файлы: раскладка каталога данных,
|
||
приведение схемы при подъёме, отдача файла ссылкой по токену, собственная
|
||
поверхность хранилища и панель владельца.
|
||
|
||
Приём и опрос готовности нормирует `intake`, вход и сессию — `access`, попытку
|
||
распознавания у внешнего провайдера — `recognition`.
|
||
|
||
Сознательно не описаны: перенос прежних данных — его нет ни по решению задачи
|
||
`pocketbase-storage`, ни по решению владельца 2026-08-14, которым прежние записи
|
||
удалены вместе с остановкой сервиса; удаление записей и файлов — сервис объявлен
|
||
архивом 2026-08-11, а удаление приносит задача `delete-record`.
|
||
## Requirements
|
||
### Requirement: Сервис поднимается на чистом каталоге данных
|
||
|
||
Сервис SHALL приводить хранилище в рабочий вид сам: на пустом каталоге данных он
|
||
MUST завести свою схему и принимать записи своим входом — приёмом по HTTP — без
|
||
единого ручного шага до первого запуска.
|
||
|
||
Прежние данные не переносятся. Каталог, оставшийся от прежней раскладки, MUST не
|
||
читаться и не считаться источником: сервис начинает с чистого листа, и это
|
||
решение задачи, а не следствие отказа.
|
||
|
||
Схема MUST заводиться версионированными шагами, а применённый шаг MUST не
|
||
переписываться — только новым шагом. Иначе повторный запуск на уже заведённом
|
||
каталоге разошёлся бы с первым молча.
|
||
|
||
Каталог данных у сервиса MUST быть один: база и файлы записей лежат под ним
|
||
вместе, и второго пути к ним не заводится.
|
||
|
||
#### Scenario: Первый запуск на пустом каталоге
|
||
|
||
- **GIVEN** каталог данных пуст
|
||
- **WHEN** сервис запускается
|
||
- **THEN** он заводит своё хранилище и продолжает работу
|
||
- **AND** принятая следом запись доходит до состояния `done`
|
||
|
||
#### Scenario: Повторный запуск на заведённом каталоге
|
||
|
||
- **GIVEN** сервис уже запускался на этом каталоге и завёл хранилище
|
||
- **WHEN** он запускается снова
|
||
- **THEN** он не заводит схему второй раз и не теряет прежние записи
|
||
|
||
### Requirement: Файл записи живёт в хранилище
|
||
|
||
Сервис SHALL держать файл записи в хранилище, а не отдельным каталогом рядом с
|
||
ним. Файл MUST попадать туда вместе с записью, которой принадлежит, и MUST
|
||
адресоваться этой записью, а не путём на диске.
|
||
|
||
Раскладку файлов на диске выбирает хранилище. Собственного плоского каталога
|
||
записей у сервиса MUST не оставаться: файл, лежащий мимо хранилища, не попадёт
|
||
ни в панель владельца, ни в резервную копию, а ради этих двух вещей перевод и
|
||
делается.
|
||
|
||
Содержимое записи MUST не читаться в память целиком ни при укладке в хранилище,
|
||
ни при чтении из него: расчётный потолок записи — шесть часов, и такая запись в
|
||
память не помещается.
|
||
|
||
**Потолок размера записи MUST быть задан числом, выведенным из этого расчётного
|
||
потолка**, и задан он MUST быть везде, где иначе действует чужое умолчание: и у
|
||
поля файла в хранилище, и у тела запроса приёма. Умолчания здесь не «без
|
||
предела», а величины на два-три порядка меньше нужного, и оставленные как есть
|
||
они отвергают штатную запись сервиса — приём отказывает, а уже принятая запись
|
||
исчерпывает попытки на шаге конвертации.
|
||
|
||
Отказ по этому потолку MUST быть виден отправителю ответом, а не молчанием.
|
||
|
||
Шаги, которым нужен файл именем на диске — конвертация и чтение метаданных
|
||
отдают его внешней программе, — MUST получать рабочую копию **одним общим
|
||
способом**, и у этого способа MUST быть единственный способ её убрать. Уборку
|
||
зовёт шаг, и звать её он MUST на любом исходе, включая отказ. Заводить копию по
|
||
месту шагам MUST не приходиться: иначе обязанность прибрать переписывается
|
||
столько раз, сколько шагов, а забытая копия — это шестичасовая запись,
|
||
оставшаяся во временном каталоге, и узнать о ней неоткуда.
|
||
|
||
#### Scenario: Принятая запись легла в хранилище
|
||
|
||
- **WHEN** запись принята любым входом
|
||
- **THEN** её файл лежит в хранилище и связан со своей записью
|
||
- **AND** отдельного каталога записей рядом с хранилищем не появляется
|
||
|
||
#### Scenario: Запись длиннее чужого умолчания принимается
|
||
|
||
- **WHEN** в хранилище кладут запись длиннее умолчания, действующего у поля файла
|
||
- **THEN** она ложится в хранилище, а не отвергается
|
||
|
||
#### Scenario: Шаг конвейера берёт файл по записи
|
||
|
||
- **GIVEN** запись принята и её файл лежит в хранилище
|
||
- **WHEN** шаг конвейера берётся за эту запись
|
||
- **THEN** он получает файл по самой записи, а не по пути на диске
|
||
|
||
#### Scenario: Рабочая копия убрана после отказа шага
|
||
|
||
- **GIVEN** шагу выдана рабочая копия файла
|
||
- **WHEN** шаг завершается отказом
|
||
- **THEN** рабочей копии во временном каталоге не остаётся
|
||
|
||
### Requirement: Файл отдаётся ссылкой
|
||
|
||
Сервис SHALL отдавать файл записи ссылкой, которую строит хранилище по самой
|
||
записи, **и только узнанному отправителю**. Поле файла MUST быть помечено
|
||
защищённым: без этого ссылка открывает запись любому, кто её знает, и знание
|
||
ссылки становится правом. Отданный файл MUST совпадать с принятым по длине.
|
||
|
||
Одной пометки мало: защищённый файл судится **коротким токеном файла**, который
|
||
узнанный отправитель берёт у хранилища, — и правилом просмотра коллекции.
|
||
Правило MUST пускать только владельца файла: незаданное означает «только владелец
|
||
панели», и тогда файла не получит и узнанный, а прежнее «всякий узнанный»
|
||
отдавало чужое аудио тому, кто знает идентификатор записи.
|
||
|
||
Токен файла хранилище выдаёт **на предъявителя**, а не на файл, и о файле при
|
||
выдаче не спрашивает. Значит владельца судит переход по ссылке, а не выдача
|
||
токена: отказ наступает там, и требовать его от выдачи значит требовать
|
||
механизма, которого нет.
|
||
|
||
Отсюда порядок для потребителя: узнавание → токен файла → ссылка с этим токеном.
|
||
Адрес выдачи токена лежит в пространстве хранилища, и узнавание по заголовку MUST
|
||
на нём работать — иначе файл записи недостижим для браузера вовсе. Одного
|
||
заголовка при этом мало: без токена ссылка файла не отдаёт, и это свойство
|
||
хранилища, а не недосмотр.
|
||
|
||
Конвейер расшифровки этим не затронут: он читает файл из файловой системы
|
||
хранилища, а не по ссылке.
|
||
|
||
Ссылка на несуществующую запись MUST отвечать отказом, а не пустым файлом.
|
||
|
||
**Ссылка сама по себе и есть право пройти по ней**, и потому она MUST не попадать
|
||
ни в журнал, ни в метку метрики, ни в ответ отправителю. Имя, под которым файл
|
||
лёг в хранилище, из журнала выводимо быть не должно: журнал уезжает в собранные
|
||
логи, откуда строку не убрать, и оттуда ссылка на чужую запись работала бы
|
||
бессрочно.
|
||
|
||
Защищённое поле сужает это право, но не отменяет запрета: право пройти теперь
|
||
требует ещё и узнавания, а строка журнала со ссылкой по-прежнему собирала бы
|
||
половину ключа.
|
||
|
||
Отсюда требование к отказам: сообщение об отказе хранилища MUST не выходить за
|
||
пределы хранилища дословно. Отказ чтения и отказ укладки называют ключ файла
|
||
целиком, а отказ выгрузки во внешнее хранилище — полный адрес объекта; и то и
|
||
другое кончается в журнале и собирает ссылку не хуже успешного пути.
|
||
|
||
Что именно журнал приёма пишет ради прослеживаемости, нормирует capability
|
||
`intake`.
|
||
|
||
#### Scenario: Файл забирают по ссылке
|
||
|
||
- **GIVEN** запись принята и её файл лежит в хранилище
|
||
- **AND** забирающий узнан и взял токен файла
|
||
- **WHEN** ссылку на файл запрашивают с этим токеном
|
||
- **THEN** приходит тот же файл, и его длина совпадает с длиной принятого
|
||
|
||
#### Scenario: Неузнанному файл не отдаётся
|
||
|
||
- **GIVEN** запись принята и её файл лежит в хранилище
|
||
- **WHEN** ссылку на файл запрашивают неузнанным
|
||
- **THEN** приходит отказ, а содержимого записи в ответе нет
|
||
|
||
#### Scenario: Токен файла выдаётся узнанному по заголовку
|
||
|
||
- **GIVEN** запрос идёт с доверенного адреса с заголовком `Remote-User`
|
||
- **WHEN** он просит у хранилища токен файла
|
||
- **THEN** токен выдаётся
|
||
|
||
#### Scenario: Конвейер читает файл без узнавания
|
||
|
||
- **GIVEN** запись принята и ждёт расшифровки
|
||
- **WHEN** шаг конвейера берётся за неё
|
||
- **THEN** файл читается из файловой системы хранилища и шаг проходит
|
||
|
||
#### Scenario: Ссылка ведёт в никуда
|
||
|
||
- **WHEN** запрашивают ссылку на запись, которой нет
|
||
- **THEN** приходит отказ, а не пустой ответ
|
||
|
||
#### Scenario: По журналу ссылку не собрать
|
||
|
||
- **GIVEN** запись принята и прошла конвейер
|
||
- **WHEN** читают журнал сервиса целиком
|
||
- **THEN** имени, под которым файл лёг в хранилище, в нём нет
|
||
|
||
#### Scenario: Отказ чтения файла не называет его ключ
|
||
|
||
- **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 не назначаться и не меняться конвейером.
|
||
|
||
#### Scenario: Колонка появляется на пустой базе
|
||
|
||
- **WHEN** сервис поднимается на чистом каталоге данных
|
||
- **THEN** у аудиозаписи есть колонка владельца
|
||
- **AND** умолчания у неё нет
|
||
- **AND** пустого значения она не принимает
|
||
|
||
#### Scenario: Запись без владельца не сохраняется
|
||
|
||
- **GIVEN** сервис поднят
|
||
- **WHEN** аудиозапись пытаются сохранить с пустым владельцем — приёмом,
|
||
конвейером или руками в панели
|
||
- **THEN** хранилище её не сохраняет
|
||
|
||
#### Scenario: Конвейер владельца не назначает
|
||
|
||
- **GIVEN** запись с владельцем прошла шаг конвейера
|
||
- **WHEN** смотрят её владельца
|
||
- **THEN** он прежний
|
||
|
||
### Requirement: Файл записи сужается владельцем наравне с задачей
|
||
|
||
Хранилище SHALL держать владельца и у файла записи — той же связью с учётной
|
||
записью, — и правило просмотра файлов MUST пускать к файлу только его владельца.
|
||
|
||
Владелец файла MUST назначаться при приёме, из узнанного предъявителя, а колонка
|
||
файла MUST не допускать пустого значения наравне с колонкой записи. Прежде пустое
|
||
значение оставалось у файлов, заведённых конвейером для записи без владельца;
|
||
таких записей больше не заводится, и разное правило у записи и у её файла
|
||
читалось бы как недосмотр.
|
||
|
||
Файл, заведённый шагом конвейера, — приведённую копию заводит именно он —
|
||
MUST получать владельца своей записи. Иного источника владельца у файла нет, и
|
||
шаг, оставивший его пустым, упрётся в отказ сохранения: запись накопит отказы и
|
||
остановится признаком на первом же приведении.
|
||
|
||
Ссылки на файлы у записи две — на принятую копию и на приведённую, — и обе живут
|
||
до конца, но владелец файла MUST по-прежнему лежать своей колонкой, а не
|
||
выводиться через запись: файл переживает свою запись, и заведённый шагом до
|
||
сохранения записи он остаётся с владельцем и без ссылки.
|
||
|
||
Отказ наступает **на переходе по ссылке**, а не на выдаче токена файла: токен
|
||
хранилище выдаёт на предъявителя, а не на файл, и о файле при выдаче не
|
||
спрашивает вовсе. Требовать отказа при выдаче значит требовать механизма,
|
||
которого нет, — а проверка, написанная под такое требование, зеленела бы, не
|
||
касаясь пути, по которому аудио и уходит.
|
||
|
||
#### Scenario: Чужой файл не отдаётся
|
||
|
||
- **GIVEN** запись принята одним узнанным
|
||
- **WHEN** другой узнанный идёт по ссылке на файл этой записи со своим токеном
|
||
- **THEN** содержимого он не получает
|
||
|
||
#### Scenario: Свой файл отдаётся
|
||
|
||
- **GIVEN** человек принял запись
|
||
- **WHEN** он идёт по ссылке на файл своей записи со своим токеном
|
||
- **THEN** содержимое отдаётся
|
||
|
||
#### Scenario: Файл без владельца не сохраняется
|
||
|
||
- **GIVEN** сервис поднят
|
||
- **WHEN** файл записи пытаются сохранить с пустым владельцем
|
||
- **THEN** хранилище его не сохраняет
|
||
|
||
#### Scenario: Приведённая копия получает владельца записи
|
||
|
||
- **GIVEN** запись с владельцем дошла до приведения
|
||
- **WHEN** шаг заводит приведённую копию файла
|
||
- **THEN** владельцем копии стоит владелец записи
|
||
- **AND** шаг завершается без отказа
|
||
|
||
### Requirement: Учётная запись с записями не удаляется
|
||
|
||
Хранилище SHALL отвергать удаление учётной записи, у которой остались
|
||
аудиозаписи **либо файлы**. Отказ MUST называть причину, и MUST доезжать до
|
||
спрашивающего: хранилище пропускает наружу только свою ошибку роутера, а всякую
|
||
другую подменяет сообщением про обязательную связь — подсказкой, по которой
|
||
владелец панели пойдёт удалять записи руками.
|
||
|
||
Считаются **все** коллекции с колонкой владельца, и перечень их 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` (байты). Единица стоит в самом имени,
|
||
а не в комментарии: шаг схемы применённым не переписывается, а расхождение
|
||
«секунды против миллисекунд» между колонкой, ответом списка и объявленным
|
||
пределом не увидит ни компилятор, ни гейт — оба конца числа. Миллисекунды выбраны
|
||
потому, что этой единицей уже названы соседние колонки схемы.
|
||
|
||
**Различать «неизвестно» и «ноль» эти колонки не обязаны, и это решение, а не
|
||
недосмотр.** Числовая колонка хранилища пустого значения не держит вовсе: пустое
|
||
кладётся нулём, и норма, требующая отличимости, потребовала бы либо четвёртой
|
||
колонки-признака, либо текстового типа у чисел. Платить за это нечем: обе
|
||
величины ставит приём, и ставит всегда — запись, метаданные которой прочитать не
|
||
удалось, отвергается отказом и не заводится вовсе. Ноль в этих колонках означает
|
||
ноль. Решение владельца 2026-08-15.
|
||
|
||
Имя файла на записи и заголовок MUST лежать **разными колонками**. Заголовок
|
||
несёт название, которое дал человек либо посчитала языковая модель; имя файла —
|
||
то, по чему человек узнаёт свою запись, пока заголовка нет. Одной колонкой на оба
|
||
смысла посчитанное название затирало бы имя, и вернуть затёртое было бы неоткуда.
|
||
|
||
Величины на записи и на её файле расходятся по смыслу, и **равенство между ними
|
||
не поддерживается никем — намеренно**. На записи лежит снимок **принятого**,
|
||
взятый приёмом один раз и больше не пересчитываемый; на файле — величины той
|
||
копии, которой файл является сейчас. Приведённая копия имеет свой размер, и
|
||
записи он не принадлежит.
|
||
|
||
Отсюда норма, без которой два числа читались бы как копии одного: величины
|
||
записи MUST не сверяться со строкой файла и MUST не переписываться ничем после
|
||
приёма. Расхождение между ними — не поломка, а разные вопросы: «что человек
|
||
прислал» и «что лежит сейчас». Уточнение длительности — перечитали метаданные,
|
||
сменили источник, нарезали длинную запись — меняет вторую величину и не трогает
|
||
первую.
|
||
|
||
#### Scenario: Список читается без содержимого
|
||
|
||
- **GIVEN** у записи есть расшифровка
|
||
- **WHEN** читают запись ради её рубежа и заголовка
|
||
- **THEN** текст расшифровки при этом не читается
|
||
|
||
#### Scenario: Длительность и размер читаются без строки файла
|
||
|
||
- **GIVEN** запись принята
|
||
- **WHEN** читают её длительность и размер
|
||
- **THEN** строка файла при этом не читается
|
||
|
||
#### Scenario: Посчитанный заголовок не затирает имя файла
|
||
|
||
- **GIVEN** запись принята с именем файла отправителя
|
||
- **WHEN** записи проставляют заголовок
|
||
- **THEN** имя файла остаётся прежним
|
||
|
||
### Requirement: Содержимое записи закрыто во всех коллекциях, где лежит
|
||
|
||
Всякая коллекция, куда переезжает содержимое аудиозаписи, SHALL быть закрыта
|
||
наравне с самой записью: её правило просмотра MUST не открывать содержимое
|
||
никому, кроме владельца связанной записи, а поле, хранящее файл или вложение,
|
||
MUST быть помечено защищённым.
|
||
|
||
Пока содержимое отдаётся собственным адресом сервиса, а не поверхностью
|
||
хранилища, правило просмотра MUST оставаться незаданным — то есть «только
|
||
владелец панели». Непустое правило открывает перечисление коллекции, и заводить
|
||
его раньше, чем появится потребитель, значит открывать поверхность впрок:
|
||
норму держит требование «Наружу хранилище отдаёт только то, что заказано».
|
||
|
||
Требование распространяется на все коллекции приложений — тексты, структуру
|
||
реплик, попытку распознавания с её сохранённым ответом, журнал событий и темы, —
|
||
и заводится потому, что содержимое **переезжает** из одной строки в шесть. Норма
|
||
о защищённом поле файла сегодня написана про файл записи, а сырой ответ
|
||
распознавателя — это полный текст речи в другой коллекции: реализация, следующая
|
||
только прежней норме, завела бы поле с умолчанием библиотеки, и ссылка на него
|
||
отдавала бы расшифровку любому, кто её знает, без сессии.
|
||
|
||
Ссылка на такое вложение MUST не попадать ни в журнал, ни в метку метрики, ни в
|
||
ответ отправителю — теми же словами, какими это нормировано для файла записи.
|
||
|
||
Умолчание библиотеки здесь не годится ни в одном месте: незаданное правило
|
||
просмотра значит «только владелец панели» и отнимает содержимое у самого
|
||
владельца записи, а незащищённое поле файла отдаёт его всем.
|
||
|
||
#### Scenario: Чужой сохранённый ответ не отдаётся
|
||
|
||
- **GIVEN** запись принята одним вошедшим и прошла распознавание
|
||
- **WHEN** другой вошедший идёт по ссылке на сохранённый ответ провайдера
|
||
- **THEN** содержимого он не получает
|
||
|
||
#### Scenario: Без сессии содержимое не отдаётся
|
||
|
||
- **WHEN** ссылку на сохранённый ответ провайдера запрашивают без сессии
|
||
- **THEN** приходит отказ, а содержимого в ответе нет
|
||
|
||
#### Scenario: Перечисление приложений закрыто
|
||
|
||
- **WHEN** запрос без прав владельца просит список записей коллекции текстов
|
||
- **THEN** приходит отказ
|
||
|
||
### Requirement: Ссылки на исходник и приведённую копию живут порознь
|
||
|
||
Аудиозапись SHALL нести две отдельные ссылки на файлы — на принятую копию и на
|
||
копию, приведённую к рабочему формату, — и шаг конвейера MUST не переставлять
|
||
одну ссылку на свой результат.
|
||
|
||
Сегодня ссылка одна, и её переставляет каждый шаг: у прошедшей конвейер записи
|
||
она ведёт на копию во внешнем хранилище, а принятого человеком файла не найти
|
||
ничем. Послушать загруженное нечем именно поэтому.
|
||
|
||
Обе копии MUST оставаться доступными после того, как запись прошла конвейер.
|
||
|
||
#### Scenario: После конвейера доступны обе копии
|
||
|
||
- **GIVEN** запись прошла конвейер целиком
|
||
- **WHEN** смотрят её ссылки на файлы
|
||
- **THEN** ссылка на принятую копию и ссылка на приведённую заполнены
|
||
- **AND** обе открываются
|
||
|
||
### Requirement: Тексты и структура лежат отдельно от записи
|
||
|
||
Хранилище SHALL держать тексты записи отдельными строками, каждая со своим видом
|
||
текста, и структуру реплик — своей строкой. Запись MUST ссылаться на них, а не
|
||
хранить их колонками.
|
||
|
||
Видов текста больше одного: сырая расшифровка и вычитанный текст. Колонкой на
|
||
каждый вид схема росла бы с каждым новым видом, а необратимый шаг схемы платится
|
||
за каждую такую колонку отдельно.
|
||
|
||
**Приложение MUST быть уникально по паре «запись и вид»**, а структура — по паре
|
||
«запись и версия разбора». Шаг завершения пишет текст, структуру и сохранённый
|
||
ответ несколькими операциями и только потом двигает рубеж: прерванный на середине
|
||
и повторённый с прежнего рубежа, он завёл бы второй комплект строк, и вопрос
|
||
«какой текст отдавать человеку» стал бы вопросом порядка записи, а не состояния.
|
||
|
||
Потребитель текста MUST называть **вид**, который берёт, а не брать последний
|
||
записанный: иначе исход зависит от порядка записи. Адрес, которым текст уходит
|
||
приложению, называет вид запросом — норму держит capability `archive`.
|
||
|
||
#### Scenario: Расшифровка лежит своей строкой
|
||
|
||
- **GIVEN** запись прошла распознавание
|
||
- **WHEN** смотрят, где лежит текст расшифровки
|
||
- **THEN** он лежит отдельной строкой, на которую запись ссылается
|
||
|
||
#### Scenario: Повтор шага не заводит второй расшифровки
|
||
|
||
- **GIVEN** шаг завершения записал расшифровку и оборвался до смены рубежа
|
||
- **WHEN** шаг повторяется с прежнего рубежа
|
||
- **THEN** строка расшифровки у записи одна
|
||
|
||
### Requirement: Словарь тем ведётся по владельцу
|
||
|
||
Хранилище SHALL держать темы отдельной коллекцией, и тема MUST быть уникальна в
|
||
паре «владелец и название»: словарь тем свой у каждого человека. У записи MUST
|
||
быть не больше пяти тем.
|
||
|
||
Коллекцией, а не набором строк в записи, — потому что перечень тем человека
|
||
нужен целиком перед каждым обращением к модели, а собрать его из наборов строк
|
||
можно только перебором всех его записей.
|
||
|
||
Потолок в пять тем MUST быть у самой записи: без него часовой разговор даёт два
|
||
десятка тем, и словарь распухает за неделю.
|
||
|
||
Название темы выведено из содержимого записи, а перечень тем человека — слепок
|
||
того, о чём он вообще говорит. В журнал сервиса темы MUST не попадать наравне с
|
||
текстом расшифровки.
|
||
|
||
Ни один шаг этого изменения тем не пишет и не читает: место заводится вперёд,
|
||
чтобы задача, считающая темы языковой моделью, не платила вторым необратимым
|
||
шагом схемы. Цена решения названа прямо — имена коллекции и её колонок
|
||
закрепляются раньше, чем известен их потребитель.
|
||
|
||
#### Scenario: Тема одного человека не мешает теме другого
|
||
|
||
- **GIVEN** у двух владельцев заведена тема с одинаковым названием
|
||
- **WHEN** смотрят словарь тем
|
||
- **THEN** это две разные темы, каждая своего владельца
|
||
|
||
#### Scenario: Шестая тема не заводится
|
||
|
||
- **WHEN** записи назначают шестую тему
|
||
- **THEN** назначение не проходит
|
||
|
||
### Requirement: Пустой результат не кладётся поверх сохранённого
|
||
|
||
Хранилище SHALL не заменять сохранённое содержимое приложения записи — текст и
|
||
структуру реплик — пустым. Замена пустым MUST оставлять прежнее значение и
|
||
считаться сделанной работой, а не отказом.
|
||
|
||
Требование стоит на повторном опросе одной и той же операции распознавания.
|
||
Повтор — обычное дело: держатель захвата умер, сохранение рубежа отказало,
|
||
человек снял признак остановки в панели. Провайдер при этом вправе ответить
|
||
пустым потоком, отказом это не считается, и безусловная замена стирала бы
|
||
расшифровку живого человека — без следа и без возврата, потому что сервис
|
||
объявлен архивом и удаления по требованию не знает.
|
||
|
||
Та же защита MUST стоять у сырого ответа провайдера: разное правило у двух
|
||
хранителей одного результата читается как недосмотр, и один из них молча теряет
|
||
то, ради чего второй заведён.
|
||
|
||
Норма записана со стороны **хранилища**, а не шага: шагов, кладущих текст,
|
||
больше одного, и правило, записанное у одного из них, у остальных читалось бы
|
||
как снятое.
|
||
|
||
#### Scenario: Пустой второй ответ не стирает расшифровку
|
||
|
||
- **GIVEN** расшифровка записи сохранена
|
||
- **WHEN** ту же операцию опрашивают снова, и провайдер отвечает пустым
|
||
- **THEN** сохранённая расшифровка остаётся прежней
|
||
- **AND** шаг завершается без отказа
|
||
|