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

394 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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** удаление проходит