Files
transcriber/openspec/specs/storage/spec.md
T
av 7f33c957e5 вход переехал на доверенный заголовок Authelia вместо собственного OIDC
- пришедшего называет заголовок Remote-User от прокси, и верят ему только с
  адреса из перечня trusted_proxies; своего входа у сервиса не осталось — ни
  корня /auth, ни кук, ни срока сессии, ни секрета клиента в конфиге и в базе
- учётная запись заводится первым обращением: EnsureUser в пакете хранилища,
  шаг схемы 202608220001 с колонкой provider_login и снятыми правилами users
- cmd/oidcstub заменён на cmd/devtools с подкомандой proxy; заодно закрыт
  унаследованный DL3066 — пользователь образа назван числом
2026-08-22 20:24:22 +03:00

51 KiB

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 шаг завершается без отказа