## Критерии приёмки

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