Files
transcriber/openspec/changes/archive/2026-08-23-storage-without-pocketbase/specs/storage/spec.md
T
av c9b7765646 хранилище переехало с PocketBase на SQLite со своим каталогом файлов
- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose
  под файловым замком, одна миграция начальной схемы вместо семи прежних
- транспорт переписан на net/http: свои слои, свой ограничитель частоты,
  отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли
- по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета
  читается справа налево, узнавание известного идёт читающим пулом
2026-08-23 08:06:04 +03:00

57 KiB
Raw Blame History

ADDED Requirements

Requirement: База принимает одного писателя

Сервис SHALL держать у базы одно соединение для записи, а чтение MUST вести отдельно от него. Журнал упреждающей записи MUST быть включён, принудительное соблюдение внешних ключей MUST быть включено, а ожидание занятой базы MUST задаваться числом, а не оставаться умолчанием драйвера.

Все три настройки MUST задаваться строкой подключения обоих пулов — и пишущего, и читающего, — а не отдельным запросом после открытия. Соблюдение внешних ключей в SQLite — настройка соединения, а не базы, и по умолчанию она выключена: PRAGMA foreign_keys на свежем соединении отвечает 0. Пул раздаёт соединения и заводит новые по мере надобности, поэтому запрос, выполненный один раз после открытия, настраивает одно соединение из многих, а остальные остаются с умолчанием — молча. На включённых внешних ключах держатся требования «Учётная запись с записями не удаляется», «Владелец, которого нет, не принимается» и «Файл без владельца не сохраняется»: с выключенными все три зеленеют на том соединении, где настройку успели поставить, и не работают на соседнем.

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

Всякая операция, которая читает и следом пишет, MUST идти целиком на пишущем соединении — и чтение, и запись, и объемлющая их транзакция. Транзакция, начатая на читающем соединении и позже пытающаяся писать, получает отказ по занятости немедленно: повысить начатую читающую транзакцию до пишущей SQLite не даёт, и заданное числом ожидание такой отказ не лечит — ждать там нечего. Под правило подпадают захват записи и накат шага схемы: каждый читает состояние, которое сам же меняет.

Узнавание под правило не подпадает, и устроено оно двумя соединениями. Поиск учётной записи по логину у провайдера MUST идти читающим пулом, а пишущая транзакция MUST открываться только тогда, когда запись не нашлась. Слой узнавания одет на весь узнанный поток — опрос карточки и каждый запрос диапазона при проигрывании, — а заводится учётная запись один раз за жизнь человека: пишущая транзакция, взятая до поиска, ставила бы весь этот поток в очередь к единственному пишущему соединению. Очередь эта ожиданием занятой базы не ограничена и отказом не кончается — обращение просто ждёт, и сотни миллисекунд ожидания видны только замером.

Окно между двумя соединениями MUST закрываться повторным поиском внутри транзакции: пока её ждали, запись успевает завести сосед, и найденную надо взять, а не заводить вторую. Уникальность ключа при этом держит схема, а не порядок обращений.

Значения ожидания и числа соединений MUST жить там, где проект держит числовые настройки, и MUST не повторяться второй константой рядом.

Scenario: Несколько воркеров пишут разом

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

Scenario: Настройки базы применены при подъёме

  • WHEN сервис поднялся на чистом каталоге данных
  • THEN у базы включён журнал упреждающей записи
  • AND ожидание занятой базы равно объявленному числу

Scenario: Внешние ключи включены на соединении читающего пула

  • GIVEN сервис поднялся на чистом каталоге данных
  • WHEN соединение берут из читающего пула и спрашивают у него PRAGMA foreign_keys
  • THEN ответ — 1

Scenario: Узнавание известного не ждёт писателя

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

Scenario: Составная операция не отказывает по занятости

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

Requirement: Время и идентификаторы приходят из одного места

Хранилище SHALL держать все колонки времени одним представлением: TEXT в RFC 3339, UTC, с суффиксом Z и секундной точностью — 2006-01-02T15:04:05Z. Второго вида времени в схеме MUST не заводиться, включая колонки, которые пишет только сам сервис.

Ширина такой записи постоянная, поэтому лексикографический порядок TEXT совпадает с хронологией, и отбор по колонке времени работает без разбора значения. Своего типа времени у SQLite нет: колонка хранит то, что в неё положили, а отбор захвата сравнивает строки — колонка, заполненная то одним видом, то другим, обращает условие срока протухания в постоянную истину или ложь молча, и запись не выдаётся ни одному воркеру никогда.

Время ставит приложение, а не умолчание шага схемы, и берёт оно его из единой точки чтения времени, которую держит линтер проекта. Умолчаний вида CURRENT_TIMESTAMP в схеме MUST не заводиться. Выбрано так по двум причинам: умолчание схемы пишет свой вид времени, отличный от объявленного выше, и вставка, забывшая проставить время, при умолчании проходит молча, а без него падает громко. Прежнее расхождение — вид времени задавало встроенное хранилище своим форматом с пробелом и долями секунды — уходит вместе с ним, и правило остаётся одно.

Идентификатор строки SHALL быть ULID в нижнем регистре, колонкой TEXT, и ставить его MUST приложение единой точкой при заведении строки. Это то, что конвенция проекта объявляет нормой; расхождение, при котором идентификаторы выдавало встроенное хранилище собственным алфавитом, уходит вместе с ним. Идентификатор, пришедший снаружи, MUST разбираться на границе — разбор проверяет вид и приводит регистр, — а каким кодом отвечает негодный, нормирует capability archive.

Scenario: Вид времени один на все колонки

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

Scenario: Строка из приёма и строка из запроса к базе отбираются одинаково

  • GIVEN одна аудиозапись заведена приёмом, а вторая — запросом к базе руками
  • AND обе стоят на одном рубеже и пригодны к захвату
  • WHEN воркеры разбирают очередь
  • THEN захвату выдаются обе
  • AND ни одна не остаётся в очереди навсегда

Requirement: Содержимое записи закрыто везде, где лежит

Всякая таблица, куда переезжает содержимое аудиозаписи, SHALL быть закрыта наравне с самой записью: сервис MUST не заводить ни одного адреса, которым её строки перечисляются или читаются мимо проверки владельца связанной записи.

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

Сохранённый ответ провайдера — это полный текст речи, и он MUST быть закрыт наравне с расшифровкой, а не считаться служебным вложением. Где именно он лежит, нормирует требование «Файл записи живёт в хранилище»: третьим файлом в подкаталоге записи.

Ссылка или путь, по которому содержимое лежит на диске, MUST не попадать ни в журнал, ни в метку метрики, ни в ответ отправителю — теми же словами, какими это нормировано для файла записи.

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

Scenario: Чужой сохранённый ответ не отдаётся

  • GIVEN запись принята одним узнанным и прошла распознавание
  • WHEN другой узнанный просит сохранённый ответ провайдера по этой записи
  • THEN содержимого он не получает

Scenario: Неузнанному содержимое не отдаётся

  • WHEN содержимое записи запрашивают неузнанным
  • THEN приходит отказ, а содержимого в ответе нет

Scenario: Перечисления приложений записи не существует

  • WHEN ищут адрес, которым перечисляются строки текстов, реплик или попыток распознавания
  • THEN такого адреса у сервиса нет

MODIFIED Requirements

Requirement: Сервис поднимается на чистом каталоге данных

Сервис SHALL приводить хранилище в рабочий вид сам: на пустом каталоге данных он MUST завести свою схему и принимать записи своим входом — приёмом по HTTP — без единого ручного шага до первого запуска.

Прежние данные не переносятся. Каталог, оставшийся от прежней раскладки, MUST не читаться и не считаться источником: сервис начинает с чистого листа, и это решение задачи, а не следствие отказа.

Схема MUST заводиться версионированными шагами, а применённый шаг MUST не переписываться — только новым шагом. Иначе повторный запуск на уже заведённом каталоге разошёлся бы с первым молча. Применённые шаги MUST учитываться самой базой, а не порядком файлов на диске.

Применение шага и запись отметки о нём MUST идти одной транзакцией. Процесс, оборванный между ними, оставляет базу со шагом, который применён и не отмечен, а следующий запуск применяет его второй раз — и второе применение отказывает на заведённой таблице, роняя старт на шаге, который на самом деле цел.

Накат MUST держаться исключающей блокировкой базы на всё своё время: второй процесс, поднятый на том же каталоге данных, MUST ждать её освобождения либо отказать, а не применять шаги параллельно. Каталог данных один, а запусков на нём бывает два — старый экземпляр ещё не остановлен, новый уже поднят, — и два наката, разошедшихся на одном шаге, оставляют схему в состоянии, которого не описывает ни один шаг.

Порядок шагов MUST быть детерминирован и выводиться из версии самого шага, а не из порядка чтения каталога: порядка обхода файловая система не обещает, а разошедшийся порядок шагов виден только на чистой базе, которую заводят один раз. Две одинаковых версии MUST давать отказ, а не молчаливый выбор одного из шагов.

Схема MUST накатываться до подъёма входов и до старта воркеров, а отказ шага MUST ронять старт. Сервис, поднявшийся на неприведённой схеме, отвечает отказом на каждый запрос и на каждый прогон воркера — вместо одной строки о причине их становятся сотни, и первопричина в них теряется.

Каталог данных у сервиса MUST быть один: база и файлы записей лежат под ним вместе, и второго пути к ним не заводится.

Scenario: Первый запуск на пустом каталоге

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

Scenario: Повторный запуск на заведённом каталоге

  • GIVEN сервис уже запускался на этом каталоге и завёл хранилище
  • WHEN он запускается снова
  • THEN он не заводит схему второй раз и не теряет прежних записей

Scenario: Схема накатана до первой строки о готовности

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

Scenario: Старт, оборванный между шагом и отметкой о нём

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

Scenario: Отказ шага схемы роняет старт

  • GIVEN шаг схемы не применяется
  • WHEN сервис запускается
  • THEN старт кончается отказом, называющим шаг
  • AND ни один вход не поднят

Requirement: Файл записи живёт в хранилище

Сервис SHALL держать файл записи в своём каталоге данных, и раскладку этого каталога MUST задавать он сам. Файл MUST адресоваться записью, которой принадлежит, а не путём на диске: шаг конвейера просит файл у записи и получает его, ничего не зная о раскладке.

Раскладка MUST держать копии одной записи вместе — под её идентификатором, — и MUST давать убрать запись целиком одним движением, не перебирая имена по маске. Плоского каталога, где копии различаются приставкой в имени, MUST не заводиться.

Содержимое записи MUST не читаться в память целиком ни при укладке, ни при чтении: расчётный потолок записи — шесть часов, и такая запись в память не помещается.

Укладка MUST быть атомарной: содержимое пишется во временное имя в том же подкаталоге записи и переименовывается в рабочее только после того, как поток дочитан до конца без отказа. Временное имя берётся в том же каталоге потому, что переименование в его пределах не копирует содержимое и не может оборваться на середине.

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

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

Отсюда две нормы о неудачах:

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

Сохранённый ответ провайдера распознавания MUST лежать третьим файлом в том же подкаталоге записи, под именем, которое задаёт сервис. Колонкой строки попытки он ехал бы в память при каждом опросе готовности — этого capability recognition избегает намеренно; отдельной таблицей он завёл бы второй путь чтения содержимого и остался бы в базе, которую сервис держит узкой. Третьим файлом он попадает под ту же атомарную укладку и под ту же уборку записи одним движением, что и копии аудио.

Копией аудио сохранённый ответ при этом MUST не считаться: копий у записи по-прежнему две — принятая и приведённая, — и перечень копий, которые сервис отдаёт адресом приложения, этим не расширяется. Адреса, которым сохранённый ответ читают снаружи, у сервиса нет вовсе.

Потолок размера записи MUST быть задан числом, выведенным из этого расчётного потолка, и задан он MUST быть везде, где иначе действует умолчание: и у тела запроса приёма, и у всякого предела, который сервис ставит сам. Умолчания здесь не «без предела», а величины на два-три порядка меньше нужного, и оставленные как есть они отвергают штатную запись сервиса — приём отказывает, а уже принятая запись исчерпывает попытки на шаге конвертации.

Отказ по этому потолку MUST быть виден отправителю ответом, а не молчанием.

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

Scenario: Принятая запись легла в каталог данных

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

Scenario: Копии одной записи лежат вместе

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

Scenario: Источник оборвался посреди потока

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

Scenario: Запись длиннее умолчания принимается

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

Scenario: Шаг конвейера берёт файл по записи

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

Scenario: Рабочая копия убрана после отказа шага

  • GIVEN шагу выдана рабочая копия файла
  • WHEN шаг завершается отказом
  • THEN рабочей копии во временном каталоге не остаётся

Requirement: Файл отдаётся ссылкой

Сервис SHALL отдавать файл записи только её владельцу и MUST судить владельца сам, при каждом обращении. Отданный файл MUST совпадать с принятым по длине.

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

Обращение к файлу чужой записи MUST быть неотличимо от обращения к несуществующей — тем же кодом и тем же телом. Разница ответов превратила бы чтение в перебор заведённых записей.

Каким адресом файл уходит и как называется вид копии, нормирует capability archive: там живут адреса приложения, и держатель нормы обязан быть один.

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

Путь, по которому файл лежит на диске, MUST не попадать ни в журнал, ни в метку метрики, ни в ответ отправителю. Имя, под которым файл лёг в каталог, из журнала выводимо быть не должно: журнал уезжает в собранные логи, откуда строку не убрать.

Отсюда требование к отказам: сообщение об отказе чтения или укладки MUST не называть ключ файла и путь к нему дословно, а отказ выгрузки во внешнее хранилище MUST не называть полного адреса объекта. И то и другое кончается в журнале и собирает ссылку не хуже успешного пути.

Что именно журнал приёма пишет ради прослеживаемости, нормирует capability intake.

Scenario: Владелец забирает свой файл

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

Scenario: Неузнанному файл не отдаётся

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

Scenario: Значения на предъявителя сервис не выдаёт

  • GIVEN человек узнан
  • WHEN ищут адрес, которым сервис выдаёт значение, открывающее файл
  • THEN такого адреса у сервиса нет

Scenario: Конвейер читает файл без узнавания

  • GIVEN запись принята и ждёт расшифровки
  • WHEN шаг конвейера берётся за неё
  • THEN файл читается из каталога данных и шаг проходит

Scenario: Файл записи, которой нет

  • WHEN просят файл записи с неизвестным идентификатором
  • THEN приходит отказ, а не пустой ответ

Scenario: По журналу путь к файлу не собрать

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

Scenario: Отказ чтения файла не называет его ключ

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

Requirement: Владелец задачи лежит связью с учётной записью

Хранилище SHALL держать владельца аудиозаписи отдельной колонкой — связью с учётной записью, — и эта колонка MUST не иметь умолчания: запись, чей владелец не назван, не достаётся никому по недосмотру схемы.

Колонка MUST не допускать пустого значения, и MUST это держать сама схема: связь объявлена внешним ключом на учётную запись и обязательна. Пока обязательность жила в одном приёме, ничью запись заводили руками мимо него, она уходила в конвейер, стоила денег на распознавание и не доставалась потом никому.

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

Владелец MUST не назначаться и не меняться конвейером.

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

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

Scenario: Запись без владельца не сохраняется

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

Scenario: Владелец, которого нет, не принимается

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

Scenario: Конвейер владельца не назначает

  • GIVEN запись с владельцем прошла шаг конвейера
  • WHEN смотрят её владельца
  • THEN он прежний

Requirement: Файл записи сужается владельцем наравне с задачей

Хранилище SHALL держать владельца и у файла записи — той же связью с учётной записью, — а отдача файла MUST пускать к нему только его владельца.

Владелец файла MUST назначаться при приёме, из узнанного предъявителя, а колонка файла MUST не допускать пустого значения наравне с колонкой записи. Разное правило у записи и у её файла читалось бы как недосмотр.

Файл, заведённый шагом конвейера, — приведённую копию заводит именно он — MUST получать владельца своей записи. Иного источника владельца у файла нет, и шаг, оставивший его пустым, упрётся в отказ сохранения: запись накопит отказы и остановится признаком на первом же приведении.

Ссылки на файлы у записи две — на принятую копию и на приведённую, — и обе живут до конца, но владелец файла MUST по-прежнему лежать своей колонкой, а не выводиться через запись: файл переживает свою запись, и заведённый шагом до сохранения записи он остаётся с владельцем и без ссылки.

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

Scenario: Чужой файл не отдаётся

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

Scenario: Свой файл отдаётся

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

Scenario: Файл без владельца не сохраняется

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

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

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

Requirement: Учётная запись с записями не удаляется

Хранилище SHALL отвергать удаление учётной записи, у которой остались аудиозаписи, файлы либо темы словаря, и MUST держать этот запрет самой схемой — обязательной связью, которая не даёт убрать строку, пока на неё ссылаются.

Считаются все таблицы с колонкой владельца, и перечень их MUST жить одним местом — шагом схемы, который эти связи объявляет. Таблица, пропущенная в счёте, пропускает удаление вперёд и оставляет за собой строки, чей владелец больше не существует.

Запрет схемой, а не проверкой вызывающего, — потому что вызывающих у удаления может стать больше одного, а проверка, записанная у одного, у остальных читалась бы как снятая. Сборка, забывшая позвать проверку, теряет защиту молча — и теряла.

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

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

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

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

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

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

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

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

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

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

Requirement: Аудиозапись — центральная сущность хранилища

Хранилище SHALL держать аудиозапись отдельной таблицей, а всё, что к ней приложено, — отдельными строками со ссылками на запись. Приложениями считаются файлы, тексты, структура реплик, темы, журнал событий и попытка распознавания.

Поля, которыми распоряжается очередь — признак захвата, срок его протухания, пауза, число отказов, время входа в рубеж, — MUST не соседствовать с содержимым записи в одной строке настолько, чтобы чтение очереди тянуло содержимое.

Запись MUST нести заголовок и краткое описание своими колонками: они читаются вместе со списком, сотней штук разом. Расшифровка и вычитанный текст MUST лежать отдельными строками: они читаются по открытию одной записи.

Тем же доводом запись MUST нести своими колонками имя файла, данное отправителем, длительность и размер. Все три показываются в списке. Приём узнаёт длительность и размер у источника метаданных и так, а имя файла приходит вместе с записью.

Имена колонок и единицы измерения нормативны: original_filename, duration_ms (миллисекунды) и size_bytes (байты). Единица стоит в самом имени, а не в комментарии: шаг схемы применённым не переписывается, а расхождение «секунды против миллисекунд» между колонкой, ответом списка и объявленным пределом не увидит ни компилятор, ни гейт — оба конца числа. Миллисекунды выбраны потому, что этой единицей уже названы соседние колонки схемы.

Различать «неизвестно» и «ноль» эти колонки не обязаны, и это решение, а не недосмотр. Обе величины ставит приём, и ставит всегда — запись, метаданные которой прочитать не удалось, отвергается отказом и не заводится вовсе. Ноль в этих колонках означает ноль, и колонки MUST быть объявлены обязательными: пустое значение, которое схема теперь допустить может, завело бы третий смысл, которого никто не читает. Решение владельца 2026-08-15, и смена хранилища его не отменяет.

Имя файла на записи и заголовок MUST лежать разными колонками. Заголовок несёт название, которое дал человек либо посчитала языковая модель; имя файла — то, по чему человек узнаёт свою запись, пока заголовка нет. Одной колонкой на оба смысла посчитанное название затирало бы имя, и вернуть затёртое было бы неоткуда.

Величины на записи и на её файле расходятся по смыслу, и равенство между ними не поддерживается никем — намеренно. На записи лежит снимок принятого, взятый приёмом один раз и больше не пересчитываемый; на файле — величины той копии, которой файл является сейчас. Приведённая копия имеет свой размер, и записи он не принадлежит.

Отсюда норма, без которой два числа читались бы как копии одного: величины записи MUST не сверяться со строкой файла и MUST не переписываться ничем после приёма. Расхождение между ними — не поломка, а разные вопросы: «что человек прислал» и «что лежит сейчас».

Scenario: Список читается без содержимого

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

Scenario: Длительность и размер читаются без строки файла

  • GIVEN запись принята
  • WHEN читают её длительность и размер
  • THEN строка файла при этом не читается

Scenario: Пустая длительность в схему не ложится

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

Scenario: Посчитанный заголовок не затирает имя файла

  • GIVEN запись принята с именем файла отправителя
  • WHEN записи проставляют заголовок
  • THEN имя файла остаётся прежним

Requirement: Словарь тем ведётся по владельцу

Хранилище SHALL держать темы отдельной таблицей, и тема MUST быть уникальна в паре «владелец и название»: словарь тем свой у каждого человека. У записи MUST быть не больше пяти тем.

Отдельной таблицей, а не набором строк в записи, — потому что перечень тем человека нужен целиком перед каждым обращением к модели, а собрать его из наборов строк можно только перебором всех его записей.

Потолок в пять тем MUST держаться самой схемой: без него часовой разговор даёт два десятка тем, и словарь распухает за неделю. То же число сервис объявляет приложению — норму держит capability archive, — и второй константы рядом MUST не заводиться.

Название темы выведено из содержимого записи, а перечень тем человека — слепок того, о чём он вообще говорит. В журнал сервиса темы MUST не попадать наравне с текстом расшифровки.

Ни один шаг этого изменения тем не пишет и не читает: место заведено вперёд, чтобы задача, считающая темы языковой моделью, не платила вторым необратимым шагом схемы. Цена решения названа прямо — имена таблицы и её колонок закрепляются раньше, чем известен их потребитель.

Scenario: Тема одного человека не мешает теме другого

  • GIVEN у двух владельцев заведена тема с одинаковым названием
  • WHEN смотрят словарь тем
  • THEN это две разные темы, каждая своего владельца

Scenario: Шестая тема не заводится

  • WHEN записи назначают шестую тему
  • THEN назначение не проходит

REMOVED Requirements

Requirement: Наружу хранилище отдаёт только то, что заказано

Reason: Требование закрывало собственную поверхность встроенного хранилища — перечисление записей коллекций, журналы запросов, резервные копии, настройки, расписание, — которую тот публиковал тем же портом. Хранилище уходит из проекта целиком, и поверхности этой не существует: закрывать больше нечего.

Migration: Отвечает сервис теперь только своими адресами, а всё, что не принадлежит корню приложения и не является отдельным адресом наблюдения, идёт общим правилом неизвестного пути — норму держит capability webapp. Что содержимое записи закрыто везде, где лежит, нормирует требование «Содержимое записи закрыто везде, где лежит».

Requirement: Владелец видит записи в панели

Reason: Панель администратора уходит вместе с хранилищем и не заменяется ничем — решение владельца 2026-08-22. Требовать поведения от инструмента, которого нет, нельзя.

Migration: Возврат остановленной записи в работу делает подкоманда набора инструментов разработчика — норму держит capability pipeline, требование «Остановка записи — признак, а не рубеж»: там названы и поля, которые возврат обязан сбросить, и обязанность писать событие журнала записи с происхождением «человек». Прочая правка записи ждёт экранов владельца, а их приносят задачи audiorecord-actions и play-recording-in-app.

Requirement: Пароль владельца от панели не лежит в конфигурации

Reason: Пароля от панели больше нет — вместе с панелью. Секрет, появившийся только ради перевода на встроенное хранилище, пропадает, и ключа под него в конфигурации не заводится по той простой причине, что заводить нечего.

Migration: Инвариант проекта «Секрет не покидает конфиг» остаётся в силе для оставшихся секретов — ключа распознавания и пары ключей внешнего хранилища. Приглашения завести владельца сервис больше не печатает.

Requirement: Содержимое записи закрыто во всех коллекциях, где лежит

Reason: Требование написано словарём коллекций, правил доступа и защищённых полей — механизмов встроенного хранилища. Механизмов этих не остаётся, а норма остаётся: она переписана требованием «Содержимое записи закрыто везде, где лежит».

Migration: Читать норму по новому требованию. Закрытость держится тем, что адреса чтения содержимого пишет сервис и каждый судит владельца связанной записи, а не правилом просмотра коллекции и не пометкой поля.