Files
transcriber/openspec/specs/storage/spec.md
T
av 8af8ec2e54 у записи появился владелец: чужую больше не отдают
- колонка `owner` связью с `users` в обеих коллекциях новым шагом схемы
  `202608140001`; чтение задачи сужено владельцем, и чужая, ничья и
  несуществующая дают один ответ; правило просмотра файлов сужено им же
- приём по HTTP берёт владельца из сессии, а предъявителя без учётной записи
  пользователя отвергает до чтения тела: позже пришлось бы убирать уложенный
  файл, а уборки файлов сервис не умеет. Выборка воркера владельцем не сужается
- удаление учётной записи с записями отвергается стражем, и вешает его сама
  сборка хранилища: сборка, забывшая его позвать, теряла защиту молча
2026-08-14 12:18:11 +03:00

29 KiB
Raw Blame History

storage Specification

Purpose

Где живут запись, её метаданные и её файл: раскладка каталога данных, приведение схемы при подъёме, отдача файла ссылкой по токену, собственная поверхность хранилища и панель владельца.

Приём и опрос готовности нормирует intake, вход и сессию — access. Сознательно не описаны: перенос прежних данных — его нет по решению задачи pocketbase-storage; удаление записей и файлов — сервис объявлен архивом 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 быть обязательными в самой схеме, а перечень состояний — закрытым.

Панель разграничению доступа сервиса не подчиняется: вошедший в неё видит все записи, все файлы и всех пользователей разом. Закрывает её контур выкладки, а не сервис — это записано моделью угроз проекта.

Scenario: Принятая запись видна владельцу

  • GIVEN запись принята и её задача заведена
  • WHEN владелец отбирает задачи по идентификатору принятой
  • THEN он видит её строкой со своим состоянием
  • AND файл этой записи скачивается из той же строки

Scenario: Мёртвую задачу вернули в работу правкой в панели

  • GIVEN задача в состоянии «мертва» с исчерпанными попытками и признаком прежнего захвата
  • WHEN владелец меняет её состояние на рабочее
  • THEN признак захвата, время захвата, пауза и число попыток очищены
  • 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, а не схема.

Колонка приезжает новым шагом схемы: применённый шаг не переписывается. Записей, заведённых до этого шага, сервис не переносит — проект заводится с чистого листа.

Scenario: Колонка появляется на пустой базе

  • WHEN сервис поднимается на чистом каталоге данных
  • THEN у таблицы задач есть колонка владельца
  • AND умолчания у неё нет

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 ставить сама сборка хранилища, а не вызывающий: сборка, забывшая его позвать, теряет защиту молча — и теряла, пока запрет вешался отдельной строкой запуска, а окружение проверок его не ставило вовсе.

Удаление при этом не только панельное: умолчание библиотеки разрешает вошедшему удалить свою учётную запись запросом, так что запрет закрывает и публичную поверхность.

Требование заведено вместо прежнего «удаление не уносит задачи следом»: оно выглядело выполненным, а на деле хранилище при выключенном каскаде снимает ссылку — задачи остаются, но становятся ничьими, а ничья задача не достаётся по API никому. Архив человека исчезал бы молча и восстановлению не подлежал: прежнего владельца не остаётся нигде.

Цена требования названа прямо: владелец панели упирается в отказ, а способа удалить записи в сервисе пока нет вовсе — его приносит задача про удаление записи. До неё удаление учётной записи с записями невозможно, и это осознанный тупик, а не недосмотр.

Scenario: Удаление учётной записи с записями отвергается

  • GIVEN у учётной записи есть задачи расшифровки
  • WHEN её удаляют
  • THEN удаление не проходит, а отказ называет причину
  • AND задачи и их владелец остаются прежними

Scenario: Учётная запись с одними файлами тоже не удаляется

  • GIVEN у учётной записи остались файлы, но задач нет
  • WHEN её удаляют
  • THEN удаление не проходит, а владелец файлов остаётся прежним

Scenario: Учётная запись без записей удаляется

  • GIVEN у учётной записи нет ни задач, ни файлов
  • WHEN её удаляют
  • THEN удаление проходит