## Критерии приёмки Скопированы из записи задачи `storage-without-pocketbase` дословно: запись закрытие удалит, критерии обязаны её пережить. - Библиотеки нет в сборке. **Оракул:** `go list -deps ./... | grep -c pocketbase` даёт `0`, а `go mod tidy` не возвращает её модулей в `go.mod`. - Чужой файл недостижим по прямой ссылке. **Оракул:** тест контроллера — запрос к файлу чужой записи и к несуществующей отвечает одинаково. - Захват записи остаётся неделимым. **Оракул:** тест с параллельными воркерами под `-race` — запись достаётся ровно одному, второй получает отказ по значению признака захвата. - Пространства хранилища не существует. **Оракул:** тест маршрутов — `/api/…`, `/_/` и `/%5f/` отвечают тем же, чем всякий неизвестный путь. - Схема накатывается на пустом каталоге до старта воркеров. **Оракул:** запуск на чистом каталоге данных — ни одного отказа в журнале до первой строки о готовности. Ниже — рубрика ревью дизайна. Приёмка судится по одному списку, поэтому она стоит здесь же, а не отдельным документом. - Ограничения схемы действуют при любом способе записи и на каждом соединении пула. **Оракул:** соединение, взятое из читающего пула, отвечает `1` на `PRAGMA foreign_keys`; вставка аудиозаписи с несуществующим владельцем отвергается базой и на пишущем соединении, и на читающем. - Захват неделим, а держатель узнаётся значением признака. **Оракул:** тест под `-race` с несколькими воркерами — запись достаётся ровно одному; запись результата с чужим значением признака не проходит и записи не меняет. - Узел, читающий состояние, которое сам же меняет, называет неделимый шаг. **Оракул:** захват записи, заведение учётной записи первым обращением и накат шага схемы идут одним запросом либо одной транзакцией на пишущем соединении; тест на два одновременных первых обращения даёт ровно одну строку пользователя. - Границы транзакции названы, и отмена контекста оставляет запись либо прежней, либо полной. **Оракул:** тест с отменой контекста посреди составной операции — читающий следом видит либо всё прежнее, либо всё новое, и ни одной половины. - Накат схемы идемпотентен и не оставляет полуприменённого состояния. **Оракул:** второй запуск на заведённом каталоге не применяет ни одного шага; запуск, оборванный между применением шага и отметкой о нём, при повторе даёт тот же исход либо отказ с именем шага, но не удвоенное применение. - Укладка файла атомарна, и неполная укладка не выдаёт себя за полную. **Оракул:** источник, отдающий отказ на середине потока, не оставляет ни строки о файле, ни файла под рабочим именем, ни временного имени в подкаталоге записи. - Время, идентификаторы и их вид приходят из одного места. **Оракул:** все колонки времени применённой схемы объявлены одним типом и без умолчания; строка, заведённая приёмом, и строка, заведённая запросом к базе, попадают в отбор захвата одинаково. - Отдача файла одинаково отвечает на чужое и несуществующее, а размер ответа ограничен запрошенным. **Оракул:** тест обработчика — чужая запись, несуществующая запись и негодное значение параметра `copy` у чужой записи дают один код и одно тело; запрос с диапазоном отдаёт длину запрошенного куска, а не файла целиком; настоящий HTTP-запрос с `Range: bytes=99999999-` и запрос с двумя диапазонами дают код `400` и тело с полями `error_code` и `message`, а не `416` телом библиотеки. - Отказы, рождающиеся не в обработчике, приходят той же формой, что и отказы обработчика. **Оракул:** настоящий HTTP-запрос через поднятую цепочку слоёв — предел тела, ограничитель частоты и неизвестный путь под корнем приложения дают тело с полями `error_code` и `message` и код из закрытого перечня; вызовом отображателя ошибки это не проверяется. - Ключ ограничителя частоты называет того, кого надо ограничить. **Оракул:** два клиентских адреса через один доверенный прокси расходуют разные бюджеты, а заголовок пересылки, пришедший с недоверенного адреса, на ключ бюджета не влияет. - Горячие выборки опираются на индекс. **Оракул:** `EXPLAIN QUERY PLAN` отбора захвата и `EXPLAIN QUERY PLAN` списка, сужаемого владельцем и страницей, не показывают полного сканирования таблицы аудиозаписей. - Подъём и остановка симметричны и громкие. **Оракул:** отказ шага подъёма даёт ненулевой код выхода и ровно одну строку журнала о причине; мягкая остановка закрывает то же, что открыл подъём, — оба пула базы, входы и воркеров, — и повторная остановка не даёт паники. ## 1. База и её схема - [x] 1.1 Завести подключение к базе на `modernc.org/sqlite`: одно соединение для записи, отдельный пул для чтения, журнал упреждающей записи, ожидание занятой базы числом из настроек - [x] 1.2 Подключить `github.com/pressly/goose/v3` библиотекой: добавить модуль в `go.mod`, завести каталог шагов, вшитый в бинарник, и поднимать провайдер в точке входа. Командная строка `goose` не заводится: ни отдельного исполняемого файла, ни своего шага выкладки - [x] 1.3 Взять исключающую блокировку наката самим: `goose` под SQLite её не поставляет — запиратели у него только для PostgreSQL. Замок на файле в каталоге данных (`syscall.Flock`, `LOCK_EX`) берётся до наката и снимается после; проверить тестом, что второй накат на том же каталоге ждёт либо отказывает, а шаги параллельно не применяются - [x] 1.4 Удалить каталог шагов PocketBase целиком — `internal/adapter/repo/pocketbase/migrations` - [x] 1.5 Завести один шаг начальной схемы в новом каталоге шагов: разовое снятие инварианта «применённая миграция не переписывается» решением владельца 2026-08-22 - [x] 1.6 Перевести ключ `[docs] migrations` в `.av-dev.toml` на новый каталог шагов. Без этого шаг гейта `migrations` покраснеет на удалении файлов прежнего каталога — он читает их статусом `D` как переписанный применённый шаг - [x] 1.7 Написать шаги схемы под все таблицы — учётные записи, аудиозаписи, файлы, тексты, структура реплик, попытки распознавания, журнал событий, темы — с обязательными связями владельца и обязательными колонками `original_filename`, `duration_ms`, `size_bytes` - [x] 1.8 Завести тем же шагом два индекса аудиозаписей: под отбор захвата — по рубежу, признаку остановки, паузе и сроку протухания захвата; под список — по владельцу и колонке упорядочивания страницы вместе с ключом записи. Индексы заводятся здесь, а не потом: применённый шаг схемы не переписывается, и добавление индекса будет стоить отдельного шага, а замер 2026-08-15 уже показывал полное сканирование на выборке, сужаемой владельцем - [x] 1.9 Накатывать схему до подъёма входов и до старта воркеров, отказ шага ронять стартом с именем шага - [x] 1.10 Записать числа настроек базы в `docs/database.md`, «Настройки с числовым значением» - [x] 1.11 Завести в `config.example.toml` два ключа секции `[storage]` — `busy_timeout_ms` (ожидание занятой базы, миллисекунды) и `read_connections` (число соединений читающего пула) — с комментарием на каждый: зачем, границы, единицы; проверить, что загрузчик их читает и старт на пустом значении не молчит - [x] 1.12 Снять `EXPLAIN QUERY PLAN` с отбора захвата и со списка, сужаемого владельцем и страницей: полного сканирования таблицы аудиозаписей ни один из планов не показывает ## 2. Репозитории на своей базе - [x] 2.1 Переписать репозиторий аудиозаписи: чтение, сохранение своих полей, условная запись результата по признаку захвата - [x] 2.2 Переписать захват одним запросом с `RETURNING`, возвращающим идентификатор записи и признак этого захвата - [x] 2.3 Переписать репозитории приложений записи — тексты, структура реплик, попытки распознавания, журнал событий, темы — с уникальностью по паре «запись и вид» и «запись и версия разбора» - [x] 2.4 Переписать репозиторий учётных записей: поиск по ключу, заведение при первом обращении, разбор двух отказов уникальности - [x] 2.5 Проверить тестом, что пустая замена не стирает сохранённый текст и сохранённый ответ провайдера - [x] 2.6 Отображать сущности в строки базы **по имени**: именованные параметры запроса и сканирование по имени колонки, без позиционных списков. У аудиозаписи ссылки на файлы, на структуру реплик, на два вида текста и на попытку распознавания стоят подряд полями одного типа, и позиционный сдвиг на одно поле скомпилировался бы молча, положив идентификатор файла в колонку текста - [x] 2.7 Убрать поля `location` у сущности файла и `source` у аудиозаписи — из домена, из отображения в строки базы и из шага начальной схемы. Обе пишутся сегодня одним значением и не читаются никем, а второе значение `source` держалось ссылкой из применённого шага, который уходит. Вернуть поле, когда у него появится читатель, будет стоить одного нового шага схемы ## 3. Файлы записей своим каталогом - [x] 3.1 Завести раскладку: подкаталог на запись под её идентификатором, имя файла задаёт сервис, копии `original` и `normalized` лежат вместе - [x] 3.2 Переписать репозиторий файлов: укладка потоком без чтения в память, чтение потоком, владелец колонкой, ссылки на две копии порознь - [x] 3.3 Сохранить единый способ выдать рабочую копию шагу и единственный способ её убрать - [x] 3.4 Проверить тестом, что имени отправителя нет ни в имени файла, ни в пути к нему, ни в журнале ## 4. Маршруты и слои на `net/http` - [x] 4.1 Переписать подъём сервера и цепочку слоёв без чужого маршрутизатора, сохранив порядок «ограничитель частоты → узнавание» - [x] 4.2 Написать свой ограничитель частоты по адресу спрашивающего под корнем приложения и вывести объявляемую частоту опроса из доли его бюджета - [x] 4.3 Переписать узнавание по доверенному заголовку на своих типах, убрав выдачу и приём всякого значения на предъявителя - [x] 4.4 Переписать обработчики приёма, списка, карточки, текста, пределов и «кто вошёл» на своих типах - [x] 4.5 Завести обработчик отдачи файла `GET /app/audiorecords/{id}/file` с проверкой владельца, параметром `copy` и закрытым перечнем его значений, кодом `409` на отсутствующую копию и выдачей по диапазону. Негодный диапазон — неудовлетворимый и множественный — приводить к обычному отказу `400` с телом сервиса, а не отдавать `416` телом библиотеки - [x] 4.6 Свести адресное пространство сервиса к одному корню `/app` плюс `/health` и `/metrics`; проверить тестом ответы на `/api/…`, `/_/` и `/%5f/` - [x] 4.7 Убрать ветвь отказа `403` и значение `forbidden` из перечня кодов отказа вместе с её единственным случаем ## 5. Уборка библиотеки - [x] 5.1 Удалить пакет адаптера хранилища вместе с панелью и правилами панели; каталог его шагов схемы уходит пунктом 1.4 - [x] 5.2 Убрать из настроек и из образца конфига всё, что относилось к панели и к её владельцу - [x] 5.3 Убрать библиотеку и достижимые только через неё модули из `go.mod`, прогнать `go mod tidy` - [x] 5.4 Проверить оракулом, что `go list -deps ./... | grep -c pocketbase` даёт `0` - [x] 5.5 Снять изъятие правил `internal/archrules`, разрешавшее транспорту знать адаптер хранилища, и добавить правило на это направление - [x] 5.6 Переписать правила `internal/archrules` о колонках записи (`TestКолонкиЗаписиПишутсяИЧитаются`, `TestКолонкиЗаписиЗаведеныШагомСхемы`) и о едином дескрипторе рубежа (`TestОтборСпискаБерётРубежиУДескриптора`, `TestУКаждогоРабочегоРубежаЕстьШаг`, `TestШагиОбъявленыРубежамиДескриптора`) под новую форму хранилища: сегодня они построены на динамической записи по имени колонки в API уходящей библиотеки и указывают в удаляемый пакет. Форма выбрана — именованные параметры и сканирование по имени (пункт 2.6), — и сторожа инварианта о колонках записи переписываются под неё: они сверяют **имена** колонок в отображении, в чтении и в шаге начальной схемы, а не порядок полей. Указывают правила в новый пакет `internal/adapter/repo/sqlite` и в новый каталог шагов ## 6. Оснастка владельца - [x] 6.1 Завести в `cmd/devtools` подкоманду возврата остановленной записи в работу: принимает идентификатор записи, открывает базу каталога данных, зовёт домен и пишет событие журнала записи с происхождением `entity.EventOriginHuman`. Колонок подкоманда не пишет: перечень полей, которые возврат обязан сбросить, исполняет домен — норму держит capability `pipeline` - [x] 6.2 Проверить тестом, что возврат в работу сбрасывает всё названное требованием — признак остановки, признак захвата и срок его протухания, число отказов, паузу и время входа в рубеж, — и что ближайший захват выдаёт запись, не дожидаясь протухания прежнего срока ## 7. Проверки и документы - [x] 7.1 Написать тест параллельного захвата под `-race`: запись достаётся ровно одному, второй получает отказ по значению признака захвата - [x] 7.2 Написать тест отдачи файла: чужая запись и несуществующая отвечают одинаково - [x] 7.3 Написать тест подъёма на чистом каталоге: до строки о готовности в журнале нет ни одного отказа - [x] 7.4 Прогнать `task gate` до зелёного - [x] 7.5 Обновить документы канона — паспорт, архитектуру, модель угроз, `docs/database.md` — под ушедшие панель, пространство хранилища и токен файла; в перечне зависимостей архитектуры назвать `pressly/goose/v3` пришедшим, а библиотеку хранилища — ушедшей - [x] 7.6 Записать в `CLAUDE.md` решение владельца от 2026-08-22 о **разовом** снятии инварианта «миграция, уехавшая на сервер, не переписывается»: причина — стройка, на сервере данных нет, сервис остановлен; граница — снятие кончается этим изменением, и шаг начальной схемы подпадает под инвариант как всякий прежний. Пункт синка: документы канона правятся после задачи - [x] 7.7 При архивации изменения поправить `Purpose` спеки `storage`: сегодня он описывает отдачу файла ссылкой по токену, собственную поверхность хранилища и панель владельца — то есть ушедшее