хранилище переехало с PocketBase на SQLite со своим каталогом файлов

- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose
  под файловым замком, одна миграция начальной схемы вместо семи прежних
- транспорт переписан на net/http: свои слои, свой ограничитель частоты,
  отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли
- по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета
  читается справа налево, узнавание известного идёт читающим пулом
This commit is contained in:
av
2026-08-23 08:06:04 +03:00
parent 1edf8cb225
commit c9b7765646
118 changed files with 11668 additions and 6679 deletions
@@ -0,0 +1,743 @@
## ADDED Requirements
### Requirement: База принимает одного писателя
Сервис SHALL держать у базы **одно** соединение для записи, а чтение MUST вести
отдельно от него. Журнал упреждающей записи MUST быть включён, принудительное
соблюдение внешних ключей MUST быть включено, а ожидание занятой базы MUST
задаваться числом, а не оставаться умолчанием драйвера.
Все три настройки MUST задаваться **строкой подключения обоих пулов** — и
пишущего, и читающего, — а не отдельным запросом после открытия. Соблюдение
внешних ключей в SQLite — настройка соединения, а не базы, и по умолчанию она
выключена: `PRAGMA foreign_keys` на свежем соединении отвечает `0`. Пул раздаёт
соединения и заводит новые по мере надобности, поэтому запрос, выполненный один
раз после открытия, настраивает одно соединение из многих, а остальные остаются с
умолчанием — молча. На включённых внешних ключах держатся требования «Учётная
запись с записями не удаляется», «Владелец, которого нет, не принимается» и «Файл
без владельца не сохраняется»: с выключенными все три зеленеют на том соединении,
где настройку успели поставить, и не работают на соседнем.
Требование заводится потому, что эту настройку прежде держала за нас чужая
библиотека двумя пулами. Драйвер пишет единственным соединением: несколько
воркеров, пишущих разом мимо этого правила, получают отказ «база занята» — и
получают его на записи результата шага, то есть после оплаченной работы.
**Всякая операция, которая читает и следом пишет, MUST идти целиком на пишущем
соединении** — и чтение, и запись, и объемлющая их транзакция. Транзакция,
начатая на читающем соединении и позже пытающаяся писать, получает отказ по
занятости **немедленно**: повысить начатую читающую транзакцию до пишущей SQLite
не даёт, и заданное числом ожидание такой отказ не лечит — ждать там нечего. Под
правило подпадают захват записи и накат шага схемы: каждый читает состояние,
которое сам же меняет.
**Узнавание под правило не подпадает, и устроено оно двумя соединениями.**
Поиск учётной записи по логину у провайдера MUST идти читающим пулом, а пишущая
транзакция MUST открываться только тогда, когда запись не нашлась. Слой
узнавания одет на весь узнанный поток — опрос карточки и каждый запрос диапазона
при проигрывании, — а заводится учётная запись один раз за жизнь человека:
пишущая транзакция, взятая до поиска, ставила бы весь этот поток в очередь к
единственному пишущему соединению. Очередь эта ожиданием занятой базы не
ограничена и отказом не кончается — обращение просто ждёт, и сотни миллисекунд
ожидания видны только замером.
Окно между двумя соединениями MUST закрываться **повторным поиском внутри
транзакции**: пока её ждали, запись успевает завести сосед, и найденную надо
взять, а не заводить вторую. Уникальность ключа при этом держит схема, а не
порядок обращений.
Значения ожидания и числа соединений MUST жить там, где проект держит числовые
настройки, и MUST не повторяться второй константой рядом.
#### Scenario: Несколько воркеров пишут разом
- **GIVEN** число рабочих потоков конвейера больше одного
- **AND** все они дошли до записи своего результата одновременно
- **WHEN** результаты записываются
- **THEN** каждый записан, и ни один не отказал по занятости базы
#### Scenario: Настройки базы применены при подъёме
- **WHEN** сервис поднялся на чистом каталоге данных
- **THEN** у базы включён журнал упреждающей записи
- **AND** ожидание занятой базы равно объявленному числу
#### Scenario: Внешние ключи включены на соединении читающего пула
- **GIVEN** сервис поднялся на чистом каталоге данных
- **WHEN** соединение берут из читающего пула и спрашивают у него `PRAGMA
foreign_keys`
- **THEN** ответ — `1`
#### Scenario: Узнавание известного не ждёт писателя
- **GIVEN** учётная запись с этим логином уже заведена
- **AND** пишущее соединение занято открытой транзакцией
- **WHEN** приходит следующее обращение тем же логином
- **THEN** учётная запись узнана, и обращение не ждёт освобождения писателя
- **AND** второй учётной записи не заведено
#### Scenario: Составная операция не отказывает по занятости
- **GIVEN** число рабочих потоков конвейера больше одного
- **AND** каждый выполняет операцию, которая читает состояние записи и следом его
пишет
- **WHEN** операции идут одновременно
- **THEN** каждая завершена, и ни одна не отказала по занятости базы
### Requirement: Время и идентификаторы приходят из одного места
Хранилище SHALL держать **все** колонки времени одним представлением: `TEXT` в
RFC 3339, UTC, с суффиксом `Z` и секундной точностью — `2006-01-02T15:04:05Z`.
Второго вида времени в схеме MUST не заводиться, включая колонки, которые пишет
только сам сервис.
Ширина такой записи постоянная, поэтому лексикографический порядок `TEXT`
совпадает с хронологией, и отбор по колонке времени работает без разбора
значения. Своего типа времени у SQLite нет: колонка хранит то, что в неё
положили, а отбор захвата сравнивает строки — колонка, заполненная то одним
видом, то другим, обращает условие срока протухания в постоянную истину или ложь
молча, и запись не выдаётся ни одному воркеру никогда.
**Время ставит приложение, а не умолчание шага схемы**, и берёт оно его из единой
точки чтения времени, которую держит линтер проекта. Умолчаний вида
`CURRENT_TIMESTAMP` в схеме MUST не заводиться. Выбрано так по двум причинам:
умолчание схемы пишет свой вид времени, отличный от объявленного выше, и вставка,
забывшая проставить время, при умолчании проходит молча, а без него падает
громко. Прежнее расхождение — вид времени задавало встроенное хранилище своим
форматом с пробелом и долями секунды — уходит вместе с ним, и правило остаётся
одно.
Идентификатор строки SHALL быть **ULID в нижнем регистре, колонкой `TEXT`**, и
ставить его MUST приложение единой точкой при заведении строки. Это то, что
конвенция проекта объявляет нормой; расхождение, при котором идентификаторы
выдавало встроенное хранилище собственным алфавитом, уходит вместе с ним.
Идентификатор, пришедший снаружи, MUST разбираться на границе — разбор проверяет
вид и приводит регистр, — а каким кодом отвечает негодный, нормирует capability
`archive`.
#### Scenario: Вид времени один на все колонки
- **GIVEN** сервис поднялся на чистом каталоге данных
- **WHEN** смотрят колонки времени в применённой схеме
- **THEN** все они объявлены одним типом и несут время одним видом
- **AND** умолчания времени ни у одной из них нет
#### Scenario: Строка из приёма и строка из запроса к базе отбираются одинаково
- **GIVEN** одна аудиозапись заведена приёмом, а вторая — запросом к базе руками
- **AND** обе стоят на одном рубеже и пригодны к захвату
- **WHEN** воркеры разбирают очередь
- **THEN** захвату выдаются обе
- **AND** ни одна не остаётся в очереди навсегда
### Requirement: Содержимое записи закрыто везде, где лежит
Всякая таблица, куда переезжает содержимое аудиозаписи, SHALL быть закрыта
наравне с самой записью: сервис MUST не заводить ни одного адреса, которым её
строки перечисляются или читаются мимо проверки владельца связанной записи.
Требование распространяется на все приложения записи — тексты, структуру реплик,
попытку распознавания с её сохранённым ответом, журнал событий и темы — и
заводится потому, что содержимое лежит не в одной строке, а в нескольких.
Правило одно на все: записанное у одного хранителя, у остальных оно читалось бы
как снятое.
Сохранённый ответ провайдера — это полный текст речи, и он MUST быть закрыт
наравне с расшифровкой, а не считаться служебным вложением. Где именно он лежит,
нормирует требование «Файл записи живёт в хранилище»: третьим файлом в
подкаталоге записи.
Ссылка или путь, по которому содержимое лежит на диске, MUST не попадать ни в
журнал, ни в метку метрики, ни в ответ отправителю — теми же словами, какими это
нормировано для файла записи.
Требование заменяет прежнее «Содержимое записи закрыто во всех коллекциях, где
лежит»: правил доступа у коллекций и защищённых полей больше нет, а закрытость
держится тем, что адреса чтения содержимого пишет сервис и каждый из них судит
владельца.
#### Scenario: Чужой сохранённый ответ не отдаётся
- **GIVEN** запись принята одним узнанным и прошла распознавание
- **WHEN** другой узнанный просит сохранённый ответ провайдера по этой записи
- **THEN** содержимого он не получает
#### Scenario: Неузнанному содержимое не отдаётся
- **WHEN** содержимое записи запрашивают неузнанным
- **THEN** приходит отказ, а содержимого в ответе нет
#### Scenario: Перечисления приложений записи не существует
- **WHEN** ищут адрес, которым перечисляются строки текстов, реплик или попыток
распознавания
- **THEN** такого адреса у сервиса нет
## MODIFIED Requirements
### Requirement: Сервис поднимается на чистом каталоге данных
Сервис SHALL приводить хранилище в рабочий вид сам: на пустом каталоге данных он
MUST завести свою схему и принимать записи своим входом — приёмом по HTTP — без
единого ручного шага до первого запуска.
Прежние данные не переносятся. Каталог, оставшийся от прежней раскладки, MUST не
читаться и не считаться источником: сервис начинает с чистого листа, и это
решение задачи, а не следствие отказа.
Схема MUST заводиться версионированными шагами, а применённый шаг MUST не
переписываться — только новым шагом. Иначе повторный запуск на уже заведённом
каталоге разошёлся бы с первым молча. Применённые шаги MUST учитываться самой
базой, а не порядком файлов на диске.
Применение шага и запись отметки о нём MUST идти **одной транзакцией**. Процесс,
оборванный между ними, оставляет базу со шагом, который применён и не отмечен, а
следующий запуск применяет его второй раз — и второе применение отказывает на
заведённой таблице, роняя старт на шаге, который на самом деле цел.
Накат MUST держаться **исключающей блокировкой базы** на всё своё время: второй
процесс, поднятый на том же каталоге данных, MUST ждать её освобождения либо
отказать, а не применять шаги параллельно. Каталог данных один, а запусков на нём
бывает два — старый экземпляр ещё не остановлен, новый уже поднят, — и два
наката, разошедшихся на одном шаге, оставляют схему в состоянии, которого не
описывает ни один шаг.
Порядок шагов MUST быть детерминирован и выводиться из **версии самого шага**, а
не из порядка чтения каталога: порядка обхода файловая система не обещает, а
разошедшийся порядок шагов виден только на чистой базе, которую заводят один раз.
Две одинаковых версии MUST давать отказ, а не молчаливый выбор одного из шагов.
**Схема MUST накатываться до подъёма входов и до старта воркеров**, а отказ шага
MUST ронять старт. Сервис, поднявшийся на неприведённой схеме, отвечает отказом
на каждый запрос и на каждый прогон воркера — вместо одной строки о причине их
становятся сотни, и первопричина в них теряется.
Каталог данных у сервиса MUST быть один: база и файлы записей лежат под ним
вместе, и второго пути к ним не заводится.
#### Scenario: Первый запуск на пустом каталоге
- **GIVEN** каталог данных пуст
- **WHEN** сервис запускается
- **THEN** он заводит своё хранилище и продолжает работу
- **AND** принятая следом запись доходит до состояния `done`
#### Scenario: Повторный запуск на заведённом каталоге
- **GIVEN** сервис уже запускался на этом каталоге и завёл хранилище
- **WHEN** он запускается снова
- **THEN** он не заводит схему второй раз и не теряет прежних записей
#### Scenario: Схема накатана до первой строки о готовности
- **GIVEN** каталог данных пуст
- **WHEN** сервис запускается
- **THEN** до строки журнала о готовности схема приведена целиком
- **AND** ни одного отказа в журнале до неё нет
#### Scenario: Старт, оборванный между шагом и отметкой о нём
- **GIVEN** запуск оборван после применения шага схемы и до записи отметки о нём
- **WHEN** сервис запускается снова
- **THEN** исход тот же, что и у необорванного запуска, либо отказ, называющий
шаг
- **AND** шаг не применяется второй раз
#### Scenario: Отказ шага схемы роняет старт
- **GIVEN** шаг схемы не применяется
- **WHEN** сервис запускается
- **THEN** старт кончается отказом, называющим шаг
- **AND** ни один вход не поднят
### Requirement: Файл записи живёт в хранилище
Сервис SHALL держать файл записи в своём каталоге данных, и раскладку этого
каталога MUST задавать он сам. Файл MUST адресоваться записью, которой
принадлежит, а не путём на диске: шаг конвейера просит файл у записи и получает
его, ничего не зная о раскладке.
Раскладка MUST держать копии одной записи вместе — под её идентификатором, — и
MUST давать убрать запись целиком одним движением, не перебирая имена по маске.
Плоского каталога, где копии различаются приставкой в имени, MUST не
заводиться.
Содержимое записи MUST не читаться в память целиком ни при укладке, ни при
чтении: расчётный потолок записи — шесть часов, и такая запись в память не
помещается.
**Укладка MUST быть атомарной:** содержимое пишется во временное имя **в том же
подкаталоге записи** и переименовывается в рабочее только после того, как поток
дочитан до конца без отказа. Временное имя берётся в том же каталоге потому, что
переименование в его пределах не копирует содержимое и не может оборваться на
середине.
Порядок MUST быть один: строка о файле заводится **после** того, как содержимое
лежит целиком под рабочим именем. Обратный порядок оставляет в базе строку,
указывающую на файл, которого ещё нет или который короче принятого.
Средство обнаружить усечение у сервиса одно, и оно снято намеренно: величины
записи со строкой файла не сверяются — так требует «Аудиозапись — центральная
сущность хранилища», — а другого не заведено. Усечённая запись поэтому уезжает в
конвейер, оплачивает распознавание и отдаёт расшифровку половины как готовый
результат. Атомарная укладка — единственное, что этого не допускает.
Отсюда две нормы о неудачах:
- содержимое легло, а сохранение самой аудиозаписи отказало — уложенный файл MUST
быть убран, и строки о нём MUST не остаться. Файл, переживший свою запись, —
штатное состояние только у приведённой копии, которую заводит шаг конвейера; у
принятой копии это мусор, на который не ссылается ничто и о котором узнать
неоткуда;
- отмена контекста посреди укладки MUST кончаться тем же исходом, что и отказ
источника: временного имени не остаётся, рабочего имени не появляется, строки о
файле нет. Записи, наполовину принятой, человек не видит.
Сохранённый ответ провайдера распознавания MUST лежать **третьим файлом в том же
подкаталоге записи**, под именем, которое задаёт сервис. Колонкой строки попытки
он ехал бы в память при каждом опросе готовности — этого capability `recognition`
избегает намеренно; отдельной таблицей он завёл бы второй путь чтения содержимого
и остался бы в базе, которую сервис держит узкой. Третьим файлом он попадает под
ту же атомарную укладку и под ту же уборку записи одним движением, что и копии
аудио.
Копией аудио сохранённый ответ при этом MUST не считаться: копий у записи
по-прежнему две — принятая и приведённая, — и перечень копий, которые сервис
отдаёт адресом приложения, этим не расширяется. Адреса, которым сохранённый ответ
читают снаружи, у сервиса нет вовсе.
**Потолок размера записи MUST быть задан числом, выведенным из этого расчётного
потолка**, и задан он MUST быть везде, где иначе действует умолчание: и у тела
запроса приёма, и у всякого предела, который сервис ставит сам. Умолчания здесь
не «без предела», а величины на два-три порядка меньше нужного, и оставленные как
есть они отвергают штатную запись сервиса — приём отказывает, а уже принятая
запись исчерпывает попытки на шаге конвертации.
Отказ по этому потолку MUST быть виден отправителю ответом, а не молчанием.
Шаги, которым нужен файл именем на диске — конвертация и чтение метаданных
отдают его внешней программе, — MUST получать рабочую копию **одним общим
способом**, и у этого способа MUST быть единственный способ её убрать. Уборку
зовёт шаг, и звать её он MUST на любом исходе, включая отказ. Заводить копию по
месту шагам MUST не приходиться: иначе обязанность прибрать переписывается
столько раз, сколько шагов, а забытая копия — это шестичасовая запись,
оставшаяся во временном каталоге, и узнать о ней неоткуда.
#### Scenario: Принятая запись легла в каталог данных
- **WHEN** запись принята
- **THEN** её файл лежит в каталоге данных сервиса и связан со своей записью
- **AND** второго каталога записей рядом не появляется
#### Scenario: Копии одной записи лежат вместе
- **GIVEN** запись принята, приведена к рабочему формату и прошла распознавание
- **WHEN** смотрят, где лежат её файлы
- **THEN** принятая копия, приведённая копия и сохранённый ответ провайдера лежат
под идентификатором этой записи
- **AND** второго места, где лежит что-то из них, нет
#### Scenario: Источник оборвался посреди потока
- **GIVEN** отправитель шлёт запись и обрывает поток на середине
- **WHEN** укладка отказывает
- **THEN** строки о файле не заведено
- **AND** ни файла под рабочим именем, ни временного имени в подкаталоге записи
не остаётся
#### Scenario: Запись длиннее умолчания принимается
- **WHEN** сервису отдают запись длиннее всякого умолчания, действующего на пути
приёма
- **THEN** она ложится в каталог данных, а не отвергается
#### Scenario: Шаг конвейера берёт файл по записи
- **GIVEN** запись принята и её файл лежит в каталоге данных
- **WHEN** шаг конвейера берётся за эту запись
- **THEN** он получает файл по самой записи, а не по пути на диске
#### Scenario: Рабочая копия убрана после отказа шага
- **GIVEN** шагу выдана рабочая копия файла
- **WHEN** шаг завершается отказом
- **THEN** рабочей копии во временном каталоге не остаётся
### Requirement: Файл отдаётся ссылкой
Сервис SHALL отдавать файл записи **только её владельцу** и MUST судить владельца
сам, при каждом обращении. Отданный файл MUST совпадать с принятым по длине.
Значения, дающего право пройти по ссылке, сервис MUST не выдавать: ни короткого
токена файла, ни подписанной ссылки со сроком. Право даёт узнавание пришедшего и
владение записью, и судится оно там же, где отдаётся файл. Отзыв доступа доходит
до файла сразу, а не через срок жизни выданного значения.
Обращение к файлу чужой записи MUST быть неотличимо от обращения к
несуществующей — тем же кодом и тем же телом. Разница ответов превратила бы
чтение в перебор заведённых записей.
Каким адресом файл уходит и как называется вид копии, нормирует capability
`archive`: там живут адреса приложения, и держатель нормы обязан быть один.
Конвейер расшифровки этим не затронут: он читает файл из каталога данных, а не
по адресу приложения.
**Путь, по которому файл лежит на диске, MUST не попадать ни в журнал, ни в
метку метрики, ни в ответ отправителю.** Имя, под которым файл лёг в каталог, из
журнала выводимо быть не должно: журнал уезжает в собранные логи, откуда строку
не убрать.
Отсюда требование к отказам: сообщение об отказе чтения или укладки MUST не
называть ключ файла и путь к нему дословно, а отказ выгрузки во внешнее
хранилище MUST не называть полного адреса объекта. И то и другое кончается в
журнале и собирает ссылку не хуже успешного пути.
Что именно журнал приёма пишет ради прослеживаемости, нормирует capability
`intake`.
#### Scenario: Владелец забирает свой файл
- **GIVEN** запись принята и её файл лежит в каталоге данных
- **WHEN** владелец записи просит её файл
- **THEN** приходит тот же файл, и его длина совпадает с длиной принятого
#### Scenario: Неузнанному файл не отдаётся
- **GIVEN** запись принята и её файл лежит в каталоге данных
- **WHEN** файл просят неузнанным
- **THEN** приходит отказ, а содержимого записи в ответе нет
#### Scenario: Значения на предъявителя сервис не выдаёт
- **GIVEN** человек узнан
- **WHEN** ищут адрес, которым сервис выдаёт значение, открывающее файл
- **THEN** такого адреса у сервиса нет
#### Scenario: Конвейер читает файл без узнавания
- **GIVEN** запись принята и ждёт расшифровки
- **WHEN** шаг конвейера берётся за неё
- **THEN** файл читается из каталога данных и шаг проходит
#### Scenario: Файл записи, которой нет
- **WHEN** просят файл записи с неизвестным идентификатором
- **THEN** приходит отказ, а не пустой ответ
#### Scenario: По журналу путь к файлу не собрать
- **GIVEN** запись принята и прошла конвейер
- **WHEN** читают журнал сервиса целиком
- **THEN** имени, под которым файл лёг в каталог данных, в нём нет
#### Scenario: Отказ чтения файла не называет его ключ
- **GIVEN** файл записи не читается с диска
- **WHEN** шаг конвейера берётся за эту запись и отказывает
- **THEN** отказ называет запись её идентификатором и не несёт имени файла
### Requirement: Владелец задачи лежит связью с учётной записью
Хранилище SHALL держать владельца аудиозаписи отдельной колонкой — связью с
учётной записью, — и эта колонка MUST не иметь умолчания: запись, чей владелец
не назван, не достаётся никому по недосмотру схемы.
Колонка MUST не допускать пустого значения, и MUST это держать сама схема: связь
объявлена внешним ключом на учётную запись и обязательна. Пока обязательность
жила в одном приёме, ничью запись заводили руками мимо него, она уходила в
конвейер, стоила денег на распознавание и не доставалась потом никому.
Правку записи мимо адресов приложения сервис ничем не предоставляет: панели у
него нет. Обязательность от этого не отменяется — она перестала зависеть от того,
кто пишет, и стала свойством схемы.
Владелец MUST не назначаться и не меняться конвейером.
#### Scenario: Колонка появляется на пустой базе
- **WHEN** сервис поднимается на чистом каталоге данных
- **THEN** у аудиозаписи есть колонка владельца
- **AND** умолчания у неё нет
- **AND** пустого значения она не принимает
#### Scenario: Запись без владельца не сохраняется
- **GIVEN** сервис поднят
- **WHEN** аудиозапись пытаются сохранить с пустым владельцем — приёмом,
конвейером или запросом к базе
- **THEN** база её не сохраняет
#### Scenario: Владелец, которого нет, не принимается
- **GIVEN** сервис поднят
- **WHEN** аудиозапись пытаются сохранить с владельцем, которому не отвечает ни
одна учётная запись
- **THEN** база её не сохраняет
#### Scenario: Конвейер владельца не назначает
- **GIVEN** запись с владельцем прошла шаг конвейера
- **WHEN** смотрят её владельца
- **THEN** он прежний
### Requirement: Файл записи сужается владельцем наравне с задачей
Хранилище SHALL держать владельца и у файла записи — той же связью с учётной
записью, — а отдача файла MUST пускать к нему только его владельца.
Владелец файла MUST назначаться при приёме, из узнанного предъявителя, а колонка
файла MUST не допускать пустого значения наравне с колонкой записи. Разное
правило у записи и у её файла читалось бы как недосмотр.
Файл, заведённый шагом конвейера, — приведённую копию заводит именно он —
MUST получать владельца своей записи. Иного источника владельца у файла нет, и
шаг, оставивший его пустым, упрётся в отказ сохранения: запись накопит отказы и
остановится признаком на первом же приведении.
Ссылки на файлы у записи две — на принятую копию и на приведённую, — и обе живут
до конца, но владелец файла MUST по-прежнему лежать своей колонкой, а не
выводиться через запись: файл переживает свою запись, и заведённый шагом до
сохранения записи он остаётся с владельцем и без ссылки.
Отказ наступает **на самом обращении за файлом**: другого места, где он мог бы
наступить, у сервиса не осталось — значений на предъявителя он не выдаёт.
Проверка, судящая владельца где-то ещё, зеленела бы, не касаясь пути, по
которому аудио и уходит.
#### Scenario: Чужой файл не отдаётся
- **GIVEN** запись принята одним узнанным
- **WHEN** другой узнанный просит файл этой записи
- **THEN** содержимого он не получает
- **AND** ответ тот же, что и на неизвестный идентификатор записи
#### Scenario: Свой файл отдаётся
- **GIVEN** человек принял запись
- **WHEN** он просит файл своей записи
- **THEN** содержимое отдаётся
#### Scenario: Файл без владельца не сохраняется
- **GIVEN** сервис поднят
- **WHEN** файл записи пытаются сохранить с пустым владельцем
- **THEN** база его не сохраняет
#### Scenario: Приведённая копия получает владельца записи
- **GIVEN** запись с владельцем дошла до приведения
- **WHEN** шаг заводит приведённую копию файла
- **THEN** владельцем копии стоит владелец записи
- **AND** шаг завершается без отказа
### Requirement: Учётная запись с записями не удаляется
Хранилище SHALL отвергать удаление учётной записи, у которой остались
аудиозаписи, файлы **либо темы словаря**, и MUST держать этот запрет самой
схемой — обязательной связью, которая не даёт убрать строку, пока на неё
ссылаются.
Считаются **все** таблицы с колонкой владельца, и перечень их MUST жить одним
местом — шагом схемы, который эти связи объявляет. Таблица, пропущенная в счёте,
пропускает удаление вперёд и оставляет за собой строки, чей владелец больше не
существует.
Запрет схемой, а не проверкой вызывающего, — потому что вызывающих у удаления
может стать больше одного, а проверка, записанная у одного, у остальных читалась
бы как снятая. Сборка, забывшая позвать проверку, теряет защиту молча — и теряла.
Файл переживает свою запись: шаг конвейера заводит его до сохранения записи, и
потерянный захват оставляет файл с владельцем и без ссылки. Тема переживает её
так же: словарь принадлежит человеку, а не записи.
Цена требования названа прямо: способа удалить записи в сервисе пока нет вовсе —
его приносит задача про удаление записи. До неё удаление учётной записи с
записями невозможно, и это осознанный тупик, а не недосмотр. Адреса, которым
учётную запись удаляют, у сервиса при этом нет: запрет закрывает удаление
запросом к базе.
#### Scenario: Удаление учётной записи с записями отвергается
- **GIVEN** у учётной записи есть аудиозаписи
- **WHEN** её строку удаляют
- **THEN** удаление не проходит
- **AND** записи и их владелец остаются прежними
#### Scenario: Учётная запись с одними файлами тоже не удаляется
- **GIVEN** у учётной записи остались файлы, но записей нет
- **WHEN** её строку удаляют
- **THEN** удаление не проходит, а владелец файлов остаётся прежним
#### Scenario: Учётная запись с одними темами тоже не удаляется
- **GIVEN** у учётной записи остались темы словаря, но ни записей, ни файлов нет
- **WHEN** её строку удаляют
- **THEN** удаление не проходит
#### Scenario: Учётная запись без записей удаляется
- **GIVEN** у учётной записи нет ни аудиозаписей, ни файлов, ни тем
- **WHEN** её строку удаляют
- **THEN** удаление проходит
### Requirement: Аудиозапись — центральная сущность хранилища
Хранилище SHALL держать аудиозапись отдельной таблицей, а всё, что к ней
приложено, — отдельными строками со ссылками на запись. Приложениями считаются
файлы, тексты, структура реплик, темы, журнал событий и попытка распознавания.
Поля, которыми распоряжается очередь — признак захвата, срок его протухания,
пауза, число отказов, время входа в рубеж, — MUST не соседствовать с содержимым
записи в одной строке настолько, чтобы чтение очереди тянуло содержимое.
Запись MUST нести заголовок и краткое описание своими колонками: они читаются
вместе со списком, сотней штук разом. Расшифровка и вычитанный текст MUST лежать
отдельными строками: они читаются по открытию одной записи.
Тем же доводом запись MUST нести своими колонками **имя файла, данное
отправителем, длительность и размер**. Все три показываются в списке. Приём
узнаёт длительность и размер у источника метаданных и так, а имя файла приходит
вместе с записью.
**Имена колонок и единицы измерения нормативны:** `original_filename`,
`duration_ms` (миллисекунды) и `size_bytes` (байты). Единица стоит в самом имени,
а не в комментарии: шаг схемы применённым не переписывается, а расхождение
«секунды против миллисекунд» между колонкой, ответом списка и объявленным
пределом не увидит ни компилятор, ни гейт — оба конца числа. Миллисекунды выбраны
потому, что этой единицей уже названы соседние колонки схемы.
**Различать «неизвестно» и «ноль» эти колонки не обязаны, и это решение, а не
недосмотр.** Обе величины ставит приём, и ставит всегда — запись, метаданные
которой прочитать не удалось, отвергается отказом и не заводится вовсе. Ноль в
этих колонках означает ноль, и колонки MUST быть объявлены обязательными: пустое
значение, которое схема теперь допустить может, завело бы третий смысл, которого
никто не читает. Решение владельца 2026-08-15, и смена хранилища его не отменяет.
Имя файла на записи и заголовок MUST лежать **разными колонками**. Заголовок
несёт название, которое дал человек либо посчитала языковая модель; имя файла —
то, по чему человек узнаёт свою запись, пока заголовка нет. Одной колонкой на оба
смысла посчитанное название затирало бы имя, и вернуть затёртое было бы неоткуда.
Величины на записи и на её файле расходятся по смыслу, и **равенство между ними
не поддерживается никем — намеренно**. На записи лежит снимок **принятого**,
взятый приёмом один раз и больше не пересчитываемый; на файле — величины той
копии, которой файл является сейчас. Приведённая копия имеет свой размер, и
записи он не принадлежит.
Отсюда норма, без которой два числа читались бы как копии одного: величины
записи MUST не сверяться со строкой файла и MUST не переписываться ничем после
приёма. Расхождение между ними — не поломка, а разные вопросы: «что человек
прислал» и «что лежит сейчас».
#### Scenario: Список читается без содержимого
- **GIVEN** у записи есть расшифровка
- **WHEN** читают запись ради её рубежа и заголовка
- **THEN** текст расшифровки при этом не читается
#### Scenario: Длительность и размер читаются без строки файла
- **GIVEN** запись принята
- **WHEN** читают её длительность и размер
- **THEN** строка файла при этом не читается
#### Scenario: Пустая длительность в схему не ложится
- **GIVEN** сервис поднят
- **WHEN** аудиозапись пытаются сохранить с пустой длительностью или пустым
размером
- **THEN** база её не сохраняет
#### Scenario: Посчитанный заголовок не затирает имя файла
- **GIVEN** запись принята с именем файла отправителя
- **WHEN** записи проставляют заголовок
- **THEN** имя файла остаётся прежним
### Requirement: Словарь тем ведётся по владельцу
Хранилище SHALL держать темы отдельной таблицей, и тема MUST быть уникальна в
паре «владелец и название»: словарь тем свой у каждого человека. У записи MUST
быть не больше пяти тем.
Отдельной таблицей, а не набором строк в записи, — потому что перечень тем
человека нужен целиком перед каждым обращением к модели, а собрать его из
наборов строк можно только перебором всех его записей.
Потолок в пять тем MUST держаться самой схемой: без него часовой разговор даёт
два десятка тем, и словарь распухает за неделю. То же число сервис объявляет
приложению — норму держит capability `archive`, — и второй константы рядом MUST
не заводиться.
Название темы выведено из содержимого записи, а перечень тем человека — слепок
того, о чём он вообще говорит. В журнал сервиса темы MUST не попадать наравне с
текстом расшифровки.
Ни один шаг этого изменения тем не пишет и не читает: место заведено вперёд,
чтобы задача, считающая темы языковой моделью, не платила вторым необратимым
шагом схемы. Цена решения названа прямо — имена таблицы и её колонок закрепляются
раньше, чем известен их потребитель.
#### Scenario: Тема одного человека не мешает теме другого
- **GIVEN** у двух владельцев заведена тема с одинаковым названием
- **WHEN** смотрят словарь тем
- **THEN** это две разные темы, каждая своего владельца
#### Scenario: Шестая тема не заводится
- **WHEN** записи назначают шестую тему
- **THEN** назначение не проходит
## REMOVED Requirements
### Requirement: Наружу хранилище отдаёт только то, что заказано
**Reason**: Требование закрывало собственную поверхность встроенного хранилища —
перечисление записей коллекций, журналы запросов, резервные копии, настройки,
расписание, — которую тот публиковал тем же портом. Хранилище уходит из проекта
целиком, и поверхности этой не существует: закрывать больше нечего.
**Migration**: Отвечает сервис теперь только своими адресами, а всё, что не
принадлежит корню приложения и не является отдельным адресом наблюдения, идёт
общим правилом неизвестного пути — норму держит capability `webapp`. Что
содержимое записи закрыто везде, где лежит, нормирует требование «Содержимое
записи закрыто везде, где лежит».
### Requirement: Владелец видит записи в панели
**Reason**: Панель администратора уходит вместе с хранилищем и **не заменяется
ничем** — решение владельца 2026-08-22. Требовать поведения от инструмента,
которого нет, нельзя.
**Migration**: Возврат остановленной записи в работу делает подкоманда набора
инструментов разработчика — норму держит capability `pipeline`, требование
«Остановка записи — признак, а не рубеж»: там названы и поля, которые возврат
обязан сбросить, и обязанность писать событие журнала записи с происхождением
«человек». Прочая правка записи ждёт экранов владельца, а их приносят задачи
`audiorecord-actions` и `play-recording-in-app`.
### Requirement: Пароль владельца от панели не лежит в конфигурации
**Reason**: Пароля от панели больше нет — вместе с панелью. Секрет, появившийся
только ради перевода на встроенное хранилище, пропадает, и ключа под него в
конфигурации не заводится по той простой причине, что заводить нечего.
**Migration**: Инвариант проекта «Секрет не покидает конфиг» остаётся в силе для
оставшихся секретов — ключа распознавания и пары ключей внешнего хранилища.
Приглашения завести владельца сервис больше не печатает.
### Requirement: Содержимое записи закрыто во всех коллекциях, где лежит
**Reason**: Требование написано словарём коллекций, правил доступа и защищённых
полей — механизмов встроенного хранилища. Механизмов этих не остаётся, а норма
остаётся: она переписана требованием «Содержимое записи закрыто везде, где
лежит».
**Migration**: Читать норму по новому требованию. Закрытость держится тем, что
адреса чтения содержимого пишет сервис и каждый судит владельца связанной
записи, а не правилом просмотра коллекции и не пометкой поля.