хранилище переехало с PocketBase на SQLite со своим каталогом файлов

- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose
  под файловым замком, одна миграция начальной схемы вместо семи прежних
- транспорт переписан на net/http: свои слои, свой ограничитель частоты,
  отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли
- по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета
  читается справа налево, узнавание известного идёт читающим пулом
This commit is contained in:
av
2026-08-23 08:06:04 +03:00
parent 1edf8cb225
commit c9b7765646
118 changed files with 11668 additions and 6679 deletions
@@ -13,6 +13,25 @@ PocketBase уходит из проекта целиком: состояние
ничем**: пока идёт стройка, остановленную запись возвращает в работу запрос к
базе.
**Два решения абзаца выше сменились при разметке изменения**, и заменившее
названо здесь.
Шаги схемы двигает библиотека `github.com/pressly/goose/v3`, а не свой раннер.
Инструмент выбрал владелец 2026-08-22: библиотека уже была в этом проекте и ушла
вместе с PocketBase, а из трёх норм, которые накат обязан выполнять, две
выполняет сама.
Остановленную запись возвращает в работу подкоманда `cmd/devtools resume`, а не
запрос к базе руками. Возврат сбрасывает не одно поле записи и пишет событие
журнала с происхождением `entity.EventOriginHuman`; рука за клавиатурой не делает
ни того, ни другого. Последствие ниже — «возврат остановленной в работу […]
делает запрос к базе руками» — читается этой сменой.
Доводы обоих решений записаны в
[design.md](../../openspec/changes/archive/2026-08-23-storage-without-pocketbase/design.md),
разделы «Шаги схемы двигает `goose`, а не свой раннер» и «Панель не заменяется
ничем, а возврат в работу делает подкоманда оснастки».
Запись заменяет три:
[ADR-2026-08-11-pocketbase-storage-with-admin-panel](ADR-2026-08-11-pocketbase-storage-with-admin-panel.md),
[ADR-2026-08-11-queue-as-pocketbase-collection](ADR-2026-08-11-queue-as-pocketbase-collection.md) и
+71 -59
View File
@@ -21,18 +21,20 @@
`http-handler-tests-never-green` и `no-user-filename-in-log` 2026-08-11,
`pocketbase-storage` и `oidc-login` 2026-08-12,
`local-run-without-telegram-token` 2026-08-13, `remove-telegram-intake`
2026-08-14;
2026-08-14, `storage-without-pocketbase` 2026-08-22;
- [pipeline](../openspec/specs/pipeline/spec.md) — пустой прогон воркера, захват
задачи и срок его протухания, число попыток, остановка признаком, пауза перед
повтором и молчание конвейера наружу: задачи
`errors-as-instead-of-typecast` 2026-08-11, `pocketbase-storage` 2026-08-12,
`local-run-without-telegram-token` 2026-08-13 и `remove-telegram-intake`
2026-08-14. Переходы состояний и отмена
`local-run-without-telegram-token` 2026-08-13, `remove-telegram-intake`
2026-08-14 и `storage-without-pocketbase` 2026-08-22. Переходы состояний и отмена
контекста посреди шага остаются
долгом; что именно не описано, перечисляет раздел `Purpose` самой спеки;
- [storage](../openspec/specs/storage/spec.md) — где живут запись, её метаданные
и её файл, как файл отдаётся и что видит владелец: задача `pocketbase-storage`
2026-08-12;
и её файл, как файл отдаётся и что видит владелец: задачи `pocketbase-storage`
2026-08-12 и `storage-without-pocketbase` 2026-08-22. Последняя убрала
встроенное хранилище целиком: база стала своей, файлы — своим каталогом,
панель владельца исчезла и не заменена ничем;
- [recognition](../openspec/specs/recognition/spec.md) — **попытка распознавания
у внешнего провайдера**: что о ней хранится, почему сырой ответ сохраняется
целиком и вложением, как из сохранённого строится структура реплик без
@@ -66,13 +68,15 @@
- **Один процесс.** HTTP-сервер и фоновые воркеры живут в одном бинарнике и
делят одну базу. Отдельного воркер-процесса нет намеренно.
- **Очередь таблицей.** Состояние задачи лежит коллекцией хранилища; неделимость
- **Очередь таблицей.** Состояние задачи лежит таблицей базы; неделимость
захвата и порядок выборки нормирует
[pipeline](../openspec/specs/pipeline/spec.md), «Захват задачи неделим».
Внешний брокер не заводим: нагрузка — единицы записей в день (оценка владельца,
не замер). Готовую библиотеку очереди тоже не заводим — решено 2026-08-11,
[ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md), сравнение
кандидатов в [research/job-queue.md](research/job-queue.md).
кандидатов в [research/job-queue.md](research/job-queue.md). Решение пережило
уход встроенного хранилища: замер снят на том же драйвере, и отменилось у него
одно слово — таблица перестала быть коллекцией.
- **Шаг конвейера идемпотентен по повтору.** Что делает срок захвата и когда
задача возвращается в работу, нормирует
[pipeline](../openspec/specs/pipeline/spec.md), «Брошенная задача возвращается
@@ -83,11 +87,10 @@
`cmd/transcriber`. Слои, их дома и словарь модели — раздел «Слои и модель
домена» ниже. Правило механизировано тестами-сканерами `internal/archrules`, и
они же держат обратные направления: транспорты не знают друг о друге, адаптер
не знает ни ядра, ни транспортов.
*Изъятие:* транспорт **вправе** знать адаптер хранилища — `controller/http`
импортирует `adapter/repo/pocketbase`, потому что HTTP-поверхность и есть
роутер этого хранилища, а не наш сервер поверх него. Правила на это
направление нет намеренно.
не знает ни ядра, ни транспортов, транспорт не знает адаптеров. Изъятие,
разрешавшее транспорту знать адаптер хранилища, снято 2026-08-22 вместе с
предметом: HTTP-поверхность была роутером встроенного хранилища, а стала своей,
и правило на это направление заведено впервые.
## Слои и модель домена
@@ -133,10 +136,10 @@
наблюдаем: правило о записи, записанное в `internal/service` условием над её
полями, принадлежит `internal/entity`.
Место, где подход нарушен сегодня, названо изъятием в «Принципах»: транспорт
знает адаптер хранилища, потому что HTTP-поверхность и есть роутер этого
хранилища. Изъятие снимает задача `storage-without-pocketbase` — своя отдача
файла и свои маршруты возвращают транспорту независимость от инфраструктуры.
Изъятий у подхода сегодня нет: последнее — транспорт знал адаптер хранилища —
снято задачей `storage-without-pocketbase` 2026-08-22. Своя отдача файла и свои
маршруты вернули транспорту независимость от инфраструктуры, а узнавание
пришедшего приходит ему интерфейсом `contract.UserRepository`.
## Компоненты
@@ -146,14 +149,15 @@
| Компонент | Где | Что делает |
| --- | --- | --- |
| HTTP API | `internal/controller/http` | Адреса приложения под корнем `/app/`: приём записи, страница своих записей, карточка, текст названного вида, пределы сервера и «кто вошёл» |
| HTTP API | `internal/controller/http` | Адреса приложения под корнем `/app/` на `net/http`: приём записи, страница своих записей, карточка, текст названного вида, файл записи, пределы сервера и «кто вошёл». Слои — свои: журнал, восстановление после паники, ограничитель частоты, узнавание, требование учётной записи |
| Воркеры | `internal/controller/worker` | Пул одинаковых потоков: каждый берёт любую пригодную запись и опрашивает базу. Число — настройкой, ноль законен |
| Сервис расшифровки | `internal/service` | Конвейер: приём, приведение, отправка, опрос, завершение. Шаг выбирается по рубежу записи |
| Конвертер и метаданные | `internal/adapter/{converter,metaviewer}/ffmpeg` | `ffmpeg` в ogg/vorbis, `ffprobe` для длительности |
| Распознаватель | `internal/adapter/recognizer/yandex` | Заливка в Object Storage и отложенное распознавание SpeechKit; разбор ответа в реплики со временем |
| Репозитории | `internal/adapter/repo/pocketbase` | Записи, файлы, тексты, структура, попытки распознавания и журнал событий — коллекциями хранилища; захват — сырым запросом |
| Шаги схемы | `internal/adapter/repo/pocketbase/migrations` | Файл на шаг, имя файла — имя шага; там же имена коллекций |
| Панель владельца | `internal/adapter/repo/pocketbase`, `panel.go` | Панель хранилища; правила правки записи нормирует [storage](../openspec/specs/storage/spec.md), «Владелец видит записи в панели» |
| Репозитории | `internal/adapter/repo/sqlite` | Учётные записи, записи, файлы, тексты, структура, попытки распознавания и журнал событий — таблицами базы; захват — одним запросом с `RETURNING` по пишущему соединению |
| Файлы записей | `internal/adapter/repo/sqlite`, `store.go` | Подкаталог на запись под её идентификатором; укладка атомарна — временное имя рядом и переименование |
| Шаги схемы | `internal/adapter/repo/sqlite/migrations` | Файл на шаг, версия — число в начале имени; накатывает `pressly/goose/v3` под своим замком |
| Оснастка владельца | `cmd/devtools` | Подставной прокси для местного запуска и возврат остановленной записи в работу. Панели у сервиса нет и не будет: экраны правки приносят отдельные задачи |
| Приложение | `web/` | Vue 3, роутер пятой версии, сборка Vite. Собранное лежит в `web/embed/dist` и вшивается в бинарник; в git его нет |
| Раздача приложения | `internal/controller/http`, `webapp.go` | Корневой маршрут: разметка вне корней сервиса, отказ внутри, срок хранения по каталогу сборщика |
@@ -174,6 +178,12 @@
асинхронное: запрос возвращает идентификатор операции, готовность опрашивается
через `operation.api.cloud.yandex.net:443`, текст читается потоком.
- **ffmpeg и ffprobe.** Внешние процессы, ищутся в `PATH`.
- **SQLite через `modernc.org/sqlite`.** Драйвер на чистом Go: CGO сборке не
нужен. База и файлы записей лежат под одним каталогом данных.
- **`github.com/pressly/goose/v3`.** Шаги схемы — библиотекой, а не командной
строкой: перечень шагов приходит провайдеру доводом, накат идёт при старте.
Исключающей блокировки под SQLite библиотека не даёт, и замок каталога данных
берём сами.
- **Node и его установщик пакетов.** Нужны только сборке приложения и на машину
не ставятся: шаг зовёт их контейнером, а образ берёт из ступени `Dockerfile`.
Требованием к машине разработчика поэтому становится docker. Реестр пакетов —
@@ -191,34 +201,23 @@
Секцию `[telegram]` и ключ `server.users_while_list` человек убирает из боевого
файла после выкладки: незнакомые ключи разбор настроек не судит, и файл с ними
сервис поднимает молча.
- **Откат образа через шаг схемы `202608140002` не работает и не говорит об
этом.** Шаг удаляет прежнюю коллекцию задач, а библиотека накатывает только
те шаги, которые знает сам бинарь: прежний образ шагов новее не видит,
поднимается **без единой ошибки** и отвечает зелёной пробой здоровья — после
чего всякое обращение к очереди отказывает «коллекции нет». Проверено прогоном
двух бинарей на одном каталоге данных.
- **Откат образа на версию до 2026-08-22 не работает вовсе.** Каталог данных
сменил раскладку целиком: база зовётся другим файлом, файлы записей лежат
другими путями, а учёт применённых шагов ведёт другая таблица. Прежний образ на
таком каталоге поднимется, накатит **свои** шаги в пустое место и заведёт
вторую, чужую схему рядом. Лечится повторной выкладкой вперёд; обратного шага
схемы нет и не планируется.
Значит штатное средство владельца на инциденте — «вернём прошлый образ» — с
этого шага делает хуже и молчит. Лечится повторной выкладкой нового образа;
обратного шага схемы нет и не планируется. Порог перехода назван прямо: до
выкладки `record-centric-model` откат образа работает, после — нет.
- **Откат образа через шаг схемы `202608220001` обрывает вход.** Шаг закрывает
правила коллекции пользователей наглухо, а прежний образ заводил учётную
запись внутренним запросом обмена кода — и этот запрос закрытое правило
отвергает. Проверено прогоном прежнего кода поверх нового каталога данных:
вход отвечает `401`, в журнале «storage rejected the exchange with code 403».
Порог тот же по форме, что и у `202608140002`: до выкладки
`trusted-header-login` откат работает, после — нет, и лечится он повторной
выкладкой вперёд. Обратного шага схемы нет и не планируется.
Окно этого порога сегодня пусто: сервис не выложен, а откат уже не работает с
шага `202608140002`. Строка стоит здесь потому, что порог принято называть
прямо, а не потому, что риск сегодня чем-то грозит.
Прежние два порога — шаги `202608140002` и `202608220001` — этим поглощены: до
выкладки `record-centric-model` откат работал, после перестал, а с уходом
встроенного хранилища перестал окончательно. Окно порога сегодня пусто: сервис
не выложен. Строка стоит здесь потому, что порог принято называть прямо, а не
потому, что риск сегодня чем-то грозит.
- **Внешние зависимости поимённо и чем каждая отказывает.** Столбец «отвечает
медленно» читается вместе с тем, что таймаута нет ни у одного обращения
наружу — [database.md](database.md), «Настройки с числовым значением»:
<!-- канон: поведение → openspec/specs/intake, pipeline -->
<!-- канон: поведение → openspec/specs/intake, pipeline, storage -->
| Зависимость | Падает | Отвечает медленно | Молчит | Отдаёт мусор |
| --- | --- | --- | --- | --- |
@@ -226,7 +225,7 @@
| ↳ *остановка сервиса* | Принятие операции от отмены защищено своим пределом в 10 секунд: операцию там могли принять и начать считать деньги, а потерянный идентификатор заставил бы повтор оплатить ту же запись второй раз. Заливка в Object Storage отменяется штатно — её повтор бесплатен, объект ложится под тем же ключом | — | — | — |
| Yandex Object Storage | Заливка падает, запись остаётся на рубеже `normalized` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции |
| ffmpeg, ffprobe | Запись останавливается признаком с текстом «сбой конвертации файла» — рубеж при этом сохраняется, и снятие признака продолжает с него. Остановка сервиса — исход другой: процесс убивают контекстом, запись остаётся на повтор и отказа не тратит | Конвейер стоит: вызов синхронный | — | Выходной файл пуст, отказ вылезет на распознавании |
| Хранилище (файл на диске) | Приложение не стартует либо шаг падает на каждом запросе | Блокировка записи держит воркеры | — | — |
| База (файл на диске) | Старт кончается отказом с именем шага схемы либо шаг падает на каждом запросе | Ожидание занятой базы задано числом; исчерпав его, операция отказывает, и запись остаётся пригодной к повтору | — | — |
| Диск | Запись файла падает, задача не заводится | — | — | — |
- **Кто заметит отказ и когда:** тот, кто загрузил запись, — карточкой записи:
@@ -237,8 +236,8 @@
«падает приведение» отличается от «падает распознавание». Плюс логи
контейнера. Отдельного оповещения нет.
- **Журнал событий записи** — второй канал наблюдения, `record_events`. Пишется
на смену рубежа, на остановку и на снятие остановки; читает его человек в
панели, ни один шаг конвейера на него не смотрит. Экрана у него пока нет.
на смену рубежа, на остановку и на возврат в работу; ни один шаг конвейера на
него не смотрит. Читается запросом к базе: ни панели, ни экрана у него нет.
- **Характер потока:** непрерывный, но разреженный. Воркеры опрашивают базу
вхолостую с паузой из
[database.md](database.md), «Настройки с числовым значением».
@@ -248,7 +247,11 @@
| Что | Где |
| --- | --- |
| Приём аудио и заведение записи | `TranscribeService.createRecord` — единственный путь, которым запись появляется в хранилище |
| Правка записи владельцем | панель хранилища; правка запросом проходит правила перехода (`pocketbase.BindPanelRules`), а шаг конвейера пишет только свои поля и правку владельца не стирает |
| Возврат остановленной записи в работу | `cmd/devtools resume` — зовёт домен и пишет событие журнала записи с происхождением «человек»; колонок сама не пишет |
| Выдача идентификатора строки | `internal/ident` — ULID в нижнем регистре, монотонный внутри миллисекунды; разбор пришедшего снаружи — там же |
| Подключение к базе | `internal/adapter/repo/sqlite.Open` — пишущее соединение одно, чтение своим пулом, настройки строкой подключения обоих |
| Накат схемы | `internal/adapter/repo/sqlite.Migrate` — до подъёма входов и до старта воркеров, под замком каталога данных |
| Раскладка файлов записи | `internal/adapter/repo/sqlite.Store` — подкаталог на запись; путь на диске за её пределы не выходит |
| Захват записи воркером | `AudioRecordRepository.FindAndAcquire` — один запрос с `RETURNING`, отдаёт идентификатор и признак захвата |
| Объявление рубежа | `internal/entity/stage.go` — выбор шага, отбор захвата, срок протухания и предел простоя выводятся отсюда |
| Выбор шага по рубежу | `TranscribeService.stepFor` — таблица, а не привязка к воркеру |
@@ -264,13 +267,17 @@
| Состояния отбора списка | `internal/entity.ListFilter` вместе с `WorkingStages` и `TerminalStages` — предикаты выводятся из дескриптора рубежа, а не пишутся строкой запроса |
| Уборка имени файла отправителя | `internal/entity.SanitizeOriginalFilename` — режет по пределу и убирает управляющие знаки; зовёт её приём |
| Адресное пространство сервиса | `internal/controller/http.ServiceMounts` — перечень корней и адресов наблюдения. Он **порождает** регистрацию наших маршрутов, а не описывает её, и из него же выводятся правило неизвестного пути, уровень журнала и область действия узнавания |
| Узнавание предъявителя | `pbrepo.EnsureUser` — поиск учётной записи по логину у провайдера и заведение при первом обращении. Дом правила один и лежит в хранилище, а не в транспорте: второй способ представиться (личные токены) возьмёт этот же метод, а уложенное куском в слой оно разошлось бы двумя копиями. Транспорт читает заголовок, судит адрес пира и зовёт метод — `internal/controller/http.TrustedHeaderIdentity` |
| Узнавание предъявителя | `sqlite.UserRepository.EnsureUser` — поиск учётной записи по логину у провайдера и заведение при первом обращении. Дом правила один и лежит в хранилище, а не в транспорте: второй способ представиться (личные токены) возьмёт этот же метод, а уложенное куском в слой оно разошлось бы двумя копиями. Транспорт читает заголовок, судит адрес пира и зовёт метод интерфейсом `contract.UserRepository``internal/controller/http.TrustedHeaderIdentity` |
| Приём значения заголовка | `internal/entity.AcceptProviderLogin`, `AcceptDisplayName`, `AcceptEmail` — правило одно на все способы представиться |
| Ограничитель частоты | `internal/controller/http.RateLimit` — бюджет по адресу спрашивающего под корнем приложения; из его чисел выводится объявляемая частота опроса |
Единых точек, которых **нет** и которые ожидались бы: идентификаторы
генерируются вызовом `uuid.NewString()` по месту. Время из этого перечня ушло
2026-08-13: его читает `internal/clock`, и запрет держит линтер; отображение
доменной ошибки — 2026-08-15 задачей `json-api-for-spa`, и до неё обработчик
решал сам: опрос отвечал `404` на упавшую базу, а приём — `500` на негодный файл.
Единых точек, которых **нет** и которые ожидались бы, сегодня не осталось.
Время ушло из перечня отсутствий 2026-08-13 — его читает `internal/clock`, и
запрет держит линтер; отображение доменной ошибки — 2026-08-15 задачей
`json-api-for-spa`, и до неё обработчик решал сам: опрос отвечал `404` на упавшую
базу, а приём — `500` на негодный файл; выдача идентификаторов — 2026-08-22
задачей `storage-without-pocketbase`, и до неё их выдавало встроенное хранилище
своим алфавитом, а сервис звал `uuid.NewString()` по месту.
## Деплой
@@ -320,8 +327,9 @@ DNS-сервер, молчащий на `AAAA`, оставляет устано
[ADR-2026-08-22-login-by-trusted-header](adr/ADR-2026-08-22-login-by-trusted-header.md).
**Не решено одно:** как связать чат Telegram с учётной записью — от этого
зависит возвращение убранного входа.
Панель администратора при этом Authelia не закрывает: у неё свой пароль
суперпользователя.
Второго периметра на порту сервиса при этом не осталось: панель администратора
ушла вместе со встроенным хранилищем 2026-08-22, и закрывать её на прокси
больше нечего.
- **Приложение.** Каркас поставлен `spa-skeleton` 2026-08-15: приложение
открывается, показывает вошедшего и вшито в бинарник. Экранов загрузки и
списка нет — их делают `upload-and-status-screen` и `records-list-screen`.
@@ -343,7 +351,8 @@ DNS-сервер, молчащий на `AAAA`, оставляет устано
шесть часов нормирует [storage](../openspec/specs/storage/spec.md), «Файл
записи живёт в хранилище»; откуда взято число —
[research/pocketbase-defaults.md](research/pocketbase-defaults.md), «Чего эта
записка не узнала».
записка не узнала». Записка описывает умолчания ушедшей библиотеки, и живой
она осталась только этим числом.
- **Приём большого файла.** Форма читается целиком, предел памяти под multipart
задан числом в [database.md](database.md), «Настройки с числовым значением»;
обрыв начинает загрузку заново.
@@ -358,8 +367,8 @@ DNS-сервер, молчащий на `AAAA`, оставляет устано
- **Резервные копии.** Копии делает сервер своими средствами, и приложение о них
ничего не знает. Не решено, хватит ли копировать каталог данных файлами, или
приложению нужна команда выгрузки: база под нагрузкой копируется файлом не
всегда целой. Своё копирование по расписанию у PocketBase есть — берём мы его
или нет, тоже не решено.
всегда целой. Готового копирования по расписанию у сервиса нет вовсе: оно
ушло вместе со встроенным хранилищем, и заводить своё пока не решено.
- **Формат для распознавания.** Конвертер отдаёт ogg/vorbis (`libvorbis`), а
SpeechKit получает `ContainerAudio_OGG_OPUS`. Расхождение не разобрано: то ли
сервис определяет содержимое сам, то ли часть записей теряется на этом.
@@ -374,12 +383,15 @@ DNS-сервер, молчащий на `AAAA`, оставляет устано
паузы, а не замер
([research/job-queue.md](research/job-queue.md), «Как снималось»), — при
нагрузке в единицы записей в день, и во что это обходится, никто не мерил.
Хранилище при этом сменилось задачей `storage-without-pocketbase` 2026-08-22
([ADR](adr/ADR-2026-08-22-storage-without-pocketbase.md)), а модель очереди
пережила смену: отменилось одно слово — таблица перестала быть коллекцией.
- **Наблюдаемость.** `/metrics` остаётся и развивается. Чем — дописывать
счётчики через `client_golang` или перейти на OpenTelemetry с трассировкой —
решает разведка `opentelemetry-fit`. Коллектор был бы процессом, которого в
выкладке сегодня нет.
- **Выводы из текста.** Литературный текст, заголовок, темы и пересказ решено
считать внешним сервисом с OpenAI-совместимым интерфейсом за шлюзом bifrost.
Появляется пятая внешняя зависимость, платная, и текст расшифровки начинает
Появляется ещё одна внешняя зависимость, платная, и текст расшифровки начинает
уходить ещё на одну сторону — сдвиг периметра [security.md](security.md). Не
решено, отдельный это шаг конвейера или продолжение шага распознавания.
+8 -5
View File
@@ -5,8 +5,8 @@
**Взято из проекта jellybit.** Расхождения с сегодняшним кодом названы по месту.
Главные: комментариями снабжена половина полей; единого места проверки на старте
нет: у секций `[auth]` и `[pipeline]` свой `Validate()` в точке входа, а пустые
ключи `[yandex]` ловит конструктор распознавателя.
нет: у секций `[auth]`, `[pipeline]` и `[storage]` свой `Validate()` в точке
входа, а пустые ключи `[yandex]` ловит конструктор распознавателя.
**Механизировано:** запрет `os.Getenv``forbidigo` в `.golangci.yml`
([go-linters.md](go-linters.md), «Механизировано»). Он держит правило «настройки
@@ -137,9 +137,12 @@ TOML. Пустые ключи Yandex ловятся в конструкторе
Два ключа секции `[telegram]`, стоявшие здесь исключением, ушли вместе с самим
входом 2026-08-14: секции больше нет, и своей проверки у неё тоже.
Секция `[auth]` — первая, у которой проверка своя и стоит на старте:
`AuthConfig.Validate()` зовётся из `cmd/transcriber` сразу после загрузки и роняет
процесс с именем незаполненного ключа. Причина в цене умолчания: поднявшись с
Секции `[auth]`, `[pipeline]` и `[storage]` проверяют себя сами, и проверка стоит
на старте: `Validate()` каждой зовётся из `cmd/transcriber` сразу после загрузки
и роняет процесс с именем незаполненного ключа. У `[storage]` это ожидание занятой
базы и число соединений читающего пула: ноль у первого отдаёт «база занята»
первому же воркеру, ноль у второго означает пул без предела — то есть настройку,
которой не управляют. Причина в цене умолчания: поднявшись с
пустым перечнем доверенных адресов, сервис не узнавал бы никого, а узнать об
этом было бы неоткуда — все адреса приложения просто отвечали бы отказом.
Сообщение называет **имя ключа**; правило «значения в отказ не идут» остаётся в
+29 -32
View File
@@ -2,11 +2,11 @@
Как мы устраиваем таблицы и ключи. Актуальная схема — [../database.md](../database.md).
**Взято из проекта jellybit целиком.** Сегодняшний код transcriber следует
этому частью: ключи — UUID v4, а не ULID, и единой точки их генерации нет. Время
единой точкой читается с 2026-08-13 — `internal/clock`, метка в UTC, — и правило
держит линтер. Правила действуют на новый код; переписывание существующего —
отдельная работа, и до неё расхождение читается как долг, а не как нарушение.
**Взято из проекта jellybit целиком.** Сегодняшний код transcriber следует этому
целиком: ключи — ULID в нижнем регистре, выдаёт их единая точка `internal/ident`
(с 2026-08-22, задача `storage-without-pocketbase`), время читает единая точка
`internal/clock` (с 2026-08-13), и правило времени держит линтер. Расхождений у
записи не осталось.
**Механизировано:** сверка изменённого шага схемы с
[../database.md](../database.md) (`docs.py check`), чтение времени единой точкой
@@ -17,17 +17,17 @@
## Первичные ключи — ULID, не автоинкремент
- **PK сущности — TEXT ULID** (26 символов Crockford base32), генерируется
**приложением** в момент создания записи.
*Расхождение:* идентификаторы записей выдаёт хранилище — 15 знаков
собственного алфавита. Своей точки генерации у приложения нет, и `ORDER BY id`
хронологией не является: порядок берут по колонке времени с ключом.
**приложением** в момент создания записи. Выдача монотонна внутри одной
миллисекунды: колонка времени несёт секунды, и порядок записей одной секунды
задаёт ключ. Порядок ленты берут парой «время заведения и ключ» — одного
времени мало.
- Почему ULID: сортируем по времени создания (`ORDER BY id` = хронология),
компактен и удобен в URL и логах (без дефисов — grep и двойной клик берут id
целиком), глобально уникален между таблицами — поиск по голому id находит все
записи сущности в логах.
- **Точка генерации и разбора одна**: создание — при вставке записи в
репозитории, разбор — на входных границах. Самодельных генераторов по месту
вызова не заводим.
- **Точка генерации и разбора одна**`internal/ident`: `New` выдаёт, `Parse`
разбирает пришедшее снаружи. Самодельных генераторов по месту вызова не
заводим.
## Канонический вид — lowercase
@@ -47,31 +47,28 @@
## Прочее
- Enum-поля (`state`, `source`, …) — обычный `TEXT` без `CHECK`; допустимые
значения держит код.
*Расхождение:* перечни, по которым панель владельца правит запись руками,
закрыты схемой (`SelectField`), а не кодом: правка руками не должна заводить
значение, которого сервис не знает. Закрыты рубеж записи, причина её
остановки, вид текста, источник и исход события журнала. Цена названа: новое
значение любого из них потребует нового шага схемы, а применённый шаг не
переписывается. Прочие перечни остаются обычным `TEXT`.
- Enum-поля (`state`, `halt_reason`, …) — обычный `TEXT` без `CHECK`; допустимые
значения держит код. Прежде часть перечней закрывала схема — правку руками вела
панель владельца, и она вправе была завести значение, которого сервис не
знает. Панели нет с 2026-08-22, правка идёт только нашим кодом, и закрытый
перечень в схеме остался бы ценой — новое значение стоило бы нового шага — без
покупателя.
- Временные метки — `TEXT` в **RFC 3339, UTC (суффикс `Z`)**, например
`2006-01-02T15:04:05Z` (секундная точность). Фиксированная ширина сохраняет
лексикографическую сортировку TEXT, то есть хронологию (`ORDER BY created_at`).
Единая точка генерации — приложение, а не умолчание в схеме: так забытая
вставка падает громко. Измерение длительности — не метка времени.
*Расхождение:* вид времени задаёт хранилище — `2006-01-02 15:04:05.000Z`,
пробел вместо `T` и доли секунды ([../database.md](../database.md), «Время»).
Правило RFC 3339 действует на то, что пишем мы сами мимо хранилища; вид
хранилища не меняем — сравнение строк в сыром запросе побайтово, и
разошедшийся вид молча обращает условие срока захвата в константу.
- Миграции — шаги PocketBase на Go
(`internal/adapter/repo/pocketbase/migrations`, файл на шаг): коллекции и их
поля заводятся кодом. При изменении структуры обновляем схему
[../database.md](../database.md) тем же изменением.
- Время в **сыром запросе** кладётся и сравнивается тем же видом, каким
хранилище пишет свои `created`/`updated`. Сравнение строк побайтово, и
разошедшийся вид обращает условие в постоянную истину или ложь — молча.
Умолчаний вида `CURRENT_TIMESTAMP` в схеме нет ни у одной колонки, и вид один
на все — включая те, что пишет только сам сервис: своего типа времени у SQLite
нет, а колонка, заполненная то одним видом, то другим, молча обращает условие
срока захвата в константу.
- Миграции — шаги `pressly/goose/v3` на Go
(`internal/adapter/repo/sqlite/migrations`, файл на шаг, версия — число в
начале имени): таблицы, их колонки и индексы заводятся кодом. При изменении
структуры обновляем схему [../database.md](../database.md) тем же изменением.
- Время в запросе кладётся и сравнивается тем же видом, каким оно лежит в
колонке. Сравнение строк побайтово, и разошедшийся вид обращает условие в
постоянную истину или ложь — молча.
- Выборка «следующей» записи с `LIMIT 1` дополняется ключом в `ORDER BY`:
сравнение по неуникальному значению делает порядок обработки
невоспроизводимым.
+23 -16
View File
@@ -100,15 +100,19 @@ transcriber — **приложение, а не библиотека**: внеш
| Доменная ошибка | Статус | `error_code` | Сообщение |
| --- | --- | --- | --- |
| сессии нет | 401 | `unauthorized` | «требуется вход» |
| предъявитель узнан, учётной записи пользователя нет | 403 | `forbidden` | «у вашей сессии нет учётной записи» |
| пришедший не узнан | 401 | `unauthorized` | «сервис вас не узнал» |
| запись не найдена, чужая либо ничья | 404 | `not_found` | «запись не найдена» |
| файл не приложен, формат не распознан, негодное значение параметра | 400 | `bad_request` | «некорректный ввод» |
| файл не приложен, формат не распознан, негодное значение параметра, негодный диапазон | 400 | `bad_request` | «некорректный ввод» |
| запись сверх потолка размера | 413 | `too_large` | «запись больше допустимого размера», плюс предел числом |
| запросов слишком много подряд | 429 | `too_many_requests` | «слишком много запросов подряд, попробуйте позже» |
| текста запрошенного вида ещё нет | 409 | `not_ready` | «действие недоступно в текущем состоянии» |
| текста или копии файла запрошенного вида ещё нет | 409 | `not_ready` | «действие недоступно в текущем состоянии» |
| прочее | 500 | `internal` | «внутренняя ошибка» |
Ветвь `403`/`forbidden` ушла отсюда 2026-08-22 вместе со своим единственным
случаем: им был владелец панели, предъявивший собственный токен хранилища.
Ни панели, ни токенов у сервиса не осталось, а узнавание по заголовку
учётную запись заводит само.
Новую штатную ветвь отказа заводим sentinel'ом и добавляем сюда — иначе
ветвь по умолчанию отдаст 500 «внутренняя ошибка» на обычный конфликт, а
логирующая граница спишет его в `ERROR` вместо `DEBUG`.
@@ -124,10 +128,13 @@ transcriber — **приложение, а не библиотека**: внеш
`json-api-for-spa` 2026-08-15.
**Часть отказов рождается не в обработчике** — предел тела, ограничитель
частоты, неизвестный путь под корнем приложения — и до этой точки не доходит
вовсе. Их приводит к той же форме слой `OneErrorForm`, стоящий снаружи всех
прочих. Без него формы отказа было бы две, и отказ у человека на мобильной сети
приходил бы телом библиотеки.
частоты, неизвестный путь под корнем приложения, негодный диапазон в запросе
файла — и до этой точки не доходит вовсе. С 2026-08-22 отдельного слоя
перевода им не нужно: маршрутизатор и слои написаны нами, и каждый из них
отвечает **своей доменной ошибкой** через ту же точку. Прежде их приводил к
общей форме слой `OneErrorForm`, стоявший снаружи всех прочих и переводивший
тело чужой библиотеки; библиотеки не осталось, и второй формы отказа взяться
неоткуда.
### Разовый ответ и сохранённая диагностика
@@ -135,7 +142,7 @@ transcriber — **приложение, а не библиотека**: внеш
- **Разовый ответ на действие** (тело HTTP-ответа) — строго нейтральный: отображение выше, `err.Error()` наружу не идёт,
полная ошибка остаётся в логах по идентификатору задачи.
- **Сохранённая диагностика состояния** — колонка `error_text` задачи. Это
- **Сохранённая диагностика состояния** — колонка `error_text` аудиозаписи. Это
**поверхность владельца**, а не пользователя: сюда сырой текст ошибки допустим
и полезен. Но:
- **секреты запрещены** — токены, ключи, пароли, заголовок
@@ -146,8 +153,8 @@ transcriber — **приложение, а не библиотека**: внеш
числом рядом**: без этого непонятно, насколько сокращать.
*Расхождение:* `error_text` пишется целиком, без вычистки и без усечения.
Наружу он при этом не выходит: опрос готовности отдаёт признак остановки без
машинного текста — эту часть правила держит спека `intake`.
Наружу он при этом не выходит: карточка записи отдаёт причину остановки без
машинного текста — эту часть правила держит спека `archive`.
## panic
@@ -156,11 +163,11 @@ transcriber — **приложение, а не библиотека**: внеш
- Не для управления потоком и не для ожидаемых ошибок (нет сети, плохой ввод) —
это значения `error`.
- `recover` — на верхней границе обработчика, чтобы один паникующий запрос не
ронял процесс. В transcriber его вешает роутер хранилища сам
(`apis.panicRecover`, слой с идентификатором `DefaultPanicRecoverMiddlewareId`
на каждом роутере PocketBase): паникующий обработчик отдаёт `500`, процесс
живёт. Своего слоя мы не пишем. У воркеров такой границы **нет**: паника в
шаге конвейера роняет процесс целиком.
ронял процесс. В transcriber его ставит свой слой `http.Recover`: паникующий
обработчик отдаёт `500` нашей формой тела, а строка о панике идёт в журнал
владельца. Слой стал своим 2026-08-22 вместе с роутером — прежде его вешала
чужая библиотека. У воркеров такой границы **нет**: паника в шаге конвейера
роняет процесс целиком.
## Несколько ошибок
+9 -9
View File
@@ -78,10 +78,11 @@
| --- | --- |
| Сравнение ошибок через `errors.Is` и `errors.As`, не `==` и не приведением типа | `.golangci.yml``errorlint` |
| Ошибка не узнаётся сравнением текста сообщения (`strings.Contains(err.Error(), …)`, `err.Error() == …`) | `internal/archrules``TestОшибкаНеУзнаётсяПоТексту` |
| Непроверенное возвращаемое значение ошибки | `.golangci.yml``errcheck`, включая присваивание в `_` (`check-blank`). Отказ, который решено не проверять, объявляют в `exclude-functions` поимённо — там сегодня `defer Close` и `os.Remove` |
| Непроверенное возвращаемое значение ошибки | `.golangci.yml``errcheck`, включая присваивание в `_` (`check-blank`). Отказ, который решено не проверять, объявляют в `exclude-functions` поимённо — там сегодня `defer Close`, `os.Remove`, отложенные `(*sql.Rows).Close` и `(*sql.Tx).Rollback` и запись тела ответа (`json.Encoder.Encode`, `http.ResponseWriter.Write`) |
| Непроверенное приведение типа (`v := x.(T)`) | `.golangci.yml``errcheck` с `check-type-assertions`. Отдельная настройка, потому что такое приведение паникует, а не возвращает ошибку, и `check-blank` его не видит |
| Проверенный отказ не оборачивается в `return nil` | `.golangci.yml``nilerr`. Механизирует половину инварианта «принятая запись не теряется молча»: молчаливый успех после отказа |
| Отказ выборки из хранилища не теряется (`rows.Err()`), а сама выборка закрывается | `.golangci.yml``rowserrcheck`, `sqlclosecheck`. **Профилактические: предмета в коде сегодня нет** — выборки идут через `dbx` хранилища, а из `database/sql` употребляются только `sql.NullString` и `sql.ErrNoRows`. Правила заведены на будущий сырой запрос; мутацией проверены на пробе, а не на своём коде |
| Отказ выборки из базы не теряется (`rows.Err()`), а сама выборка закрывается | `.golangci.yml``rowserrcheck`, `sqlclosecheck`. Предмет у правил появился 2026-08-22: выборки идут своим `database/sql`, и обе ветви ловятся на живом коде |
| Обращение к базе идёт с контекстом (`ExecContext`, `QueryContext`, `BeginTx`) | `.golangci.yml``noctx`. Контекст у репозиториев свой — почему, названо в [../database.md](../database.md), «Представление данных» |
| Ошибки — только stdlib, без сторонних пакетов | `.golangci.yml``depguard` |
### Структура и границы
@@ -90,8 +91,9 @@
| --- | --- |
| Ядро (`internal/service`) не знает ни адаптеров, ни транспортов | `internal/archrules``TestЯдроНеЗнаетОбАдаптерах`, `TestЯдроНеЗнаетОТранспортах` |
| Транспорты (`controller/http`, `controller/worker`) не знают друг о друге | `internal/archrules``TestТранспортыНеЗнаютДругОДруге` |
| Транспорты не знают адаптеров | `internal/archrules``TestТранспортыНеЗнаютАдаптеров`. Правило заведено 2026-08-22: изъятие, разрешавшее транспорту знать адаптер хранилища, снято вместе с предметом |
| Адаптер не знает ни ядра, ни транспортов | `internal/archrules``TestАдаптерыНеЗнаютНиЯдра_НиТранспортов` |
| Колонки записи согласованы: что пишет отображение ↔ что читает обратное ↔ что заводит шаг схемы | `internal/archrules` → правила о колонках. Закрывает инвариант «колонки записи правятся в двух местах» (CLAUDE.md, major), которого компилятор не держит. Литерал колонки ищется в телах нужных функций, а не в файле целиком |
| Колонки записи согласованы: что пишет отображение ↔ что спрошено чтением ↔ что доезжает до сущности ↔ что заводит шаг схемы | `internal/archrules` → правила о колонках (`TestКолонкиЗаписиПишутсяИЧитаются`, `TestПрочитанныеКолонкиДоезжаютДоСущности`, `TestКолонкиЗаписиЗаведеныШагомСхемы`). Закрывает инвариант «колонки записи правятся в трёх местах» (CLAUDE.md, major), которого компилятор не держит. Имя колонки ищется в телах нужных функций, а не в файле целиком |
| Рубежи согласованы: дескриптор ↔ таблица выбора шага, в обе стороны | `internal/archrules` → правила о рубежах. Закрывает инвариант «рубеж объявляется одним дескриптором» (CLAUDE.md, major). Рубеж без шага останавливает запись, не начав работы; шаг без рубежа недостижим — захват такую запись не выдаст никогда |
### Отмена и внешний собеседник
@@ -138,7 +140,7 @@
| Правило | Где механизировано |
| --- | --- |
| Применённый шаг схемы не переписывается: у файла шага допустим один статус — `A` | `Taskfile.yml` → шаг `migrations`. Закрывает инвариант CLAUDE.md (critical), которого не держит ни компилятор, ни хранилище: применённое считается по имени файла. Баз диффа две — `BASE` и `HEAD`: первая отвечает на «шаг уже уехал» ровно настолько, насколько свежа `origin/master`, вторая ловит правку закоммиченного шага независимо от неё. Каталог берётся из ключа `migrations` секции `[docs]` в `.av-dev.toml`, чтобы у факта не было второго дома. Исходы шага и их коды — [CLAUDE.md](../../CLAUDE.md), «Гейт». `migrations.go` под правило не подпадает: строка `Register` нового шага прибавляется именно там |
| Применённый шаг схемы не переписывается: у файла шага допустим один статус — `A` | `Taskfile.yml` → шаг `migrations`. Закрывает инвариант CLAUDE.md (critical), которого не держит ни компилятор, ни база: применённое считается своей таблицей учёта. Баз диффа две — `BASE` и `HEAD`: первая отвечает на «шаг уже уехал» ровно настолько, насколько свежа `origin/master`, вторая ловит правку закоммиченного шага независимо от неё. Каталог берётся из ключа `migrations` секции `[docs]` в `.av-dev.toml`, чтобы у факта не было второго дома. Исходы шага и их коды — [CLAUDE.md](../../CLAUDE.md), «Гейт». `migrations.go` под правило не подпадает: строка `Register` нового шага прибавляется именно там |
| Раскладка документов, битые ссылки, изменённый шаг схемы без правки `database.md` | `docs.py check`; каталог шагов задаёт ключ `migrations` секции `[docs]` в `.av-dev.toml` |
| Согласованность каталога задач, форма `openspec/config.yaml` | `tasks.py check`, `openspec.py check` |
| Секреты в коммите | `lefthook.yml``gitleaks git --staged` |
@@ -188,11 +190,9 @@
ещё никуда не уехал. Отсюда следствие: при отставшей `origin/master` правило
молчит на всём каталоге, и на подозрении база задаётся руками
(`task migrations BASE=<rev>`);
- направление «транспорт не знает адаптера»: сегодня оно нарушено осознанно —
`controller/http` импортирует адаптер хранилища, потому что HTTP-поверхность и
есть роутер этого хранилища. Изъятие названо в
[../architecture.md](../architecture.md), «Принципы», и правила на это направление
нет.
- чистота домена: правила смотрят ядро, входы и адаптеры, а импорт внешней
библиотеки в `internal/entity` сегодня пройдёт молча. Названо в
[../architecture.md](../architecture.md), «Слои и модель домена».
Отдельно названы **правила, чей подъём отклонён**:
+9 -10
View File
@@ -105,12 +105,12 @@ stdlib-логом в поток ошибок. Это выбор, а не дол
| Когда добавляем | Поля |
| --- | --- |
| на входящий HTTP-запрос | `transport` (`http`), `http.method`, `http.route`, `http.status_code`, `duration_ms` |
| на входящий HTTP-запрос | `transport` (`http`), `http.method`, `http.route`, `http.status_code`, `duration_ms`, `http.path_length`. **Запрошенного пути в строке нет ни под каким корнем**: его выбирает спрашивающий, и дословная запись сделала бы журнал местом, куда аноним пишет свой текст. В `http.route` идёт маршрут из закрытого перечня — точный адрес наблюдения либо образец адреса приложения, — а всё прочее обозначается одним общим значением |
| на узнавание пришедшего | `http.peer_addr` — адрес того, кто открыл соединение; плюс `account_id` на заведении учётной записи. **Значения заголовка в строке нет**: им довольно назваться, чтобы стать этим человеком, а с недоверенного адреса его пишет аноним |
| на задачу | `capability` (значения — по именам заведённых capability в `openspec/specs/`), `record_id`, `file_id`, `source` |
| на запись об ошибке | `error` |
| на вызов внешнего сервиса | `ext.service`, `ext.operation`, `ext.status_code`, `duration_ms`, `retry` |
| на запрос, отданный приложению | `webapp.outcome` (`markup`, `asset`, `failure` — перечень закрыт), `http.path_length`. Самого пути в строке нет: его выбирает спрашивающий, и дословная запись сделала бы журнал местом, куда аноним пишет свой текст. Вместо пути в `http.route` стоит `<приложение>` |
| на запрос, отданный приложению | `webapp.outcome` (`markup`, `asset`, `failure` — перечень закрыт). Правило о пути строкой выше, общее: вместо пути в `http.route` стоит `<приложение>` |
| на подъёме сервиса | `webapp.build` — отпечаток вшитой сборки; им «не та сборка» отличается от «той» |
Не заводим `service.*` и `host.*` — для одного бинарника на одном хосте это шум.
@@ -205,7 +205,7 @@ Object Storage и опрос операции не логируются ника
## HTTP и проверка здоровья
- Входящие HTTP-запросы логируем с полями `http.method`, `http.route`,
`http.status_code`, `duration_ms`, `transport`.
`http.status_code`, `duration_ms`, `http.path_length`, `transport`.
- **Поле, которое уже даёт логгер с подставленным ключом, руками не
доклеиваем.** Иначе в JSON получается дублирующийся ключ, и строгий
потребитель молча оставит одно из значений. Правило проверяется чтением,
@@ -214,13 +214,12 @@ Object Storage и опрос операции не логируются ника
периодически, на `INFO` они забивают разбор шумом. В продакшене при базовом
`INFO` они не пишутся.
Расхождения здесь больше нет: слой журналирования запросов свой,
`cmd/transcriber`, хук `OnServe` — вместе с gin ушёл и `sloggin`. `/health` и `/metrics`
идут на `DEBUG`, то есть при боевом `INFO` не пишутся вовсе.
Расхождения здесь больше нет: слой журналирования запросов свой
`internal/controller/http`, `journal.go`. `/health` и `/metrics` идут на `DEBUG`,
то есть при боевом `INFO` не пишутся вовсе.
Хранилище ведёт **свой** журнал запросов в собственной таблице, и он виден
владельцу в панели. Заменой потоку процесса он не служит: в журнал контейнера,
по которому разбирают отказы, эта таблица не попадает.
**Журнал у сервиса один.** Второй, куда встроенное хранилище клало путь целиком
вместе с адресом отправителя, ушёл вместе с самим хранилищем 2026-08-22.
## Безопасность: что не логируем
@@ -257,7 +256,7 @@ Object Storage и опрос операции не логируются ника
(`filepath.Ext`), поэтому имя `запись.тайное-слово` отдаёт приватный хвост
расширением. В журнал оно идёт **собственным полем** строки приёма — это
объявленное изъятие инварианта приватности ([CLAUDE.md](../../CLAUDE.md),
«Инварианты»); ни имени файла в хранилище, ни пути к нему в журнале нет вовсе
«Инварианты»); ни имени файла на диске, ни пути к нему в журнале нет вовсе
(норма — `openspec/specs/intake`). Наружу — в метку метрики — хвост не выходит:
там расширение приводится к перечню известных форматов. Остаток описан в
[../security.md](../security.md).
+10 -9
View File
@@ -74,15 +74,16 @@
[webapp](../../openspec/specs/webapp/spec.md).
- **Адреса обычные, а не после решётки** (`createWebHistory`). Отсюда требование
к серверу: неизвестный путь **вне корней сервиса** отдаёт `index.html`, а не
`404`; путь внутри корня в приложение не проваливается никогда. Корней
сегодня три — `/api/` у хранилища, `/app/` у приложения, `/_/` у панели, —
плюс `/health` и `/metrics` отдельными адресами. Корень `/auth/` снят
2026-08-22 вместе с собственным входом, и пути под ним стали обычными путями
вне корней. Приложение
уехало из общего `/api/` решением владельца 2026-08-15: пространство
принадлежит хранилищу, и обновление библиотеки вправе занять там имя рядом с
нашим. Перечень корней сервису не описывают, а из него **порождают**
регистрацию маршрутов: описанный порознь, он разошёлся бы с ними молча.
`404`; путь внутри корня в приложение не проваливается никогда. Корень
сегодня **один**`/app/` у приложения, — плюс `/health` и `/metrics`
отдельными адресами. Корни `/auth/`, `/api/` и `/_/` сняты 2026-08-22: первый
ушёл с собственным входом, два других — со встроенным хранилищем и его
панелью, и пути под ними стали обычными путями вне корней. Приложение уехало
из общего `/api/` решением владельца 2026-08-15, и корень свой сохранило:
соседа, ради которого выбирался, больше нет, а формы запросов и ответов от
смены хранилища не изменились ни одним полем. Перечень корней сервису не
описывают, а из него **порождают** регистрацию маршрутов: описанный порознь,
он разошёлся бы с ними молча.
- **Несовпавший ресурс разметкой не подменяется.** Путь под каталогом сборщика,
которому не нашлось файла, отвечает `404`. Правило — вторая половина
предыдущего: разметка прежней сборки называет ресурсы прежней сборки, и
+276 -217
View File
@@ -1,62 +1,118 @@
# Схема хранилища
Хранилище, коллекции, правило времени и идентификаторов.
База, таблицы, раскладка файлов, правило времени и идентификаторов.
Хранилище **встроенная PocketBase 0.39.10**: она держит и базу, и файлы
записей под одним каталогом данных. Ключ конфигурации — `[storage] data_dir`,
умолчание `data`. В SQLite библиотека ходит через `modernc.org/sqlite`, поэтому
CGO сборке не нужен.
Хранилище **своё**: база SQLite через `modernc.org/sqlite` (CGO сборке не нужен)
и файлы записей своим каталогом рядом с ней. Ключ конфигурации один
`[storage] data_dir`, умолчание `data`. Встроенная PocketBase, державшая до
2026-08-22 и базу, и файлы, и панель, и маршрутизатор, ушла из проекта целиком —
задача `storage-without-pocketbase`,
[ADR](adr/ADR-2026-08-22-storage-without-pocketbase.md).
Схему двигают **шаги миграций PocketBase** на Go, каталог
`internal/adapter/repo/pocketbase/migrations`, файл на шаг и имя файла — имя
шага. Шаг регистрируется при загрузке пакета, а накатывается при подъёме
хранилища (`pocketbase.New`), прежде чем стартуют воркеры и сервер. Применённый
шаг не переписывается — изменение только новым шагом: применённое хранилище
считает по имени шага.
**База принимает одного писателя.** Пишущий пул держит одно соединение — драйвер
пишет единственным, и несколько воркеров, пришедших писать разом мимо этого
правила, получают отказ по занятости на записи результата шага, то есть после
оплаченной работы. Чтение идёт отдельным пулом: в журнале упреждающей записи
читатели не мешают писателю.
Журнал упреждающей записи, соблюдение внешних ключей и ожидание занятой базы
задаются **строкой подключения обоих пулов**, а не запросом после открытия: две
из трёх настроек в SQLite принадлежат соединению, а не базе, а пул заводит новые
соединения по мере надобности — запрос настроил бы одно из многих. Операция,
которая читает и следом пишет, идёт целиком по пишущему соединению: читающую
транзакцию SQLite до пишущей не повышает и отказывает по занятости немедленно.
Схему двигают **шаги `github.com/pressly/goose/v3`** — библиотекой, а не
командной строкой. Каталог `internal/adapter/repo/sqlite/migrations`, файл на
шаг, версия шага — число в начале имени файла. Перечень шагов приходит
провайдеру доводом, провайдер заводится в точке входа и получает пишущий пул,
накат идёт **до подъёма входов и до старта воркеров**, а отказ шага роняет старт.
Применённый шаг не переписывается — изменение только новым шагом.
Шаг и отметка о нём идут одной транзакцией: библиотека открывает её на том же
соединении. Порядок шагов детерминирован и выводится из версии, а не из порядка
чтения каталога; две одинаковых версии дают отказ сбора.
**Исключающую блокировку наката держим сами.** Библиотека под SQLite её не
поставляет вовсе — её запиратели объявлены только для PostgreSQL, а провайдер без
запирателя накатывает без всякой блокировки. Замок берётся на файле
`data/migrate.lock` (`syscall.Flock`, `LOCK_EX`) и снимается закрытием
дескриптора; с умершим процессом его снимает ядро, поэтому просроченного замка,
который надо чистить руками, не остаётся.
Каталог у шагов свой, а не файл внутри пакета репозитория, и причина внешняя:
шаг гейта сверяет изменённые шаги схемы с правкой этого документа по **префиксу
пути**, а префикс наводится только на каталог. Где этот префикс задан —
[conventions/go-linters.md](conventions/go-linters.md), «Механизировано». Имена коллекций живут там же, рядом с шагом, который их заводит; пакет
репозитория берёт их оттуда.
[conventions/go-linters.md](conventions/go-linters.md), «Механизировано».
**Идентификаторы** записей выдаёт хранилище — 15 знаков собственного алфавита.
Свои UUID остались только в **именах файлов**: имя, под которым запись ложится в
хранилище, задаёт сервис, и это `<uuid><расширение>`.
**Идентификаторы** — ULID в нижнем регистре, `TEXT`, 26 знаков алфавита
Crockford. Выдаёт их приложение единой точкой `internal/ident`; внутри одной
миллисекунды выдача монотонна, потому что колонка времени несёт секунды и
порядок записей одной секунды задаёт ключ. Идентификатор, пришедший снаружи,
разбирается на границе: разбор проверяет вид и приводит регистр, а негодный
считается несуществующей записью и до базы не доходит.
**Время** — вид хранилища: строка `2006-01-02 15:04:05.000Z` в UTC. Колонки
`created` и `updated` проставляет само хранилище; те же поля в сыром запросе
захвата кладёт наш код — **тем же видом**, потому что сравнение строк в SQLite
побайтово, и разошедшийся вид обратил бы условие срока в постоянную истину или
постоянную ложь молча.
Тем же идентификатором зовётся **подкаталог записи** в каталоге данных, а имя
файла внутри него — `<ULID><расширение>`.
Того, что единой точки генерации идентификатора и времени нет, здесь не
повторяем: перечень единых точек и их отсутствий держит
[architecture.md](architecture.md), «Единые точки проекта».
**Время**`TEXT` в RFC 3339, UTC, суффикс `Z`, секундная точность:
`2006-01-02T15:04:05Z`. Ширина записи постоянная, поэтому лексикографический
порядок совпадает с хронологией. Вид один на **все** колонки времени, включая
те, что пишет только сам сервис: своего типа времени у SQLite нет, колонка
хранит то, что в неё положили, и колонка, заполненная то одним видом, то другим,
обратила бы условие срока протухания захвата в постоянную истину или ложь молча.
## Коллекции
Время ставит приложение единой точкой `internal/clock`. **Умолчаний вида
`CURRENT_TIMESTAMP` в схеме нет**: умолчание писало бы свой вид времени, а
вставка, забывшая проставить время, при нём прошла бы молча.
## Таблицы
**Перечни значений держит код, а не схема.** Прежде рубеж, причина остановки и
вид текста были закрыты `CHECK`-подобным типом хранилища, потому что панель
владельца правила запись руками и вправе была завести значение, которого сервис
не знает. Панели нет, правка идёт только нашим кодом, и закрытый перечень в схеме
остался бы ценой — новое значение стоило бы нового шага — без покупателя.
### `users`
| Поле | Тип | Что |
| --- | --- | --- |
| `id` | TEXT PK | ULID, выдаёт приложение |
| `provider_login` | TEXT, уникален | Логин человека **у провайдера**: то значение, которым его называет обратный прокси заголовком `Remote-User`. Ключ учётной записи |
| `name` | TEXT | Имя, пригодное к показу; берётся при заведении и вторым обращением не переписывается |
| `email` | TEXT | Адрес почты; необязателен |
| `created_at`, `updated_at` | TEXT | Время |
Уникальность почты держится **частичным** индексом (`WHERE email <> ''`), поэтому
записи без почты уживаются друг с другом. Уникальность логина — обычным.
Ключом почта не служит вовсе: адрес меняется, и первое обращение с чужим адресом
досталось бы чужой записи.
### `files`
Одна запись на одну физическую копию. Копий у аудиозаписи ровно две: принятая и
Одна строка на одну физическую копию. Копий у аудиозаписи ровно две: принятая и
приведённая к рабочему формату. Копия во внешнем хранилище файлом записи не
считается — она существует только потому, что провайдер распознавания читает
аудио по адресу, и её ключ живёт в строке попытки распознавания.
| Поле | Тип | Что |
| --- | --- | --- |
| `id` | TEXT PK | Идентификатор записи, выдаёт хранилище |
| `file` | file | Сам файл |
| `owner` | relation → `users` | Владелец файла; пустого значения не принимает |
| `location` | select | `local` или `s3` |
| `object_key` | TEXT | Ключ объекта; заведён прежним шагом и новым путём не заполняется |
| `size` | INTEGER | Размер в байтах |
| `id` | TEXT PK | ULID |
| `owner_id` | TEXT → `users(id)` | Владелец копии; пустого значения не принимает |
| `record_id` | TEXT | Запись, которой копия принадлежит: имя её подкаталога |
| `file_name` | TEXT | Имя файла в этом подкаталоге; задаёт сервис |
| `size_bytes` | INTEGER | Размер копии в байтах |
| `format` | TEXT | Расширение без точки, в нижнем регистре |
| `duration_ms` | INTEGER | Длительность, если её удалось прочитать |
| `created`, `updated` | DATETIME | Проставляет хранилище |
| `created_at` | TEXT | Время |
Поле названо `location`, а не `storage`: последним словом зовут само хранилище и
capability, и третий смысл развёл бы одно слово по разным вещам.
**Внешнего ключа на аудиозапись у `record_id` нет намеренно.** Приём заводит
файл **до** самой записи — подкаталог назван её идентификатором, и знать его надо
раньше, — и обязательная связь отвергала бы первую же принятую запись. Владелец
при этом лежит своей колонкой, а не выводится через запись: файл переживает свою
запись, и заведённый шагом до её сохранения остаётся с владельцем и без ссылки.
### `audio_records`
@@ -65,36 +121,37 @@ capability, и третий смысл развёл бы одно слово п
| Поле | Тип | Что |
| --- | --- | --- |
| `id` | TEXT PK | Идентификатор записи, выдаёт хранилище |
| `owner` | relation`users` | Владелец записи; пустого значения не принимает |
| `source` | select | `api`, `unknown`; значение `telegram` осталось историческим — вход убран, новых записей с ним не появляется |
| `id` | TEXT PK | ULID |
| `owner_id` | TEXT`users(id)` | Владелец записи; пустого значения не принимает |
| `title`, `brief` | TEXT | Заголовок и краткое описание: читаются вместе со списком |
| `original_filename` | TEXT ≤ 255 | Имя файла, данное отправителем; кладёт приём, обрезав по пределу и убрав управляющие знаки |
| `duration_ms` | INTEGER ≥ 0 | Длительность **принятого**, миллисекунды; ставит приём и всегда |
| `size_bytes` | INTEGER ≥ 0 | Размер **принятого**, байты |
| `state` | select | Рубеж: `uploaded`, `normalized`, `submitted`, `transcribed`, `done`; перечень закрыт схемой |
| `state_entered_at` | DATETIME | Время входа в рубеж — сторож застревания |
| `halted_at` | DATETIME | Признак остановки; рубеж при ней не стирается |
| `halt_reason` | select | `step_failed`, `attempts_exhausted`, `stuck` |
| `original_filename` | TEXT | Имя файла, данное отправителем; кладёт приём, обрезав по пределу и убрав управляющие знаки |
| `duration_ms` | INTEGER, обязателен | Длительность **принятого**, миллисекунды; ставит приём и всегда |
| `size_bytes` | INTEGER, обязателен | Размер **принятого**, байты |
| `state` | TEXT | Рубеж: `uploaded`, `normalized`, `submitted`, `transcribed`, `done` |
| `state_entered_at` | TEXT | Время входа в рубеж — сторож застревания |
| `halted_at` | TEXT | Признак остановки; рубеж при ней не стирается |
| `halt_reason` | TEXT | `step_failed`, `attempts_exhausted`, `stuck` |
| `error_text` | TEXT | Текст ошибки, машинный |
| `acquisition_id` | TEXT | Признак **этого** захвата, уникальный для каждого |
| `acquire_expires_at` | DATETIME | Срок протухания захвата; приезжает с рубежом |
| `delay_time` | DATETIME | Не брать запись раньше этого времени |
| `attempts` | INTEGER ≥ 0 | Число **отказов**: растёт при захвате, обнуляется на шаге без отказа и на откладывании |
| `original_file` | relation`files` | Принятая копия |
| `normalized_file` | relation`files` | Копия, приведённая к рабочему формату |
| `transcript_text`, `literary_text` | relation → `texts` | Тексты записи |
| `structure` | relation → `structures` | Структура реплик |
| `recognition` | relation → `recognitions` | Попытка распознавания |
| `topics` | relation → `topics`, до 5 | Темы записи |
| `tg_chat_id` | INTEGER | Адресат ответа у записи убранного входа; кодом не читается |
| `tg_reply_message_id` | INTEGER | Ответное сообщение у неё же; кодом не читается |
| `created`, `updated` | DATETIME | Проставляет хранилище |
| `acquire_expires_at` | TEXT | Срок протухания захвата; приезжает с рубежом |
| `delay_time` | TEXT | Не брать запись раньше этого времени |
| `attempts` | INTEGER | Число **отказов**: растёт при захвате, обнуляется на шаге без отказа и на откладывании |
| `original_file_id` | TEXT`files(id)` | Принятая копия |
| `normalized_file_id` | TEXT`files(id)` | Копия, приведённая к рабочему формату |
| `transcript_text_id`, `literary_text_id` | TEXT | Тексты записи |
| `structure_id` | TEXT | Структура реплик |
| `recognition_id` | TEXT | Попытка распознавания |
| `created_at`, `updated_at` | TEXT | Время |
Индексов три. Первый — по паре «рубеж и признак остановки»: по ним, паузе и
сроку протухания идёт выборка захвата. Два других завела страница списка приложения:
`(owner, created DESC, id DESC)` под страницу «новыми сверху» и
`(owner, state, halted_at)` под отбор тремя состояниями.
Индексов два. `idx_audio_records_acquire``(state, halted_at, created_at, id)`:
по нему идёт отбор захвата, и по нему же он берёт запись в определённом порядке.
`idx_audio_records_owner_page``(owner_id, created_at, id)`: под страницу
списка, сужаемую владельцем и режущуюся полным ключом сортировки.
Оба индекса заведены **начальным шагом**, а не отложены: применённый шаг схемы не
переписывается, и добавление индекса стоило бы отдельного шага. Проверено
`EXPLAIN QUERY PLAN`: ни отбор захвата, ни страница списка не показывают полного
сканирования таблицы.
**Ведущая колонка у ленты — владелец, и потому индекс захвата ей не помогает
ничем.** Замер на задаче `json-api-for-spa` 2026-08-15: без своего индекса
@@ -109,21 +166,20 @@ capability, и третий смысл развёл бы одно слово п
узнаёт свою запись, пока заголовка нет. Одной колонкой на оба смысла посчитанное
название затирало бы имя, и вернуть затёртое было бы неоткуда. Имя приходит
извне, поэтому приём режет его по пределу и убирает управляющие знаки; в имя
файла в хранилище и в журнал оно по-прежнему не идёт.
файла на диске и в журнал оно по-прежнему не идёт.
**Длительность и размер лежат и на записи, и на её файле, и равенство между ними
не поддерживается никем — намеренно.** На записи снимок **принятого**, взятый
приёмом один раз; на файле — величины нынешней копии файла.
Уточнение длительности меняет вторые и не трогает первые: это разные вопросы —
«что человек прислал» и «что лежит сейчас». Колонками записи они нужны потому,
что показываются в списке, а список читается без содержимого. Решение владельца
от 2026-08-15.
приёмом один раз; на файле — величины нынешней копии. Уточнение длительности
меняет вторые и не трогает первые: это разные вопросы — «что человек прислал» и
«что лежит сейчас». Колонками записи они нужны потому, что показываются в списке,
а список читается без содержимого. Решение владельца от 2026-08-15.
**«Неизвестно» эти колонки не выражают, и это решение владельца от 2026-08-15.**
Числовая колонка хранилища пустого значения не держит: пустое она кладёт нулём.
Платить за отличимость четвёртой колонкой-признаком или текстовым типом у чисел
не за что — обе величины ставит приём и ставит всегда, а запись с непрочитанными
метаданными отвергается отказом и не заводится вовсе.
**«Неизвестно» эти колонки не выражают**, и это то же решение владельца: обе
величины ставит приём и ставит всегда — запись с непрочитанными метаданными
отвергается отказом и не заводится вовсе. Обе объявлены обязательными: пустое
значение, которое схема теперь допустить может, завело бы третий смысл, которого
никто не читает.
**Ссылки на файлы две и порознь.** Прежняя модель держала одну и переставляла её
каждым шагом: у прошедшей конвейер записи она вела на копию во внешнем
@@ -131,20 +187,32 @@ capability, и третий смысл развёл бы одно слово п
**Остановка — признак, а не рубеж.** Прежние состояния `failed` и `dead`
схлопнуты в `halted_at` с причиной: обе восстанавливаются одинаково — снятием
признака, — и различие между ними перестало быть структурным. Рубеж при
остановке сохраняется, поэтому запись продолжает с места остановки.
признака, — и различие между ними перестало быть структурным.
**Сторожей двое.** `attempts` ограничивает повторы внутри шага,
`state_entered_at` — застревание. Прежде обе обязанности несло одно число, и не
справлялось ни с одной.
### `record_topics`
| Поле | Тип | Что |
| --- | --- | --- |
| `record_id` | TEXT → `audio_records(id)` | Запись |
| `topic_id` | TEXT → `topics(id)` | Тема |
Первичный ключ — пара целиком. Потолок в пять тем на запись держит **триггер**:
без него часовой разговор даёт два десятка тем, и словарь распухает за неделю.
Число берётся у домена — то же самое, которое сервис объявляет приложению.
### `texts`
| Поле | Тип | Что |
| --- | --- | --- |
| `record` | relation → `audio_records` | Чья это расшифровка |
| `kind` | select | `transcript` или `literary` |
| `contents` | editor | Сам текст |
| `id` | TEXT PK | ULID |
| `record_id` | TEXT → `audio_records(id)` | Чья это расшифровка |
| `kind` | TEXT | `transcript` или `literary` |
| `contents` | TEXT | Сам текст |
| `created_at`, `updated_at` | TEXT | Время |
Пара «запись и вид» уникальна: повтор прерванного шага не заводит второй строки.
Поле зовётся `kind`, а не `format`: словом `format` в этой же схеме зовут формат
@@ -154,9 +222,11 @@ capability, и третий смысл развёл бы одно слово п
| Поле | Тип | Что |
| --- | --- | --- |
| `record` | relation → `audio_records` | Чья это структура |
| `id` | TEXT PK | ULID |
| `record_id` | TEXT → `audio_records(id)` | Чья это структура |
| `version` | INTEGER | Версия вида разбора |
| `contents` | JSON | Реплики со временем |
| `contents` | TEXT | Реплики со временем, JSON |
| `created_at`, `updated_at` | TEXT | Время |
Пара «запись и версия разбора» уникальна. Номер версии нужен потому, что разбор
сохранённого ответа изменится раньше, чем архив пересчитают.
@@ -167,92 +237,86 @@ capability, и третий смысл развёл бы одно слово п
| Поле | Тип | Что |
| --- | --- | --- |
| `record` | relation → `audio_records` | Чья это попытка |
| `id` | TEXT PK | ULID |
| `record_id` | TEXT → `audio_records(id)` | Чья это попытка |
| `provider`, `model` | TEXT | Кем и какой моделью считано |
| `external_id` | TEXT | Идентификатор операции у провайдера |
| `source_uri` | TEXT | Адрес, по которому провайдер читает аудио |
| `payload` | file, **защищённое** | Сырой ответ провайдера целиком |
| `started_at`, `finished_at` | DATETIME | Границы операции |
| `payload_file` | TEXT | Имя файла с сохранённым ответом провайдера |
| `started_at`, `finished_at` | TEXT | Границы операции |
| `created_at`, `updated_at` | TEXT | Время |
**Сырой ответ лежит вложением, а не колонкой.** Шаг опроса читает эту строку раз
в несколько секунд, а хранилище читает запись целиком: ответ на многочасовую
запись ехал бы в память при каждом опросе. Хранится он потому, что результат
операции у провайдера не переспрашивается.
Поле вложения помечено защищённым: сырой ответ — это полный текст речи, и
умолчание библиотеки отдавало бы его по ссылке любому, кто её знает.
**Сохранённый ответ лежит третьим файлом в подкаталоге записи, а не колонкой.**
Шаг опроса читает эту строку раз в несколько секунд, а репозиторий читает строку
целиком: ответ на многочасовую запись, положенный колонкой, ехал бы в память при
каждом опросе. Хранится он потому, что результат операции у провайдера не
переспрашивается. Копией аудио он при этом не считается — их у записи по-прежнему
две, — и адреса, которым его читают снаружи, у сервиса нет вовсе.
### `record_events`
| Поле | Тип | Что |
| --- | --- | --- |
| `record` | relation → `audio_records` | Чьё это событие |
| `origin` | select | `pipeline` или `human` |
| `id` | TEXT PK | ULID |
| `record_id` | TEXT → `audio_records(id)` | Чьё это событие |
| `origin` | TEXT | `pipeline` или `human` |
| `step` | TEXT | Имя шага |
| `outcome` | select | `done`, `failed`, `halted`, `resumed` |
| `outcome` | TEXT | `done`, `failed`, `halted`, `resumed` |
| `outcome_text` | TEXT | Причина, если она есть |
| `duration_ms` | INTEGER | Сколько шаг занял |
| `created_at` | TEXT | Время |
Колонка текста зовётся `outcome_text`, а не `error_text`: последнее имя названо
поимённо инвариантом о секрете, и две колонки с этим именем сделали бы инвариант
двусмысленным.
Журнал пишется на смену рубежа, на остановку и на снятие остановки — не на
Журнал пишется на смену рубежа, на остановку и на возврат в работу — не на
каждое откладывание опроса. Ни один шаг конвейера его не читает, чтобы решить,
что делать дальше.
что делать дальше. Происхождение `human` пишет сегодня подкоманда оснастки,
возвращающая остановленную запись в работу: другого писателя, кроме конвейера, у
журнала не осталось.
### `topics`
| Поле | Тип | Что |
| --- | --- | --- |
| `owner` | relation → `users` | Чей это словарь |
| `id` | TEXT PK | ULID |
| `owner_id` | TEXT → `users(id)` | Чей это словарь |
| `name` | TEXT | Название темы |
| `created_at`, `updated_at` | TEXT | Время |
Пара «владелец и название» уникальна: словарь тем свой у каждого человека.
Коллекцией, а не набором строк в записи, потому что перечень тем нужен целиком
перед каждым обращением к языковой модели. Ни один шаг сегодняшнего сервиса тем
не пишет и не читает — место заведено вперёд, чтобы задача, считающая темы, не
платила вторым необратимым шагом схемы.
Отдельной таблицей, а не набором строк в записи, потому что перечень тем нужен
целиком перед каждым обращением к языковой модели. Ни один шаг сегодняшнего
сервиса тем не пишет и не читает — место заведено вперёд, чтобы задача,
считающая темы, не платила вторым необратимым шагом схемы.
### Чего в схеме больше нет
Коллекция `transcribe_jobs` удалена шагом `202608140002`. Данных под ней не было:
сервис на сервере остановлен, а прежние записи удалены решением владельца
2026-08-14 — переноса это изменение не делало. Оставленная пустая коллекция
висела бы в панели вторым домом для понятия, которого больше нет.
**Каталог шагов PocketBase удалён целиком, и на его месте стоит один шаг
начальной схемы** — `202608220002_init.go`. Это разовое снятие инварианта
«применённая миграция не переписывается», решением владельца от 2026-08-22:
стадия проекта — стройка, на сервере данных нет, сервис остановлен, а новая база
ведёт учёт применённого своей таблицей, которой отметки прежнего каталога не
годятся вовсе. Снятие кончается этим шагом.
**Владелец записи** заведён шагом `202608140001` — связью с коллекцией `users` в
обеих таблицах, — и шагом `202608140003` пустого значения больше не принимает.
Прежде принимал, и цену за это платили записи входа Telegram: связи чата с
учётной записью сервис не вёл. Вход убран 2026-08-14, ничью запись заводить стало
некому, и обязательность переехала из приёма в схему — туда, где её держит
хранилище, а не договорённость.
**Колонок `location` и `source` в новой схеме нет.** Обе писались одним значением
и не читались никем: в `location` уходило `local`, второго значения (`s3`) не
писал ни один шаг; в `source` всякий приём писал `api`, а второе значение
(`telegram`) держалось ссылкой из применённого шага, а не потребителем. Шаги
ушли, и держать их стало нечем. Поле, у которого появится читатель, вернётся
одним новым шагом схемы.
**Колонки `tg_chat_id` и `tg_reply_message_id`** остались от убранного входа и
кодом больше не читаются. Из схемы они не убираются: заводили их применённые
шаги `202608110001` и `202608140002`, а применённый шаг не переписывается.
**Колонок `tg_chat_id`, `tg_reply_message_id` и `object_key` нет по той же
причине:** их держал применённый шаг, которого больше не существует.
Выборка по владельцу сужает **чтение записи**: чужая, ничья и несуществующая
дают один и тот же отказ. Выборку воркера владелец не сужает — конвейер
обрабатывает записи всех. Тот же шаг сузил правило просмотра коллекции `files`
владельцем: прежнее правило пускало всякого вошедшего, и знание идентификатора
файловой записи равнялось праву скачать чужое аудио.
**Учётная запись с записями не удаляется.** Каскадное удаление у связи выключено,
но одного этого мало: при выключенном каскаде хранилище снимает ссылку и
сохраняет запись без проверок — записи остались бы, но стали бы ничьими, а ничья
запись не достаётся никому. Отказ ставит слой приложения `GuardOwnerDeletion`,
а не правило коллекции: панель ходит правами суперпользователя, и правило её не
судит. Считаются все коллекции с колонкой владельца — `audio_records`, `files` и
`topics`, — и перечень живёт одним местом: пропущенная коллекция пропускает
удаление вперёд, а наружу приезжает подсказка библиотеки про обязательную связь
вместо нашего отказа с причиной.
**Правила доступа новых коллекций пусты**, то есть перечислять и читать их может
только владелец панели. Содержимое записи отдаёт собственный адрес сервиса, а не
поверхность хранилища; непустое правило открыло бы перечисление коллекции впрок.
Проверено прогоном: анонимный запрос к `/api/collections/*/records` отвечает
`403`, к `/api/logs`, `/api/backups`, `/api/settings` и `/api/crons``401`.
**Учётная запись с записями не удаляется**, и держит это схема обязательной
связью, а не проверка вызывающего: `audio_records`, `files` и `topics` ссылаются
на `users(id)` без каскада, а соблюдение внешних ключей включено на каждом
соединении обоих пулов. Прежде запрет ставил слой приложения — сборка, забывшая
его позвать, теряла защиту молча, и теряла. Адреса, которым учётную запись
удаляют, у сервиса нет вовсе; способа удалить записи тоже нет, и это осознанный
тупик до задачи про удаление записи.
## Представление данных
@@ -260,70 +324,63 @@ capability, и третий смысл развёл бы одно слово п
- **Расшифровка лежит отдельной строкой `texts`**, а не колонкой записи. Захват
её не тянет вовсе: он возвращает **идентификатор и признак своего захвата**, а
колонки шаг читает отдельным чтением. Прежде расшифровка стояла колонкой той
же строки и читалась при каждом опросе очереди.
- **Аудио лежит в раскладке хранилища:**
`data/storage/<коллекция>/<запись>/<имя>` рядом с файлом атрибутов. Имя задаёт
сервис — `<uuid><расширение>`; собственного суффикса хранилище не дописывает,
потому что умолчание, строящее имя из имени отправителя, не применяется. Ни
файлы, ни объекты в Object Storage не удаляются после завершения задачи:
каталог и бакет растут неограниченно.
- **Файл отдаётся ссылкой** `/api/files/<коллекция>/<запись>/<имя>`. Поле файла
помечено защищённым шагом `202608120001`, а правило просмотра коллекции
пускает всякого вошедшего: пройти по ссылке можно только с коротким токеном
файла, который берёт узнанный. Прежнее решение — «право прочитать запись даёт
знание её идентификатора» — отменено задачей `oidc-login` 2026-08-12. Имя файла
в хранилище **в журнал не пишется** по-прежнему: оно последняя часть ссылки.
- **Коллекция `users`** заводится самой библиотекой, а два наших шага её сужают.
`202608120001` выключил вход по паролю и одноразовый код; `202608220001`
довершил: снял настройки OAuth2 и **все пять правил доступа** — перечисление,
чтение, создание, правку и удаление, — оставив их пустыми, что у хранилища
означает «только владелец панели».
Правку и удаление умолчание библиотеки открывало владельцу записи
(`id = @request.auth.id`), и до переезда входа это ничему не мешало: слой
предъявления жил под корнем приложения, и браузер до поверхности хранилища не
дотягивался. С узнаванием по заголовку она достижима, а ключ учётной записи
лежит теперь обычной колонкой — правка своей записи была бы присвоением чужого
имени. Наш код читает и заводит запись мимо правил, панель работает
суперпользователем, своих экранов профиля сервис не заводит.
- **Ключ учётной записи — колонка `provider_login`** с уникальным индексом,
заведена шагом `202608220001`. В ней логин человека **у провайдера** — то
значение, которым его называет обратный прокси заголовком. По нему запись
ищется и по нему же заводится при первом обращении.
Почта в той же коллекции переведена в необязательную тем же шагом: провайдер
не обязан её приносить, а ключом она не служит. Уникальность почты держится
**частичным** индексом (`WHERE email != ''`), поэтому записи без почты
уживаются друг с другом; уникальность логина — обычным, поэтому двух записей с
пустым ключом схема не примет вовсе.
- **Захват записи — один запрос с `RETURNING`**, мимо записей коллекции.
`app.DB()` направляет всё, кроме выборок, в пул с единственным соединением,
поэтому захваты выстраиваются в очередь. Порядок выборки — по времени
заведения **и по ключу**: время неуникально, и без ключа порядок обработки
невоспроизводим. Отбор идёт по рубежам из дескриптора, паузе, сроку протухания
захвата и отсутствию признака остановки; срок протухания выбирается по рубежу
самой записи прямо в запросе — воркер, ещё не знающий, что вытянет, подставить
его не может.
колонки шаг читает отдельным чтением.
- **Файлы записи лежат подкаталогом на запись:**
`data/records/<ULID записи>/<имя>`. Внутри — принятая копия, приведённая копия
и сохранённый ответ провайдера. Так копии одной записи лежат вместе, а запись
убирается целиком одним движением; плоский каталог, где копии различаются
приставкой в имени, обращал бы уборку в перебор по маске. Имя, данное
отправителем, не попадает ни в имя файла, ни в путь к нему. Ни файлы, ни
объекты в Object Storage не удаляются после завершения записи: каталог и бакет
растут неограниченно.
- **Укладка атомарна:** содержимое пишется во временное имя **в том же
подкаталоге записи** и переименовывается в рабочее только после того, как поток
дочитан до конца без отказа. Строка о файле заводится **после** этого;
содержимое легло, а строка не сохранилась — уложенный файл убирается.
- **Файл отдаётся адресом приложения** —
`GET /app/audiorecords/{id}/file?copy=original|normalized`, — и право пройти по
нему даёт узнавание пришедшего и владение записью. Значений на предъявителя
сервис не выдаёт вовсе: ни короткого токена файла, ни подписанной ссылки со
сроком. Отзыв доступа доходит до файла сразу, а не через срок жизни выданного
значения. Имя файла на диске в журнал не пишется и в ответ не идёт.
- **Учётная запись заводится первым обращением** — поиск по `provider_login` и
вставка идут одной транзакцией на пишущем соединении. Два отказа уникальности
различаются повторным поиском по ключу: нашёлся — гонка двух первых обращений
одним логином, не нашёлся — занятая почта, и запись заводится без неё.
- **Захват записи — один запрос `UPDATE … RETURNING`** по пишущему соединению:
выбор подходящей записи и пометка её захваченной идут вместе. Порядок выборки —
по времени заведения **и по ключу**: время неуникально, и без ключа порядок
обработки невоспроизводим. Отбор идёт по рубежам из дескриптора, паузе, сроку
протухания захвата и отсутствию признака остановки; срок протухания выбирается
по рубежу самой записи прямо в запросе — воркер, ещё не знающий, что вытянет,
подставить его не может.
- **Запись результата условна по признаку захвата** — инвариант «Результат пишет
только держатель захвата» в [CLAUDE.md](../CLAUDE.md), «Инварианты» (major);
норма — [pipeline](../openspec/specs/pipeline/spec.md). Здесь названо потому,
что условие проверяется тем же запросом, что и сам захват.
- **Список колонок задан двумя местами** — `applyOwnedByPipeline` вместе с
`applyToRecord` и `recordToAudioRecord`, — плюс шагом схемы. Мест было четыре,
пока захват перечислял колонки поимённо; теперь он возвращает идентификатор, и
перечень перестал расти с моделью. Правило правки и его серьёзность —
инвариант в [CLAUDE.md](../CLAUDE.md), «Инварианты»; сверку держат правила
норма — [pipeline](../openspec/specs/pipeline/spec.md). Условие стоит в самом
запросе правки, поэтому между проверкой и записью не остаётся окна.
- **Колонки записи отображаются по имени**: именованные параметры запроса и место
назначения, найденное по имени колонки. У аудиозаписи поля одного типа идут
длинным непрерывным рядом, и позиционный список дал бы сдвиг на одно поле,
который компилируется молча и кладёт идентификатор файла в колонку текста.
Перечень задан двумя местами — `writeOwnedByPipeline` вместе с `writeRecord` и
`readRecordColumns`, — плюс шагом схемы; правило правки и его серьёзность —
инвариант в [CLAUDE.md](../CLAUDE.md), сверку держат правила
`internal/archrules`.
- **Перечень рубежей объявлен одним дескриптором** — `internal/entity/stage.go`.
Из него выводятся выбор шага, отбор захвата, срок протухания и предел простоя:
рубеж, забытый в отборе, не выдаётся ни одному воркеру никогда, а пустой прогон
по инварианту проекта не пишется в журнал и не считается в метрику.
- **Отказ хранилища наружу не выходит дословно.** Он несёт ключ файла целиком, а
ключ — последняя часть ссылки на скачивание; поэтому чтение и укладка отдают
свой текст с идентификатором записи, а цепочку `%w` обрывают. То же у выгрузки
в Object Storage: отказ SDK несёт полный URL объекта.
- **Отказ базы наружу не выходит дословно.** Отказы чтения и укладки называют
запись её идентификатором и не несут ни имени файла, ни пути к нему: имя —
часть пути к чужому аудио. То же у выгрузки в Object Storage: отказ SDK несёт
полный URL объекта.
- **Обращения к базе идут с собственным контекстом**, а не с контекстом запроса.
Отменять там нечего: операции местные и короткие, а единственное ожидание —
занятая база — задано числом. За отмену платили бы дважды: шаг, прерванный
остановкой сервиса, перестал бы освобождать захват и писать причину остановки —
то есть отмена ломала бы ровно ту уборку, ради которой она и делается. Отмена,
которой сервис распоряжается по-настоящему, доходит до `ffmpeg` и до платного
распознавания.
## Настройки с числовым значением
@@ -336,41 +393,46 @@ capability, и третий смысл развёл бы одно слово п
| Срок захвата, опрос операции | 1 час | там же | опрос идёт секунды |
| Срок захвата, завершение | 1 час | там же | запись текста и ответ идут секунды |
| Число воркеров конвейера | 3 | конфиг, `[pipeline] workers` | решение владельца; ноль — законное значение |
| Ожидание занятой базы | 5000 миллисекунд | конфиг, `[storage] busy_timeout_ms` | выведено из числа воркеров, а не замерено: пишет сервис короткими операциями, и очередь из трёх воркеров укладывается в него с запасом |
| Соединений в читающем пуле | 4 | конфиг, `[storage] read_connections` | число воркеров плюс запас под запросы приложения; пишущее соединение при этом всегда одно и настройкой не делается |
| Предел простоя, своя работа | 60 минут | конфиг, `[pipeline] own_work_limit_minutes` | решение владельца 2026-08-14: сторож ловит зависание, а не долгую работу. Число **меньше** времени приведения многочасовой записи, и цена названа прямо — остановка обратима. Предел этот работает только по записи, вернувшейся в выборку: см. строку ниже |
| Предел простоя, чужая операция | 1440 минут | конфиг, `[pipeline] foreign_work_limit_minutes` | сколько идёт распознавание долгой записи, никто не мерил: ошибаемся в сторону долгого |
| Версия вида структуры реплик | 1 | `entity.StructureVersion` | первая |
| Умолчание размера страницы списка | 30 | `controller/http.DefaultPageLimit` | столько помещается на экран телефона без прокрутки в два экрана |
| Потолок размера страницы списка | 100 | `controller/http.MaxPageLimit` | против того, чтобы попросить весь архив одним запросом и тем обойти постраничность её же параметром |
| Ограничитель частоты под `/app/` | 120 запросов за 60 секунд | `controller/http.appRateMaxRequests`, `appRateWindowSec` | сервисом пользуются единицы человек; бюджет считается по адресу спрашивающего, а не по учётной записи |
**Адрес спрашивающего берётся из `X-Forwarded-For`, и это назначается кодом при
подъёме** — `controller/http.ApplyTrustedProxyHeaders`. Без этого хранилище
ключует счётчик адресом пира, а пир с переездом входа на заголовок всегда один и
тот же — обратный прокси; бюджет тогда становится общим на весь сервис, и восемь
одновременно открытых карточек выбирают его целиком. Требование к контуру,
которое отсюда следует, записано в [security.md](security.md), «Периметр»:
`X-Forwarded-For` прокси обязан перезаписывать, а не дописывать.
| Срок жизни неиспользуемого счётчика ограничителя | 10 минут | `controller/http.staleBudgetAge` | карта счётчиков растёт с числом адресов, и без уборки она стала бы местом, куда спрашивающий кладёт по строке на каждый свой адрес |
| Доля бюджета под опрос карточки | 1/8 | `controller/http.pollBudgetShare` | опрос идёт не один: в ту же секунду приложение листает список и грузит новую запись. Из этой доли **выводится** объявляемая частота опроса, и своей константы у неё нет |
| Потолок длины имени файла отправителя | 255 знаков | `entity.MaxOriginalFilenameLen` | предел длины имени в распространённых файловых системах: длиннее системный диалог выбора файла не даёт |
| Потолок длины расширения | 32 знака | `service/transcribe.go`, `maxExtLen` | сторож от патологии, а не перечень: расширения известных форматов укладываются в пять знаков, а `x.` с четырьмястами знаками роняет заведение временного файла |
| Потолок тем на запись | 5 | `entity.MaxTopicsPerRecord` | решение владельца: без него часовой разговор даёт два десятка тем |
| Срок хранения ресурса приложения | 1 год | `controller/http.assetMaxAgeSeconds` | имена ресурсов несут отпечаток содержимого, поэтому ответ устареть не может; срок ставится только файлам из каталога сборщика, всё прочее браузер спрашивает заново |
| Потолок сохранённого ответа провайдера | 256 МиБ | шаг `202608140002` | ответ многословнее расшифровки: несёт альтернативы, время каждого слова и разбор говорящих |
| Потолок структуры реплик | 16 МиБ | там же | шестичасовой разговор даёт порядка мегабайта текста с временем |
| Задержка перед первой проверкой операции | 10 секунд | `service/transcribe.go` | как было |
| Задержка между проверками операции | 5 секунд | там же | как было |
| Пауза воркера между прогонами | 1 секунда | `controller/worker/worker.go` | как было |
| Таймаут мягкой остановки | 5 секунд | конфиг, `[server] shutdown_timeout` | — |
| Таймаут жёсткой остановки | 20 секунд | конфиг, `[server] force_shutdown_timeout` | — |
| Качество кодирования vorbis | `-q:a 4` | `adapter/converter/ffmpeg/ffmpeg.go` | — |
| Жизнь приглашения завести владельца панели | 30 минут | умолчание PocketBase | — |
| Потолок размера одной записи | 8 ГиБ | `entity.MaxRecordSize` | расчётный потолок в шесть часов с запасом на видео |
| Предел длины логина у провайдера | 255 знаков | `pbrepo.MaxProviderLoginLength` и колонка `provider_login` | значение приходит заголовком, то есть задаётся тем, кто шлёт запрос; число то же, что у имени в умолчании библиотеки |
| Предел длины логина у провайдера | 255 знаков | `entity.MaxProviderLoginLength` | значение приходит заголовком, то есть задаётся тем, кто шлёт запрос; число то же, что у имени, пригодного к показу |
| Предел длины имени, пригодного к показу | 255 знаков | `entity.MaxDisplayNameLength` | то же |
| Длина идентификатора | 26 знаков | `ident.Len` | ширина записи ULID |
Три числа отсюда ушли 2026-08-22 вместе с собственным входом: срок жизни сессии,
потолок времени на вход у провайдера и таймаут обмена кода. Сессия не выдаётся
вовсе, обменивать код не на что, а отзыв доступа судит провайдер на каждом
запросе — задержке, которую измерял срок сессии, теперь неоткуда взяться.
**Адрес спрашивающего ограничитель берёт из `X-Forwarded-For` — и только тогда,
когда соединение пришло с адреса из объявленного перечня доверенных.** Без этого
счётчик ведётся по адресу пира, а пир с переездом входа на заголовок всегда один
и тот же — обратный прокси; бюджет тогда становится общим на весь сервис, и
восемь одновременно открытых карточек выбирают его целиком. Обратная ошибка —
верить заголовку без сверки пира — отдаёт обход ограничителя ровно тому, кого он
ограничивает. Сама цепочка читается справа налево с отбрасыванием доверенных
адресов, поэтому дописывающий прокси правилом покрыт; почему так —
[security.md](security.md), «Периметр».
Числа, ушедшие отсюда со встроенным хранилищем: потолок сохранённого ответа
провайдера и потолок структуры реплик — их держало поле коллекции, а теперь ответ
лежит файлом, а структура текстовой колонкой; жизнь приглашения завести владельца
панели — панели нет. Прежде, вместе с собственным входом, ушли срок жизни сессии,
потолок времени на вход у провайдера и таймаут обмена кода.
**У сторожа простоя есть второй потолок, и он не тот, что в настройке.** Предел
простоя проверяется в момент захвата, а захват не выдаёт запись, чей срок
@@ -381,15 +443,12 @@ capability, и третий смысл развёл бы одно слово п
остановка «застряла» наступает только после него. Мягкая остановка сюда не
подпадает: она снимает захват сама.
**Потолок размера назван числом в двух местах сразу** — у поля файла в схеме и у
тела запроса приёма, — и оба умолчания пришлось перекрыть: нулевой потолок поля
библиотека читает не как «без предела», а как свои 5 МиБ, а роутер отсекает тело
на 32 МиБ раньше обработчика. Оставленные умолчания отвергали бы всё длиннее
примерно пяти минут. Таймаут чтения запроса снят: шесть часов записи по
медленному каналу переживают любой фиксированный, а стойкость к целенаправленной
нагрузке объявлена вне модели угроз.
**Потолок размера назван числом там, где иначе действует умолчание**у тела
запроса приёма, и назван дважды: объявленная длина судится заранее, а
необъявленная и солгавшая ловятся на чтении. Умолчания здесь не «без предела», а
величины на два-три порядка меньше нужного. Таймаут чтения запроса снят: шесть
часов записи по медленному каналу переживают любой фиксированный, а стойкость к
целенаправленной нагрузке объявлена вне модели угроз.
Чего среди настроек **нет**: режим журналирования, таймаут занятости и размер
пула соединений задаёт хранилище своими умолчаниями, а не мы; срока хранения
файлов и объектов нет вовсе. Таймаутов у
обращений к S3 и SpeechKit тоже нет — ни одного.
Чего среди настроек **нет**: срока хранения файлов и объектов нет вовсе.
Таймаутов у обращений к S3 и SpeechKit тоже нет — ни одного.
+11 -10
View File
@@ -65,9 +65,9 @@ Telegram.
записи у сервиса при этом есть, и границы это не двигает: сервис **зеркалит**
имя, названное провайдером, — заводит строку при первом обращении под новым
именем и связывает с ней записи владельца. Кто этот человек и пускать ли его,
сервис не решает никогда. Одно исключение появилось 2026-08-11 вместе с
решением про PocketBase: в панель администратора владелец входит своим
паролем, потому что подпустить к ней внешнего провайдера PocketBase не даёт.
сервис не решает никогда. Исключений у этого больше нет: панель администратора
со своим паролем владельца жила здесь с 2026-08-11 по 2026-08-22 и ушла вместе
со встроенным хранилищем — своего входа сервис не ведёт вовсе.
- **Живая расшифровка.** Работаем с готовой записью, поток в реальном времени не
обрабатываем.
- **Диктофон.** Запись звука делает телефон, а приложение принимает готовый
@@ -117,10 +117,11 @@ Telegram.
которой пользуемся: она и задаёт потолок по длине записи и формату.
- **Whisper и его серверные обёртки** — запасной путь, если внешний сервис
перестанет устраивать по цене или по качеству русской речи.
**PocketBase** из референсов ушла: она больше не кандидат — в стек её перевела
задача `pocketbase-storage` 2026-08-12
([adr](adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)); там она
держит хранилище, файлы и панель владельца. Схема и
раскладка — [database.md](database.md). Учётные записи она хранит, а заводит их
сервис по имени, названному Authelia; источником людей она при этом не
становится: заводит и проверяет их по-прежнему Authelia.
**PocketBase** побывала и референсом, и стеком, и ушла из проекта целиком.
Референсом она быть перестала 2026-08-12, когда задача `pocketbase-storage`
перевела её в стек; стеком — 2026-08-22, когда задача
`storage-without-pocketbase`
([adr](adr/ADR-2026-08-22-storage-without-pocketbase.md)) убрала её вместе с
панелью владельца и собственным адресным пространством. Хранилище у сервиса своё:
SQLite напрямую и файлы записей своим каталогом. Схема и раскладка —
[database.md](database.md).
+5
View File
@@ -20,6 +20,11 @@ SpeechKit, Yandex Object Storage и `ffmpeg`. Мерить нужно то, чт
## Записи
Две записи о PocketBase — [pocketbase.md](pocketbase.md) и
[pocketbase-defaults.md](pocketbase-defaults.md) — описывают библиотеку, ушедшую
из проекта 2026-08-22. Они остаются записями о прошлом, и строкой в каждой это
сказано.
| Дата | Запись | О чём |
| --- | --- | --- |
| 2026-08-22 | [Хранилище: PocketBase против голого SQLite с каталогом файлов](storage-without-pocketbase.md) | Шесть ролей библиотеки в этом коде, отпавший довод перевода, объём кода на её типах, шесть модулей только через неё |
+8
View File
@@ -1,5 +1,13 @@
# PocketBase: умолчания, которые ломают штатный сценарий
**Записка о прошлом.** PocketBase ушла из проекта целиком 2026-08-22 —
[ADR-2026-08-22-storage-without-pocketbase](../adr/ADR-2026-08-22-storage-without-pocketbase.md).
Умолчания ниже принадлежат ушедшей библиотеке и ни на что в сервисе не влияют.
Живое из записки переехало в [../database.md](../database.md), «Настройки с
числовым значением», — потолок размера одной записи, потолок тела запроса и
снятый таймаут чтения, — и в
[ADR-2026-08-15-owner-required-by-schema](../adr/ADR-2026-08-15-owner-required-by-schema.md).
Наблюдения, снятые по ходу задачи `pocketbase-storage` уже на своём коде. От
[записки разведки](pocketbase.md) отличаются предметом: та мерила, **что даёт
панель**, эта — **что библиотека делает молча**, если её не переубедить.
+6
View File
@@ -1,5 +1,11 @@
# PocketBase: что даёт панель администратора
**Записка о прошлом.** PocketBase ушла из проекта целиком 2026-08-22 —
[ADR-2026-08-22-storage-without-pocketbase](../adr/ADR-2026-08-22-storage-without-pocketbase.md).
Панели у сервиса нет, и ничто из описанного ниже сегодня не работает. Записка
остаётся затем, что ею мерили цену потери: возврат остановленной записи в работу
делает подкоманда `cmd/devtools resume`, а остальное приносят отдельные задачи.
Отвечает на вопрос разведки `pocketbase-admin-fit`: что панель показывает и
правит по трём частям — записи, пользователи, файлы, — и хватает ли этого, чтобы
держать перевод хранилища в планах.
+86 -18
View File
@@ -72,11 +72,11 @@
- вшито то, что собрано этим прогоном, а не то, что осталось от прошлого;
- шаг следует словарю кодов: отказ сети и реестра — 3, красная сборка — 1, и он
**отказывает, а не висит**;
- путь, выбранный анонимом, не уходит ни меткой метрики, ни строкой журнала — и
журналов **два**: свой, в вывод контейнера, и журнал хранилища, куда
библиотека кладёт путь целиком вместе с адресом отправителя. Второй молчит
только на успехе и только потому, что признак отказа от записи поставлен
руками: готовая раздача статики ставит его сама, своя — нет.
- путь, выбранный анонимом, не уходит ни меткой метрики, ни строкой журнала.
Журнал у сервиса с 2026-08-22 **один** свой, в вывод контейнера: второй
ушёл вместе со встроенным хранилищем, которое клало путь целиком вместе с
адресом отправителя. Правило при этом расширилось, а не сузилось: путь не
пишется дословно ни под каким корнем, включая корень приложения.
**Клиент внешнего сервиса** (`adapter/recognizer/yandex`):
@@ -86,17 +86,20 @@
- вырожденный ответ (пустой, усечённый, без ожидаемого поля) не превращает в
успех молча.
**Репозиторий хранилища** (`internal/adapter/repo/pocketbase`; шаги схемы —
**Репозиторий хранилища** (`internal/adapter/repo/sqlite`; шаги схемы —
подпакетом `migrations`):
- список колонок совпадает в обоих местах — `applyOwnedByPipeline` вместе с
`applyToRecord` и `recordToAudioRecord` — и в шаге схемы (инвариант
[CLAUDE.md](../CLAUDE.md), «Инварианты»);
- список колонок совпадает во всех трёх местах — `writeOwnedByPipeline` вместе с
`writeRecord`, `readRecordColumns` и `rowToAudioRecord` — и в шаге схемы
(инвариант [CLAUDE.md](../CLAUDE.md), «Инварианты»). Колонки называются
**именами**: именованный параметр запроса и место назначения по имени, а не
позиция в списке;
- захват задачи не выдаёт одну строку двум вызывающим, а результат пишет только
держатель захвата;
- репозиторий кладёт время в сыром запросе тем же видом, каким хранилище пишет
свои `created`/`updated` ([database.md](database.md), «Представление данных»);
- отказ хранилища не выходит наружу дословно: он несёт ключ файла целиком.
держатель захвата, и держатель узнаётся значением признака;
- репозиторий кладёт время тем же видом, каким его кладут остальные, и берёт его
из единой точки ([database.md](database.md), «Представление данных»);
- отказ хранилища не выходит наружу дословно: он несёт ключ файла и путь к нему
целиком.
**Обёртка над внешним процессом** (`adapter/converter/ffmpeg`,
`adapter/metaviewer/ffmpeg`):
@@ -256,7 +259,7 @@
- изменение, трогающее конвейер задач целиком: состояние, воркер, шаг сервиса и
колонку разом;
- замена хранилища или переход на PocketBase — любой её кусок;
- замена хранилища — любой её кусок;
- смена модели очереди: захват, повторы и воркеры разом;
- каркас приложения: сборка фронтенда, раздача статики и шаг гейта разом;
- изменение, убирающее или возвращающее вход приёма целиком.
@@ -301,9 +304,10 @@ API и имя не откатываются обратной правкой по
**весь** барьер: он обязан заголовки `Remote-*` перезаписывать, а не пропускать
пришедшие. Проверить это отсюда нечем — правило живёт в `pet-project-server`
([adr/ADR-2026-08-12-access-delegated-to-provider.md](adr/ADR-2026-08-12-access-delegated-to-provider.md));
- `security`: поведение браузера с куками. Своих кук сервис больше не ставит
(2026-08-22), и класс сузился до кук, которые ставит панель хранилища; браузера
в прогоне нет, и находки этого рода остаются гипотезами.
- `security`: поведение браузера с куками. Своих кук сервис не ставит с
2026-08-22, а вместе со встроенным хранилищем ушли и те, что ставила его
панель. Класс опустел, и строка стоит здесь затем, чтобы возврат кук читался
как возврат недоступного проверке, а не как обычная работа.
**Перестали проверять сознательно:**
@@ -315,7 +319,8 @@ API и имя не откатываются обратной правкой по
- **работа сервиса с настоящими внешними собеседниками.** Сам сервис поднять
можно: он встаёт своим единственным входом на выдуманных непустых ключах
секций `[auth]` и `[yandex]` — наружу они на старте не ходят. Живой прогон —
осмотр HTTP, панели, журнала, метрик и остановки — доступен любой задаче.
осмотр HTTP, журнала, метрик и остановки — доступен любой задаче; панели среди
предметов осмотра нет с 2026-08-22.
Прежняя формулировка «всё, что требует поднять сервис целиком» снята задачей
`local-run-without-telegram-token` 2026-08-13; рецепт прогона менялся дважды —
с пустого ключа доступа на выключенный вход (`telegram-enabled-flag` того же
@@ -341,6 +346,69 @@ API и имя не откатываются обратной правкой по
истории git 2026-08-10: поле «Чем воспроизведён» называет у них коммит, а не
оракул, и выдумывать оракул задним числом нельзя.
## 2026-08-23 — путь, выбранный анонимом, уезжал в журнал под корнем приложения [пойман ревью]
- **Где:** `internal/controller/http/journal.go`, `JournalRoute`; задача
`storage-without-pocketbase`
- **Симптом:** неузнанный писал в журнал владельца свой текст произвольной длины.
Путь под корнем приложения уходил в строку дословно — в том числе при ответе
`401`, потому что слой журнала стоит снаружи ограничителя частоты
- **Причина:** правило «путь спрашивающего в журнал не идёт» было записано только
для запроса, отданного приложению. Путь вида `/app/<текст>` принадлежит
сервису, под то правило не подпадал и уезжал целиком, хотя множеством значений
под корнем распоряжается тот же аноним
- **Чем воспроизведён:** прогон враждебного прохода — путь в 1 044 480 знаков дал
прирост журнала в 1 044 632 байта; одно соединение за 1,003 с дало 122 запроса
и 121,5 МиБ журнала; 120 отказов ограничителя оставили 240 строк
- **Почему не поймали раньше:** правило записали по месту, где его впервые
понадобилось применить, а не по признаку «значением распоряжается спрашивающий».
Зазор был ровно шириной в корень приложения
- **Что меняем:** `JournalRoute` обобщает всё, что накрыто корнем приложения, а
длину отдаёт полем `http.path_length`; дословно пишутся только адреса из
закрытого перечня. Правило в [conventions/logging.md](conventions/logging.md)
переписано на все корни разом — оно стоит теперь у строки о всяком входящем
запросе, а не у строки о раздаче приложения
## 2026-08-23 — ключ бюджета ограничителя выбирал тот, кого ограничивают [пойман ревью]
- **Где:** `internal/controller/http/rate_limit.go`, `clientAddress`; задача
`storage-without-pocketbase`
- **Симптом:** ограничитель пропустил 1200 запросов одного спрашивающего при
бюджете 120 за окно. Заодно карта счётчиков росла линейно от числа выдуманных
адресов
- **Причина:** адрес брался из **левого** значения `X-Forwarded-For`, а прокси
заголовок дописывает, а не заменяет. Левым значением распоряжается сам
спрашивающий, значит он же выбирает и ключ карты — и меняет его на каждом
запросе
- **Чем воспроизведён:** прогон враждебного прохода — 1200 пропущенных запросов
при бюджете 120; 200 000 ключей в карте дали прирост кучи в 19 810 376 байт
- **Почему не поймали раньше:** слой писался заново вместе с транспортом, а
свойство «ключ бюджета не выбирает тот, кого ограничивают» не стояло ни в
конвенции, ни в типовом узле — его держала прежде чужая библиотека
- **Что меняем:** цепочка читается справа налево, доверенные адреса
отбрасываются, ключом становится первый недоверенный, а заголовок читается
всеми строками, а не одной. Требование к контуру этим снято: дописывающий
прокси правилом покрыт — [security.md](security.md), «Периметр»
## 2026-08-23 — инвариант о колонках записи потерял предмет [пойман ревью]
- **Где:** [CLAUDE.md](../CLAUDE.md), «Инварианты»;
`internal/adapter/repo/sqlite/record_mapping.go`, `internal/archrules`
- **Симптом:** инвариант называл поимённо `applyOwnedByPipeline`, `applyToRecord`
и `recordToAudioRecord` — функций с такими именами в коде уже не было. Сослаться
на инвариант как на оракул стало нельзя
- **Причина:** сторож и отображение переписаны под новую форму хранилища, а текст
инварианта остался от прежней. Мест при этом стало три: что спрошено
(`readRecordColumns`), куда лягут (`recordRow`) и что доедет до сущности
(`rowToAudioRecord`), — а сверялось правилом одно
- **Чем воспроизведён:** `grep` по трём прежним именам — пусто; `grep` по
`rowToAudioRecord` в `internal/archrules` — пусто
- **Почему не поймали раньше:** инвариант проверяется правилом, а имена в его
тексте — ничем. Текст и сторож разошлись молча
- **Что меняем:** инвариант назван действующими именами и действительным числом
мест; правило `internal/archrules` расширено на `rowToAudioRecord` — перечень
колонок чтения сверяется с перечнем присвоений в сущность
## 2026-08-15 — короткая форма рецепта входа не работала, а проверяли длинную [пойман ревью]
- **Где:** `cmd/oidcstub` — подставной провайдер OIDC для локального входа;
+103 -109
View File
@@ -43,27 +43,22 @@ Telegram — связи чата с учётной записью сервис
сделало сервис архивом. Оба сдвига описаны ниже разделами «Куда
уходит содержимое записи» и «Что вне модели».
**Третий сдвиг — панель администратора.** Решением от 2026-08-11
([adr](adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)) хранилищем
становится PocketBase, и вместе с ним на том же порту появляется панель по
адресу `/_/`: доступ ко всем записям, всем файлам и всем пользователям разом.
Порт опубликован в интернет через обратный прокси, а сама PocketBase вход в
панель через Authelia не пускает — у неё свой пароль суперпользователя.
**Закрывает панель контур, а не приложение:** решением владельца от 2026-08-11
адрес `/_/` закрывает Authelia на обратном прокси, пропуская только группу
администраторов. Задачи в беклоге у этого нет — работа принадлежит выкладке, а
она вне модели («Что вне модели», строка про контур).
**Третьего сдвига — панели администратора — больше нет, и это снятие.** Решением
от 2026-08-11 хранилищем становилась PocketBase, и вместе с ней на том же порту
появлялась панель `/_/`: доступ ко всем записям, всем файлам и всем пользователям
разом, закрываемый не приложением, а правилом обратного прокси. 2026-08-22,
задачей `storage-without-pocketbase`, встроенное хранилище убрано целиком:
панели не существует, второго периметра на порту сервиса не осталось, и правилу
прокси нечего закрывать.
**И этот барьер обходится подменой одного знака.** Маршрутизатор сравнивает
сегменты пути **после** раскодирования, поэтому `/%5f/` попадает в ту же группу,
что и `/_/`, а правило прокси написано на литерал и такой формы не видит.
Проверено прогоном 2026-08-15 ревью задачи `spa-skeleton`: обе формы отвечают
байт в байт, и весь клиент панели грузится анониму. Вход в приложение при этом
не обходится — `/%61pp/me` отвечает `401`. Дефект старше задачи, которая его
нашла, и **сегодня не закрыт**: лечение — приведение пути к канонической форме на
стороне сервиса, и глухая проверка тут не годится, потому что сломает скачивание
файлов с пробелами и не-латиницей в имени. Половину пути проверить нечем: правило
прокси живёт в `pet-project-server`, вне этого репозитория.
**Вместе с панелью снят и дефект подменённого знака.** Маршрутизатор сравнивал
сегменты пути после раскодирования, поэтому `/%5f/` попадал в ту же группу, что и
`/_/`, а правило прокси, написанное на литерал, такой формы не видело — весь
клиент панели грузился анониму (проверено прогоном 2026-08-15 ревью задачи
`spa-skeleton`). Лечится он теперь тем, что за обоими адресами не стоит ничего:
оба попадают под общее правило неизвестного пути и отдают разметку приложения.
Проверено прогоном 2026-08-22: `/_/`, `/%5f/` и всякий путь под `/api/` отвечают
байт в байт тем же, чем отвечает выдуманный путь вне корней сервиса.
**Четвёртый сдвиг был — секрет клиента в базе, — и он снят.** Задача
`oidc-login` 2026-08-12 клала адреса провайдера, идентификатор клиента и его
@@ -82,14 +77,17 @@ Telegram — связи чата с учётной записью сервис
`Remote-*` прокси обязан перезаписывать, а не пропускать**. Выкладку запускает
человек.
**То же требование распространяется на `X-Forwarded-For`, и по другой причине.**
С 2026-08-22 сервис называет этот заголовок хранилищу источником адреса
спрашивающего — иначе счётчик ограничителя частоты ключуется адресом пира, а
пир теперь всегда один, и бюджет становится общим на весь сервис. Прокси,
дописывающий `X-Forwarded-For` к присланному вместо замены, отдаёт ключ счётчика
самому спрашивающему: тот меняет значение и обходит ограничитель. Барьером
узнавания этот заголовок при этом не служит — кто пришёл, решает адрес самого
соединения.
**`X-Forwarded-For` сервис читает сам, и правило чтения закрывает дописывание.**
С 2026-08-22 адрес спрашивающего ограничитель частоты берёт из этого заголовка:
иначе счётчик ведётся по адресу пира, а пир теперь всегда один — прокси, — и
бюджет становится общим на весь сервис. Цепочка читается **справа налево**,
доверенные адреса отбрасываются, и ключом становится первый недоверенный: левым
значением распоряжается сам спрашивающий, а правое приписал ближайший к нам
прокси. Заголовок читается всеми строками, а не одной: цепочка законно приходит
несколькими. Прокси, дописывающий `X-Forwarded-For` к присланному, этим правилом
покрыт, и требования «перезаписывать, а не дописывать» у сервиса к нему нет — в
отличие от `Remote-*`. Барьером узнавания заголовок при этом не служит: кто
пришёл, решает адрес самого соединения.
**Ширина перечня доверенных адресов — тоже цена, и она принимается сознательно.**
Перечень задаёт, чьему `Remote-User` верить, и всякий, кто дотянулся до сервиса
@@ -150,7 +148,7 @@ Telegram — связи чата с учётной записью сервис
Сегодня запись покидает наш сервер двумя путями: файл уезжает в Yandex Object
Storage, оттуда его читает SpeechKit. Третий путь — ответ в Telegram — исчез
2026-08-14 вместе с убранным входом: текст теперь достаётся только своим адресом
и в панели владельца.
приложения.
Целевой периметр добавляет три пути, каждый — своей задачей:
@@ -170,44 +168,40 @@ Storage, оттуда его читает SpeechKit. Третий путь —
Отсюда возможен выход за пределы каталога хранения — запись файла туда, куда
путь не предполагался.
- **Путь на диске** выбирает хранилище:
`data/storage/<коллекция>/<запись>/<имя>`. **Имя задаёт сервис**
`<uuid><расширение>`, — а умолчание PocketBase, строящее имя из имени
отправителя, не применяется: имя отправителя в хранилище не попадает.
Расширение берётся из имени отправителя через `filepath.Ext` без проверки
- **Путь на диске** выбирает сервис: `data/records/<ULID записи>/<имя>`. Обе
части задаёт он сам — подкаталог назван идентификатором записи, имя файла это
`<ULID><расширение>`, — и имя, данное отправителем, не попадает ни в одну из
них. Расширение берётся из имени отправителя через `filepath.Ext` без проверки
списком; `filepath.Ext` режет по последней точке и не пропускает разделитель
каталогов, но это единственное, что стоит между входом и именем файла.
каталогов, но это единственное, что стоит между входом и именем файла. Длина
расширения при этом ограничена числом — иначе `x.` с четырьмястами знаками
роняет заведение временного файла.
- **Ключ объекта в Object Storage** — то же имя файла, то есть UUID с
расширением. Бакет один на все записи, префикса по пользователю нет. С
2026-08-14 копия там файлом записи не считается: она существует лишь потому,
что провайдер читает аудио по адресу, и её ключ живёт в строке попытки
распознавания.
- **Вторая раскладка файла на диске** появилась 2026-08-14 вместе с сохранённым
ответом провайдера: `data/storage/<recognitions>/<попытка>/<имя>.payload`. Имя
задаёт сервис, как и у аудио. Содержимое там — **полный текст речи**, а не
метаданные, поэтому поле помечено защищённым, правило просмотра коллекции
оставлено пустым, и ссылка на вложение подпадает под тот же запрет, что и
ссылка на аудио: в журнал она не пишется. Проверено прогоном: без сессии, с
чужим и со своим токеном файла ссылка отвечает «не найдено».
- **Ссылка на файл** — `/api/files/<коллекция>/<запись>/<имя>`. Поле файла
помечено защищённым задачей `oidc-login` 2026-08-12: пройти по ссылке теперь
можно только с коротким токеном файла, который выдаётся по сессии, и запрос
без него получает «не найдено». Сама ссылка отзыва по-прежнему не имеет —
токен сужает круг и живёт недолго, но выданное не отзывается. Отсюда запрет
остаётся: **имя файла в хранилище в журнал не пишется**
— иначе строка журнала вместе с идентификатором записи собирала бы ссылку
целиком и работала бы бессрочно. В журнал идёт расширение своим полем.
- **Идентификатор записи** — 15 знаков, выдаёт хранилище. Он же единственное,
что защищает карточку записи и её текст.
- **Поверхность самого хранилища.** Вместе с переводом наружу выходят
`/api/collections/...`, `/api/logs`, `/api/backups`, `/api/settings`,
`/api/crons` и панель `/_/`. Правила доступа коллекций оставлены пустыми, то
есть доступны они только владельцу панели, — и коллекции, заведённые
2026-08-14, тоже: содержимое записи отдаёт собственный адрес сервиса, а не
поверхность хранилища. Коды, снятые прогоном, —
[database.md](database.md), «Коллекции», норма —
[storage](../openspec/specs/storage/spec.md), «Наружу хранилище отдаёт только
то, что заказано».
- **Сохранённый ответ провайдера** лежит третьим файлом в том же подкаталоге
записи, под именем, которое задаёт сервис. Содержимое там — **полный текст
речи**, а не метаданные, поэтому закрыт он наравне с расшифровкой: адреса,
которым его читают снаружи, у сервиса нет вовсе, а путь к нему не пишется ни в
журнал, ни в метку метрики, ни в ответ.
- **Адрес файла** — `GET /app/audiorecords/{id}/file?copy=original|normalized`.
Право пройти по нему даёт **узнавание пришедшего и владение записью**, и
судится оно там же, где отдаётся файл. Значений на предъявителя сервис не
выдаёт вовсе: короткий токен файла ушёл 2026-08-22 вместе со встроенным
хранилищем, и отзыв доступа доходит до файла сразу, а не через срок жизни
выданного значения. Запрет при этом остаётся: **имя файла на диске в журнал не
пишется** — строка журнала стала бы бессрочным ключом к чужой записи. В журнал
идёт расширение своим полем.
- **Идентификатор записи** — ULID, 26 знаков, выдаёт приложение. Он же
единственное, что защищает карточку записи, её текст и её файл сверх владения.
- **Чужой поверхности на порту сервиса нет.** Адреса `/api/collections/...`,
`/api/logs`, `/api/backups`, `/api/settings`, `/api/crons` и панель `/_/` ушли
вместе со встроенным хранилищем 2026-08-22. Отвечает сервис только своими
адресами, а всё прочее идёт общим правилом неизвестного пути — норму держит
[webapp](../openspec/specs/webapp/spec.md). Что содержимое записи закрыто
везде, где лежит, нормирует [storage](../openspec/specs/storage/spec.md).
Целевой периметр добавляет сюда три вещи, и все три — от новых задач:
@@ -227,33 +221,34 @@ Storage, оттуда его читает SpeechKit. Третий путь —
заголовка: пересылаемым распоряжается тот, кто шлёт запрос. Значения,
переживающего запрос, сервис не выдаёт вовсе — ни куки, ни токена, — и потому
отзыв доступа у Authelia действует со следующего обращения.
Предъявленный собственный токен хранилища побеждает заголовок: им работает
владелец панели, и подмена его учётной записью пользователя отобрала бы у него
панель. Протухший и негодный токен предъявленными не считаются.
**Область узнавания сужена** до корня приложения и адреса выдачи файлового
токена: собственная поверхность хранилища под неё не подпадает, иначе узнанный
переписал бы себе ключ учётной записи на чужое имя.
Собственных токенов сервис не принимает вовсе: значения, предъявленного
запросом и дающего доступ помимо заголовка, у него не существует. Прежде такое
значение било заголовок — им работал владелец панели; панели нет, и правило
приоритета осталось бы правилом без предмета.
**Область узнавания — корень приложения**, и выводится она из объявленного
адресного пространства сервиса: слои одеты на корень целиком, вторым списком
адресов область не описывается. Проба здоровья, метрики и ресурсы приложения
под неё не подпадают — иначе запрос за каждой картинкой стоил бы обращения к
базе, а первый такой запрос с новым именем — записи в неё.
- **Учётная запись** — заводится первым обращением с новым логином и находится
по нему же дальше. Ключ — колонка `provider_login`, уникальная; править её
снаружи нельзя, все пять правил доступа коллекции пользователей закрыты шагом
схемы `202608220001`.
- **Файл записи** — короткий токен файла, который берёт узнанный. Поле файла
помечено защищённым, правило просмотра коллекции пускает только владельца
файла, и ссылка `/api/files/...` перестала быть правом пройти по ней. Одного
заголовка мало: порядок здесь «узнавание → токен файла → ссылка». **Это
единственное значение, переживающее запрос**, и на его срок отзыв доступа до
файловой ссылки не доходит.
снаружи нельзя, потому что адреса правки учётной записи у сервиса нет вовсе:
своих экранов профиля он не заводит, а поверхности хранилища, правившей запись
библиотечным правилом, не осталось.
- **Файл записи** — узнавание пришедшего и владение записью, судимые в самом
обработчике отдачи. Отказ наступает **на обращении за файлом**: другого места,
где он мог бы наступить, у сервиса не осталось. Значений, переживающих запрос,
сервис не выдаёт ни одного, поэтому отзыв доступа доходит и до файла.
- **Кто допущен** — **решает Authelia, а не сервис.** Своей проверки группы
приложение не делает: кого пускать, определяет правило провайдера на этого
клиента. Правило живёт **вне репозитория**, в настройках выкладки, и по коду
его не проверить. Клиент, настроенный слишком широко, открывает сервис
всякому, у кого есть учётная запись в общей Authelia. Решение владельца от
2026-08-12.
- **Собственный вход хранилища закрыт целиком.** Создание записи, вход по
паролю, одноразовый код, обмен кода у внешнего провайдера, восстановление
доступа и продление — ни один не даёт доступа и не меняет учётной записи:
хранилище заводит коллекцию пользователей открытой, и без этого закрытия
узнавание обходилось бы двумя запросами.
- **Собственного входа у сервиса нет вовсе.** Создание записи, вход по паролю,
одноразовый код, обмен кода у внешнего провайдера, восстановление доступа и
продление принадлежали встроенному хранилищу и ушли вместе с ним: закрывать
больше нечего, и адресов этих не существует.
- **Метрики и здоровье** — `GET /metrics` и `GET /health` открыты неузнанному:
учётной записи нет ни у пробы, ни у сборщика. Заголовок их ответа не меняет и
учётной записи на них не заводит. Наружу их закрывает правило обратного
@@ -284,23 +279,27 @@ Storage, оттуда его читает SpeechKit. Третий путь —
Откуда он берётся — из группы OIDC или из конфигурации — не решено
(`admin-stats-screen`).
**Панель администратора в эту таблицу не входит и разграничению не подчиняется.**
Суперпользователь PocketBase видит все записи, все файлы и всех пользователей
мимо любого из четырёх механизмов, а пускает его свой пароль, а не Authelia.
Замер показал, что закрыть панель провайдером OIDC или вторым фактором нельзя:
обе настройки у коллекции суперпользователей отклоняются. Остаётся ограничение
по списку адресов (`superuserIPs`), и оно же запирает владельца, если список
задан неверно: сброса в наборе команд нет.
**Панели администратора в этой таблице нет, и это снятие, а не пропуск.** До
2026-08-22 суперпользователь встроенного хранилища видел все записи, все файлы и
всех пользователей мимо любого из механизмов разграничения, а пускал его свой
пароль, а не Authelia. Хранилище ушло, панели не существует, и разграничение у
сервиса осталось одно — владение записью.
Владелец сервиса взамен получил одно действие и один инструмент: подкоманда
`cmd/devtools resume` возвращает остановленную запись в работу. Она ходит **в тот
же каталог данных**, то есть требует доступа к файлам сервера, а не к сети:
поверхности, открытой в интернет, у неё нет вовсе.
## Что чувствительнее чего
1. **Содержимое записей и расшифровок.** Голосовые сообщения — личная переписка;
это самое чувствительное, что здесь есть. С 2026-08-14 оно живёт не одной
колонкой, а шестью коллекциями: сама запись (заголовок и краткое описание),
колонкой, а шестью таблицами: сама запись (заголовок и краткое описание),
`texts` (расшифровка и вычитанный текст), `structures` (реплики со временем),
`recognitions` (**сырой ответ провайдера вложением — полный текст речи**),
`recognitions` (попытка распознавания; **сохранённый ответ провайдера —
полный текст речи — лежит файлом в подкаталоге записи**),
`record_events` (журнал событий, содержимого не несёт) и `topics` (словарь
тем человека). Всякая новая коллекция, куда содержимое переезжает, закрывается
тем человека). Всякая новая таблица, куда содержимое переезжает, закрывается
наравне с записью — норму держит спека `storage`.
2. **Ключи Yandex Cloud**`speech_kit_api_key` и пара ключей Object Storage.
Утечка оплачивается деньгами и доступом к бакету.
@@ -325,14 +324,10 @@ Storage, оттуда его читает SpeechKit. Третий путь —
5. **Статистика потребления** (`usage-accounting`). Текста записей не содержит,
но говорит, кто и когда пользовался сервисом и сколько; страница расхода
открыта только владельцу.
6. **Пароль владельца от панели.** Открывает все записи, все файлы и всех
пользователей разом, то есть стоит вровень с самым чувствительным из списка
выше. Второй секрет после токенов пользователей, который лежит **не в
конфигурации**: его отпечаток хранит сама база, а задаёт пароль сам владелец
по приглашению, которое сервис печатает в журнал при первом запуске. У
приглашения тридцать минут жизни, и после того как владелец заведён, оно не
печатается вовсе — иначе строка журнала отдавала бы панель всякому его
читателю навсегда.
Пароля владельца от панели в этом списке больше нет: он ушёл 2026-08-22 вместе с
самой панелью. Секрет, появившийся только ради перевода на встроенное хранилище,
пропал, и ключа под него в конфигурации не заводится по той простой причине, что
заводить нечего.
Тексты расшифровок в логи не пишутся — логируется длина текста и
идентификаторы. Имя файла, данное отправителем, из журнала приёма убрано
@@ -355,7 +350,7 @@ Storage, оттуда его читает SpeechKit. Третий путь —
Это закрыто задачей `no-user-filename-in-log` 2026-08-11 вместе с самим именем.
Заодно у метки размера принятой записи пропала ведущая точка (`.mp3` стало
`mp3`) — форма выровнялась с меткой конвертации, которая точку не носила
никогда. Ряды, собранные до выкладки, перестают пополняться: панель, отобранная
никогда. Ряды, собранные до выкладки, перестают пополняться: график, отобранный
по старому значению, покажет пустоту, и это не поломка.
Требование важно тем, что `GET /metrics` открыт вместе с остальным: без
приведения хвост читал бы кто угодно из интернета, а множеством значений метки
@@ -422,14 +417,13 @@ Storage, оттуда его читает SpeechKit. Третий путь —
Учёт расхода удалению не подлежит по решению человека: деньги потрачены, а
строки потребления текста не содержат.
**Руками запись сегодня не удаляется, и прежняя строка об этом была неверна.**
Проверено прогоном 2026-08-14: содержимое живёт в коллекциях, перечисленных
выше («Что чувствительнее чего»), связи приложений с записью обязательны и
каскада не имеют, поэтому удаление самой
строки записи отвергается хранилищем, а удаление её файлов проходит молча.
Владелец, выполнивший прежнюю процедуру, стирает аудио и **оставляет полный
текст речи** — расшифровку, разбивку по репликам и сырой ответ провайдера
файлом на диске. Порядок, которым запись убирается на самом деле: сперва
строки приложений — журнал событий, попытка распознавания вместе с её
вложением, структура, тексты, — потом сама запись, потом её файлы. До
**Руками запись сегодня убирается только запросом к базе, и порядок в нём
несущий.** Содержимое живёт в таблицах, перечисленных выше («Что чувствительнее
чего»), связи приложений с записью обязательны и каскада не имеют, поэтому
удаление самой строки отвергается базой, пока живы приложения. Порядок такой:
сперва строки приложений — журнал событий, попытка распознавания, структура,
тексты, связи с темами, — потом сама запись, потом её файлы. Файлы при этом
убираются **одним движением**: подкаталог записи под её идентификатором. Тот,
кто убрал только файлы, стирает аудио и **оставляет полный текст речи**
расшифровку, разбивку по репликам и сохранённый ответ провайдера. До
`delete-record` это единственный способ, и он ручной целиком.