# storage Specification ## Purpose Где живут аудиозапись, её приложения и её файлы: раскладка каталога данных, версионированный накат схемы при подъёме, правила чтения и записи базы, отдача файла его владельцу с проверкой при каждом обращении. Приём записи нормирует `intake`, узнавание пришедшего — `access`, адреса, по которым приложение спрашивает запись и её файл, — `archive`, попытку распознавания у внешнего провайдера — `recognition`. Сознательно не описаны: перенос прежних данных — его нет ни по решению задачи `pocketbase-storage`, ни по решению владельца 2026-08-14, которым прежние записи удалены вместе с остановкой сервиса; удаление записей и файлов — сервис объявлен архивом 2026-08-11, а удаление приносит задача `delete-record`. ## 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 задаваться **строкой подключения обоих пулов** — и пишущего, и читающего, — а не отдельным запросом после открытия. Соблюдение внешних ключей в 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 держать копии одной записи вместе — под её идентификатором, — и 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 не попадать ни в журнал, ни в метку метрики, ни в ответ отправителю — теми же словами, какими это нормировано для файла записи. Требование заменяет прежнее «Содержимое записи закрыто во всех коллекциях, где лежит»: правил доступа у коллекций и защищённых полей больше нет, а закрытость держится тем, что адреса чтения содержимого пишет сервис и каждый из них судит владельца. #### 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 держаться самой схемой: без него часовой разговор даёт два десятка тем, и словарь распухает за неделю. То же число сервис объявляет приложению — норму держит capability `archive`, — и второй константы рядом MUST не заводиться. Название темы выведено из содержимого записи, а перечень тем человека — слепок того, о чём он вообще говорит. В журнал сервиса темы MUST не попадать наравне с текстом расшифровки. Ни один шаг этого изменения тем не пишет и не читает: место заведено вперёд, чтобы задача, считающая темы языковой моделью, не платила вторым необратимым шагом схемы. Цена решения названа прямо — имена таблицы и её колонок закрепляются раньше, чем известен их потребитель. #### Scenario: Тема одного человека не мешает теме другого - **GIVEN** у двух владельцев заведена тема с одинаковым названием - **WHEN** смотрят словарь тем - **THEN** это две разные темы, каждая своего владельца #### Scenario: Шестая тема не заводится - **WHEN** записи назначают шестую тему - **THEN** назначение не проходит ### Requirement: Пустой результат не кладётся поверх сохранённого Хранилище SHALL не заменять сохранённое содержимое приложения записи — текст и структуру реплик — пустым. Замена пустым MUST оставлять прежнее значение и считаться сделанной работой, а не отказом. Требование стоит на повторном опросе одной и той же операции распознавания. Повтор — обычное дело: держатель захвата умер, сохранение рубежа отказало, человек снял признак остановки подкомандой оснастки. Провайдер при этом вправе ответить пустым потоком, отказом это не считается, и безусловная замена стирала бы расшифровку живого человека — без следа и без возврата, потому что сервис объявлен архивом и удаления по требованию не знает. Та же защита MUST стоять у сырого ответа провайдера: разное правило у двух хранителей одного результата читается как недосмотр, и один из них молча теряет то, ради чего второй заведён. Норма записана со стороны **хранилища**, а не шага: шагов, кладущих текст, больше одного, и правило, записанное у одного из них, у остальных читалось бы как снятое. #### Scenario: Пустой второй ответ не стирает расшифровку - **GIVEN** расшифровка записи сохранена - **WHEN** ту же операцию опрашивают снова, и провайдер отвечает пустым - **THEN** сохранённая расшифровка остаётся прежней - **AND** шаг завершается без отказа