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