## Context

Способ решения выбран до этой задачи, и переписывать его здесь незачем:
хранилищем становится PocketBase вместе с файлами и панелью
([ADR-2026-08-11-pocketbase-storage-with-admin-panel](../../../docs/adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md),
замер — [research/pocketbase.md](../../../docs/research/pocketbase.md)), очередь
остаётся своей таблицей, но коллекцией той же базы
([ADR-2026-08-11-queue-as-pocketbase-collection](../../../docs/adr/ADR-2026-08-11-queue-as-pocketbase-collection.md),
сравнение кандидатов — [research/job-queue.md](../../../docs/research/job-queue.md)).
Ниже — только то, что этими решениями не закрыто.

Сегодня состояние лежит в SQLite через `mattn/go-sqlite3`, запросы строит goqu,
схему двигает goose, файлы лежат плоским каталогом с именами по идентификатору,
а HTTP отдаёт gin. Версия PocketBase — та же, что мерила разведка: 0.39.10.

## Goals / Non-Goals

**Goals:**

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

**Non-Goals:**

- **Перенос прежних данных.** Не переносим и не пытаемся прочитать: решение
  задачи, а не следствие отказа.
- **Вход пользователей.** Провайдер OIDC у коллекции пользователей — задача
  `oidc-login`.
- **Закрытие панели снаружи.** Это работа выкладки: адрес панели закрывает
  Authelia на обратном прокси.
- **Отказ от холостого опроса.** Три воркера по-прежнему опрашивают базу раз в
  секунду; открытый вопрос архитектуры остаётся открытым.
- **Удаление файлов и объектов.** Хранение бессрочно, задача `delete-record`
  своя.
- **Своё резервное копирование.** Берём мы встроенное или нет — вопрос не
  решён и здесь не решается.

## Decisions

### Приложение поднимает PocketBase само, а не отдаёт ему командную строку

Библиотека умеет запускаться двумя способами: `Start()` отдаёт процесс её
собственному набору команд, а `Bootstrap()` плюс `apis.Serve()` оставляет
управление нам.

Берём второй. Первый забирает разбор флагов себе, и ключ `-c config.toml`,
объявленный в командах проекта, пришлось бы либо ломать, либо подпирать. Мягкая
остановка по сигналу с двумя таймаутами из конфигурации — тоже наша, и отдавать
её чужой команде не за что.

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

*Отвергнуто:* `Start()` с подстройкой флагов — экономит десяток строк и ломает
объявленный контракт запуска.

### Наши два обработчика переезжают на роутер PocketBase, gin уходит

Панель отдаётся тем же портом, что и приложение, — так решено ADR. Значит порт
слушает сервер PocketBase, и второму серверу на том же порту взяться неоткуда.

Наши маршруты — `POST /api/audio`, `GET /api/status/:id`, `GET /health`,
`GET /metrics` — переезжают в его роутер обработчиком на `OnServe`. Столкновения
имён нет: PocketBase занимает `/api/collections`, `/api/files`, `/api/settings`,
`/api/logs`, `/api/backups`, `/api/crons`, `/api/realtime`, `/api/batch` и
`/api/health`, а `/api/audio` и `/api/status/:id` свободны. Публичный контракт
API от этого не меняется — меняется то, кто его обслуживает.

Вместе с gin уходит `samber/slog-gin`, и запросы начинает писать журнал
PocketBase. Проверки приёма по HTTP переписываются под новый обработчик; их
предмет — коды, поля ответа и запрет имени отправителя в журнале — сохраняется
дословно.

*Отвергнуто:* **два сервера на разных портах** — наружу опубликован один порт, и
панель осталась бы недоступной либо потребовала бы второй маршрут на прокси;
ADR решил иначе. **gin общим обработчиком под роутером PocketBase** — маршруты
разбирались бы дважды, а совпадение с чужим путём проявилось бы как молчаливый
перехват.

### Схему заводят миграции PocketBase, каталог `migrations/` уходит

Коллекции `files` и `transcribe_jobs` заводит зарегистрированная миграция на Go;
`apis.Serve` применяет непринятые перед стартом сервера. Инвариант проекта
«миграция, уехавшая на сервер, не переписывается» переносится дословно: файл
шага не правится, изменение — только новым файлом.

Goose, goqu и `mattn/go-sqlite3` уходят из зависимостей вместе с каталогом
`migrations/*.sql` и вшиванием его в бинарник.

### Состав коллекций

`files` — по записи на физическую копию, как и сегодня:

| Поле | Что |
| --- | --- |
| `file` | сам файл; пусто у копии в Object Storage |
| `location` | `local` или `s3` |
| `object_key` | ключ объекта; пусто у местной копии |
| `size` | размер в байтах |

Поле названо `location`, а не `storage`, как сегодня, потому что после перевода
слово `storage` занято дважды: так зовётся capability и так зовут само хранилище.
Третий смысл в поле записи развёл бы `storage.FileRepository` и `entity.StorageS3`
по разным вещам под одним словом, и увидеть это в коде было бы нечем.

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

- `is_error` **уходит**. Задача выбывает из выборки состоянием, и способ этот
  один: два способа разошлись бы, и молчаливо потерялся бы тот, который забыли
  проверить;
- `state` получает значение `dead`;
- прибавляется `attempts` — число попыток.

Идентификаторы записей выдаёт PocketBase — 15 знаков собственного алфавита. Наши
UUID уходят: два источника идентификатора в одной таблице дают два формата
ссылки на одну сущность. Ответ `POST /api/audio` при этом продолжает нести
`job_id` строкой — контракт говорит о поле, а не о длине значения.

**Инвариант «новая колонка правится в четырёх местах» остаётся, но переезжает.**
Мест по-прежнему четыре — отображение задачи в запись и обратно, перечень колонок
захвата и структура, в которую он читает, — и все четыре лежат в одном пакете, а
не в четырёх запросах разных слоёв. Условие, ради которого инвариант писался, при
этом не снято: компилятор видит два места из четырёх, и колонка, забытая в паре
«перечень — структура», приезжает из захвата нулевой, а первый же `Save` пишет
этот ноль поверх сохранённого. Теряется поле **только у задачи, попавшей к
воркеру**, — то есть тише, чем прежде.

### Захват — один запрос с `RETURNING` мимо записей коллекции

```
UPDATE transcribe_jobs
   SET acquisition_id = ?, acquire_time = ?, attempts = attempts + 1, updated = ?
 WHERE id = (SELECT id FROM transcribe_jobs
              WHERE state = ? AND (delay_time IS NULL OR delay_time < ?)
                AND (acquisition_id IS NULL OR acquire_time < ?)
              ORDER BY created LIMIT 1)
RETURNING <колонки>
```

`RETURNING` в движке за `modernc.org/sqlite` есть, и замер разведки показал: на
трёх горутинах разом запись получает ровно одна. Запрос идёт через `app.DB()`,
который всё, кроме выборок, направляет в пул с единственным соединением, — то
есть захваты выстраиваются в очередь, а не соревнуются за файл.

`ORDER BY` идёт по времени заведения **и по ключу записи**: время неуникально, и
без ключа порядок обработки невоспроизводим, а проверка, опирающаяся на
«следующую» задачу, зелена через раз.

**Время во всех колонках очереди — то же, каким хранилище пишет свои
`created`/`updated`:** строка `2006-01-02 15:04:05.000Z` в UTC (`types.DateTime`
библиотеки). Наш запрос кладёт и сравнивает `acquire_time`, `delay_time` и
`updated` только через это же значение. Причина не в аккуратности: сравнение
строк в SQLite побайтовое, и вид, разошедшийся на разделителе или на дробной
части, обращает `acquire_time < ?` в постоянную истину — тогда любая захваченная
задача немедленно достаётся второму воркеру — или в постоянную ложь — тогда
брошенная задача не возвращается никогда. Оба исхода тихие, и тест, который сам
же кладёт время своим кодом, зелен в обоих.

Хуки коллекции на сыром запросе не срабатывают — цена названа в ADR; поле
времени изменения проставляет тот же запрос.

*Отвергнуто:* **захват записями коллекции** — это снова два шага без транзакции,
ровно то, от чего уходим. **Захват в транзакции PocketBase** — даёт то же
свойство дороже: транзакция на каждый холостой опрос, которых 259 200 в сутки.

### Результат пишет только держатель захвата

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

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

Без второго два воркера пишут в одну задачу по очереди: результат первого
затирает результат второго, файл второго остаётся сиротой, распознавание уходит в
Yandex дважды за наши деньги, а отправитель получает два ответа на одну запись.

### Мёртвая задача, попытки и пауза

Три вещи, которые легко свести в одну и нельзя: **счётчик попыток**, **пауза
повтора** и **задержка опроса чужой операции**.

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

**Кто переводит в «мертва».** Тот, кто захватил задачу с превышенным счётчиком:
захват её выдаёт, вызывающий видит перебор, ставит `dead`, сообщает отправителю и
возвращает «работы нет». Условие `attempts < предел` прямо в отборе не годится —
задача исчезла бы из выборки, не получив состояния, то есть выбыла бы молча.

**Отправителю сообщается.** Переход в «мертва» идёт тем же путём, что отказ шага:
инвариант «Принятая запись не теряется молча» допускает два исхода — либо задача
пригодна к повтору, либо о неудаче сказано, — и молчаливая смерть не подходит ни
под один.

**`dead` и `failed` — разные приговоры, а не два имени одного.** В `failed`
задачу переводит шаг, рассудивший об этой записи окончательно: файл не
конвертируется, распознавание вернуло ошибку. В `dead` задача уходит без такого
суждения: мы повторяли и перестали. Ни один шаг конвейера в `dead` не переводит
сам, и выбирать между двумя ему не приходится.

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

Числа — в таблицу настроек `docs/database.md`:

| Настройка | Значение | Откуда |
| --- | --- | --- |
| Предел попыток | 5 | — |
| Пауза перед повтором | `2^(попытка−1)` секунд, потолок 5 минут | — |
| Срок захвата, конвертация | 8 часов | потолок записи 6 часов плюс запас |
| Срок захвата, проверка операции | 1 час | опрос идёт секунды |
| Задержка перед первой проверкой операции | 10 секунд | как сегодня |
| Задержка между проверками операции | 5 секунд | как сегодня |

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

### Имя файла в хранилище задаём мы, а не хранилище

Умолчание PocketBase строит имя из имени, данного отправителем: `sample.ogg`
превращается в `sample_uztrv6wvz3.ogg` — так это замерила разведка, и так это
предсказали ADR и модель угроз.

**Умолчание не берём.** Спека `intake` уже нормирует обратное: имя отправителя в
хранилище не попадает, потому что имя файла кончается в журнале, а имя
отправителя в журнал не пишется по инварианту приватности. Имя файла у библиотеки
— обычное поле, и мы ставим в него своё: идентификатор с расширением, как
сегодня.

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

Изъятие из инварианта — расширение, хвост после последней точки — остаётся ровно
таким, каким объявлено, и не расширяется до полного имени.

### Файл кладётся потоком, а рабочая копия заводится одним способом

Расчётный потолок записи — шесть часов, и в память такая запись не помещается.
Библиотека умеет строить файл из пути на диске и читает его потоком. Значит приём
пишет тело во временный файл, отдаёт его хранилищу и убирает за собой; каталог
временных файлов — общесистемный, не `data/`.

Обратная сторона — та же и упускается легче. **Конвертер и чтение метаданных
принимают путь**, потому что отдают файл внешней программе: `ffmpeg` и `ffprobe`
получают имя аргументом. Хранилище пути наружу не даёт, значит между ними нужна
рабочая копия — и вот её-то и надо завести **одним местом**, а не по месту в
каждом шаге.

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

*Отвергнуто:* **построение файла из байтов в памяти** — проще на строку и роняет
процесс на первой же длинной записи. **Путь внутрь раскладки хранилища, отданный
`ffmpeg` напрямую** — раскладка библиотеки становится нашим контрактом, а
требование «файл адресуется записью» не выполняется с первого дня и молча.
**Перевод конвертера и `ffprobe` на потоки** — дороже всего и упирается в то, что
длительность из потока `ffprobe` отдаёт не всегда.

### Панель — вход в задачу, а не окно просмотра

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

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

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

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

### Потолок размера назван числом, потому что чужие умолчания малы

Прогон показал то, что чтением не видно: нулевой потолок у поля файла библиотека
читает не как «без предела», а как своё умолчание в **5 МиБ**, а роутер
хранилища отсекает тело запроса на **32 МиБ** раньше нашего обработчика. Оба
умолчания на два-три порядка меньше расчётной записи в шесть часов: приём
отказывал бы на всём длиннее примерно пяти минут, а уже принятая запись
исчерпывала бы попытки на шаге конвертации — результат в ogg переваливает 5 МиБ
примерно на пятой минуте.

Поэтому потолок задан числом и одним: `entity.MaxRecordSize`, 8 ГиБ, выведено из
шести часов с запасом на видео. Тем же числом ограничено тело запроса приёма.
Заодно снят таймаут чтения — умолчание в пять минут не переживает заливку
шестичасовой записи по медленному каналу, а стойкость к целенаправленной
нагрузке объявлена вне модели угроз.

### Правила панели стоят на правке запросом, а не на всяком сохранении

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

Событие правки **запросом** различает источник по построению: конвейер пишет
мимо HTTP-слоя и под него не попадает.

### Поле файла не помечаем защищённым, но ссылка не уезжает в журнал

Защищённое поле требует отдельного файлового токена. Не помечаем: сегодня право
прочитать задачу даёт знание её идентификатора, и файл встаёт вровень с
`GET /api/status/:id`, а не ниже. Правила доступа коллекций при этом остаются
пустыми — то есть перечислить записи может только владелец панели, и подобрать
идентификатор снаружи неоткуда.

**Отсюда следствие, которого не было при плоском каталоге, и оно меняет смысл
изъятия из инварианта приватности.** Изъятие выписано под путь на диске:
`data/files/<uuid>.ogg` читателю журнала бесполезен. После перевода имя файла в
хранилище — это последняя часть ссылки `/api/files/...`, по которой запись
скачивает кто угодно; строка журнала стала бы бессрочным ключом к чужому аудио.

Поэтому **в журнал идёт расширение собственным полем**, а имя файла — ни в каком
виде. Прослеживаемость от этого не страдает: требование `intake` просит
идентификатор, расширение и размер, и все три остаются. Изъятие остаётся ровно
таким, каким объявлено: расширение, и только оно.

**Отказы обрываются там же.** Отказ чтения из хранилища несёт ключ файла целиком,
отказ выгрузки в Object Storage — полный адрес объекта; обе цепочки `%w` уехали
бы в журнал и собрали бы ссылку не хуже успешного пути. Поэтому наружу идёт свой
текст с идентификатором записи, а чужой не оборачивается.

Цена названа: ссылка на файл, единожды утёкшая, работает без ограничения по
времени. Разграничение доступа целиком — задачи `oidc-login` и
`record-ownership`, и до них периметр таков, каким его описывает модель угроз.

### Ссылку на файл строит панель, а не наш контракт

Потребителя у ссылки внутри сервиса нет: ответ опроса готовности её не несёт,
распознавание берёт содержимое, а панель строит ссылку сама. Поэтому метода
«построй ссылку» в договоре ядра с хранилищем **не заводим** — иначе форма
HTTP-пути протекла бы в доменный контракт, а знать о протоколе хранилищу незачем.

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

### Владелец панели заводится ссылкой при первом запуске

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

Ссылка равносильна паролю от панели, поэтому у неё два ограничения, и оба у
библиотеки уже есть: **тридцать минут жизни** (`NewStaticAuthToken(30*time.Minute)`)
и печать **только пока владельца нет**. Проверено чтением её кода;
подтвердить прогоном — шаг приёмки. Бессрочная ссылка в журнале отдала бы панель
всякому читателю логов навсегда — при инварианте «строки уже уехали в журнал
контейнера» это необратимо.

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

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

### Ключи конфигурации: два пути заменяются одним каталогом

`[database] path` и `[storage] path` уходят: база и файлы съезжаются под один
каталог, и по-другому хранилище не умеет.

**Имя ключа конфигурации проект объявил необратимым**, поэтому решение принял
человек 2026-08-11: **`[storage] data_dir` со значением `data`**. Варианты и цена
каждого:

- `[storage] data_dir` — **выбрано**. Ключ назван по назначению, как названы и
  сегодняшние; смена библиотеки через год имени не тронет. Слово `storage` при
  этом уже занято capability, но в конфигурации оно значит ровно то же — где
  лежат данные;
- `[pocketbase] data_dir` — прямее всего читается тем, кто знает библиотеку, и
  вписывает имя поставщика в необратимый ключ. Смена библиотеки потребует второго
  необратимого переименования;
- `[data] dir` — короче и нейтральнее всех, но `data` в проекте уже значит
  каталог на диске, и секция с таким именем читается как «настройки каталога», а
  не «настройки хранилища».

## Risks / Trade-offs

- **Правила доступа коллекций оставлены пустыми, а сама база публикует
  `/api/collections/...` и служебные разделы наружу** → пустое правило значит
  «только владелец панели», то есть анонимный запрос к записям получает отказ.
  Проверяется прогоном на живом сервисе, а не рассуждением, и прогон этот —
  отдельный шаг приёмки.
- **Панель висит на публичном порту** → закрывает её Authelia на обратном прокси;
  это работа выкладки, и до неё панель открыта всякому, кто знает адрес. Записано
  моделью угроз, задачи в беклоге нет намеренно.
- **Захват идёт сырым запросом мимо записей коллекции** → правка состава колонок
  очереди перестаёт быть видной компилятору в этом одном месте. Держится тестом
  захвата, который читает захваченную задачу целиком.
- **Число попыток растёт при захвате** → задача, которую бросают по независящей от
  неё причине (перезапуск сервиса), тратит попытки. Смягчение: счётчик обнуляется
  на каждом шаге, завершившемся без отказа, поэтому пять перезапусков подряд
  должны прийтись на одну и ту же задачу, чтобы её убить.
- **Задача умирает молча, если сообщение отправителю не дошло** → переход в
  «мертва» отвечает тем же путём, что и отказ, и отказ отправки логируется так же.
  Гарантии доставки у нас нет ни там, ни там, и этой задачей она не заводится.
- **Проверки приёма по HTTP переписываются целиком** → предмет проверок при этом
  не меняется, и расхождение поймает сравнение с прежним списком сценариев спеки
  `intake`.
- **Идентификаторы задач меняют формат** → внешняя программа, хранящая прежние
  идентификаторы, их не найдёт. Прежних данных нет по решению задачи, поэтому
  цена нулевая — но названа, потому что при переносе данных была бы не нулевой.

## Migration Plan

Переноса нет. Сервис поднимается на чистом каталоге данных; момент перехода на
сервере назначает человек, и до него прежний каталог остаётся нетронутым.

Откат — возврат прежнего образа и прежнего каталога `data/`: новый каталог
данных заводится рядом, старого не трогает.

## Open Questions

- **Своё резервное копирование PocketBase** — берём или оставляем серверу;
  открытый вопрос архитектуры, этой задачей не закрывается.
- **Отказ от холостого опроса** — 259 200 запросов в сутки посчитаны, цена не
  измерена; вопрос остаётся открытым.
