docs: канон приведён к сегодняшнему состоянию после сверки

- периметр: passport.md и security.md больше не утверждают, что HTTP API
  открыт без аутентификации, а review.md не числит эту находку типовой
  ложноположительной — приём, опрос и файл закрыты сессией с 2026-08-12;
- logging.md писал, что расширение попадает в журнал полем пути: описано
  изъятие инварианта приватности — собственное поле, имени и пути нет;
- узел ревью переименован в repo/pocketbase, поведение конвейера из обзора
  уехало ссылкой в спеку pipeline, Purpose спеки storage написан вместо
  заглушки, в ADR о переезде дописано уточнение о действующей раскладке.
This commit is contained in:
av
2026-08-12 21:13:06 +03:00
parent 8739b18a9f
commit b46be019fc
11 changed files with 100 additions and 51 deletions
@@ -67,6 +67,10 @@ PocketBase заменяет SQLite с goqu и goose и берёт на себя
`pb_data/storage/<коллекция>/<запись>/<имя>_<10 случайных символов>` рядом с `pb_data/storage/<коллекция>/<запись>/<имя>_<10 случайных символов>` рядом с
файлом атрибутов. Момент перехода назначает человек; данные прежней базы не файлом атрибутов. Момент перехода назначает человек; данные прежней базы не
переносятся по прежнему решению задачи `pocketbase-storage`. переносятся по прежнему решению задачи `pocketbase-storage`.
*Уточнено 2026-08-12:* каталог задаётся ключом `[storage] data_dir` со
значением `data`. Суффикс из десяти знаков дописывает конструктор имени,
которого сервис не зовёт, — имя задаёт он сам. Действующая раскладка —
[../database.md](../database.md), «Представление данных».
- `` вход перестаёт быть нашим: задача `oidc-login` переписывается с - `` вход перестаёт быть нашим: задача `oidc-login` переписывается с
собственной обработки ответа провайдера на настройку провайдера в PocketBase. собственной обработки ответа провайдера на настройку провайдера в PocketBase.
Что делать с сессией и где она живёт, решает уже не наш код. Что делать с сессией и где она живёт, решает уже не наш код.
+24 -14
View File
@@ -12,9 +12,12 @@
потребителей; пятая — исключение из первого абзаца: она нормирует не сервис, а потребителей; пятая — исключение из первого абзаца: она нормирует не сервис, а
инструмент, которым его собирают, и потребитель у неё другой — тот, кто собирает. инструмент, которым его собирают, и потребитель у неё другой — тот, кто собирает.
- [intake](../openspec/specs/intake/spec.md) — **только приём по HTTP**: его - [intake](../openspec/specs/intake/spec.md) — **только приём по HTTP**: приём и
нормируют проверки, написанные задачей `http-handler-tests-never-green` опрос за сессией, имя отправителя не доходит ни до хранилища, ни до журнала,
2026-08-11; метка метрики несёт только известное расширение. Задачи
`http-handler-tests-never-green` и `no-user-filename-in-log` 2026-08-11,
`pocketbase-storage` и `oidc-login` 2026-08-12. Приём из Telegram здесь не
описан;
- [pipeline](../openspec/specs/pipeline/spec.md) — пустой прогон воркера, захват - [pipeline](../openspec/specs/pipeline/spec.md) — пустой прогон воркера, захват
задачи и срок его протухания, число попыток, состояние «мертва» и пауза перед задачи и срок его протухания, число попыток, состояние «мертва» и пауза перед
повтором: задачи `errors-as-instead-of-typecast` 2026-08-11 и повтором: задачи `errors-as-instead-of-typecast` 2026-08-11 и
@@ -40,14 +43,17 @@
- **Один процесс.** Бот, HTTP-сервер и фоновые воркеры живут в одном бинарнике и - **Один процесс.** Бот, HTTP-сервер и фоновые воркеры живут в одном бинарнике и
делят одну базу. Отдельного воркер-процесса нет намеренно. делят одну базу. Отдельного воркер-процесса нет намеренно.
- **Очередь таблицей.** Состояние задачи лежит коллекцией хранилища, воркер - **Очередь таблицей.** Состояние задачи лежит коллекцией хранилища; неделимость
забирает работу одним запросом с захватом. Внешний брокер не заводим: нагрузка захвата и порядок выборки нормирует
— единицы записей в день (оценка владельца, не замер). Готовую библиотеку [pipeline](../openspec/specs/pipeline/spec.md), «Захват задачи неделим».
очереди тоже не заводим — решено 2026-08-11, Внешний брокер не заводим: нагрузка — единицы записей в день (оценка владельца,
не замер). Готовую библиотеку очереди тоже не заводим — решено 2026-08-11,
[ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md), сравнение [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), «Брошенная задача возвращается
в работу»; здесь это принцип письма шага, а не описание поведения.
- **Ядро зависит от интерфейсов.** `internal/service` знает только - **Ядро зависит от интерфейсов.** `internal/service` знает только
`internal/contract`; ffmpeg, Yandex, Telegram и хранилище подставляются в `internal/contract`; ffmpeg, Yandex, Telegram и хранилище подставляются в
`main.go`. `main.go`.
@@ -149,11 +155,15 @@
## Открытые вопросы ## Открытые вопросы
- **Учётные записи.** Вход через OIDC, провайдер — Authelia, а ответ провайдера - **Учётные записи.** Вход через OIDC решён и развёрнут 2026-08-12: провайдер
обрабатывает PocketBase, а не наш код Authelia, ответ провайдера обрабатывает PocketBase, а не наш код
([ADR](adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)). Не решено, где живёт сессия ([ADR](adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)), сессия
и как связываются пользователь Telegram и пользователь веба. Панель живёт кукой `transcriber_session` и сама себя не продлевает. Норма —
администратора при этом Authelia не закрывает: у неё свой пароль [access](../openspec/specs/access/spec.md), решения —
[ADR-2026-08-12-session-without-refresh](adr/ADR-2026-08-12-session-without-refresh.md)
и [ADR-2026-08-12-oidc-exchange-via-own-route](adr/ADR-2026-08-12-oidc-exchange-via-own-route.md).
**Не решено одно:** как связываются пользователь Telegram и пользователь веба.
Панель администратора при этом Authelia не закрывает: у неё свой пароль
суперпользователя. суперпользователя.
- **Приложение.** Экранов нет вовсе, есть только API. Решено делать SPA, - **Приложение.** Экранов нет вовсе, есть только API. Решено делать SPA,
устанавливаемое на телефон, а фреймворком взят Vue 3 с роутером пятой версии и устанавливаемое на телефон, а фреймворком взят Vue 3 с роутером пятой версии и
+5 -2
View File
@@ -130,7 +130,10 @@ TOML. Пустой токен бота ловится в `NewTelegramController`
## Структура в коде ## Структура в коде
- Весь разбор и проверка — в `internal/config`; наружу отдаётся готовая `Config`. - Весь разбор и проверка — в `internal/config`; наружу отдаётся готовая `Config`.
- Одна корневая структура `Config` с под-структурами по секциям (`Server`, - Одна корневая структура `Config` с под-структурами по секциям. Перечень секций
`Database`, `Storage`, `Yandex`, `Telegram`). и полей здесь не повторяем: источник истины по составу — `config.dist.toml`,
действующие числа — [../database.md](../database.md), «Настройки с числовым
значением». Каталог данных задаётся одним ключом `[storage] data_dir`
([ADR](../adr/ADR-2026-08-12-single-data-dir-config-key.md)).
- Умолчания задаются в `defaultConfig()`, файл их перекрывает. Новое поле - Умолчания задаются в `defaultConfig()`, файл их перекрывает. Новое поле
требует правки обоих мест. требует правки обоих мест.
+5 -2
View File
@@ -135,8 +135,11 @@ transcriber — **приложение, а не библиотека**: внеш
- Не для управления потоком и не для ожидаемых ошибок (нет сети, плохой ввод) — - Не для управления потоком и не для ожидаемых ошибок (нет сети, плохой ввод) —
это значения `error`. это значения `error`.
- `recover` — на верхней границе обработчика, чтобы один паникующий запрос не - `recover` — на верхней границе обработчика, чтобы один паникующий запрос не
ронял процесс. В transcriber это `gin.Recovery()`; у воркеров и у бота такой ронял процесс. В transcriber его вешает роутер хранилища сам
границы **нет**: паника в шаге конвейера роняет процесс целиком. (`apis.panicRecover`, слой с идентификатором `DefaultPanicRecoverMiddlewareId`
на каждом роутере PocketBase): паникующий обработчик отдаёт `500`, процесс
живёт. Своего слоя мы не пишем. У воркеров и у бота такой границы **нет**:
паника в шаге конвейера роняет процесс целиком.
## Несколько ошибок ## Несколько ошибок
+7 -4
View File
@@ -241,11 +241,14 @@ Object Storage, скачивание файла из Telegram и опрос оп
она не логируется — то есть утечки нет, но защищает от неё только отсутствие она не логируется — то есть утечки нет, но защищает от неё только отсутствие
строки лога. строки лога.
*Расхождение:* расширение берётся из имени отправителя дословно *Изъятие, а не расхождение:* расширение берётся из имени отправителя дословно
(`filepath.Ext`), поэтому имя `запись.тайное-слово` отдаёт приватный хвост (`filepath.Ext`), поэтому имя `запись.тайное-слово` отдаёт приватный хвост
расширением, и в журнал оно попадает полем пути. Наружу — в метку метрики — этот расширением. В журнал оно идёт **собственным полем** строки приёма — это
хвост не выходит: там расширение приводится к перечню известных форматов. Остаток объявленное изъятие инварианта приватности ([CLAUDE.md](../../CLAUDE.md),
описан в [../security.md](../security.md). «Инварианты»); ни имени файла в хранилище, ни пути к нему в журнале нет вовсе
(норма — `openspec/specs/intake`). Наружу — в метку метрики — хвост не выходит:
там расширение приводится к перечню известных форматов. Остаток описан в
[../security.md](../security.md).
## Куда пишем и уровень ## Куда пишем и уровень
+17 -9
View File
@@ -22,7 +22,7 @@
| Владелец сервиса | Загрузить диктофонную запись или видео из семейного архива с телефона и получить текст. Видеть, кто сколько загрузил и во что это обошлось | | Владелец сервиса | Загрузить диктофонную запись или видео из семейного архива с телефона и получить текст. Видеть, кто сколько загрузил и во что это обошлось |
| Приглашённый пользователь | Войти в приложение через свою учётную запись, загрузить запись, забрать текст, вернуться к ней через месяц. Приложение ставится на телефон; каждый видит только свои записи | | Приглашённый пользователь | Войти в приложение через свою учётную запись, загрузить запись, забрать текст, вернуться к ней через месяц. Приложение ставится на телефон; каждый видит только свои записи |
| Пользователь Telegram | Отправить боту голосовое сообщение и получить текст ответом. Работает сегодня | | Пользователь Telegram | Отправить боту голосовое сообщение и получить текст ответом. Работает сегодня |
| Внешняя программа | Отдать файл по HTTP, представившись своим токеном, и опросить готовность. Работает сегодня, но токенов нет и доступ не разграничен | | Внешняя программа | Отдать файл по HTTP, представившись своим токеном, и опросить готовность. Сегодня почти не работает: приём и опрос закрыты сессией OIDC, а своего токена у программы нет — годится только кука, снятая из браузера. Токен приносит `api-tokens` |
**Основной вход — приложение**, бот и HTTP API дополняют его. До 2026-08-11 **Основной вход — приложение**, бот и HTTP API дополняют его. До 2026-08-11
основным был бот, и порядок здесь перевёрнут сознательно: диктофонная запись на основным был бот, и порядок здесь перевёрнут сознательно: диктофонная запись на
@@ -68,8 +68,11 @@
файл. Своей записи и работы без сети не делаем — граница цели файл. Своей записи и работы без сети не делаем — граница цели
[web-access](../tasks/items/web-access.md). [web-access](../tasks/items/web-access.md).
- **Файловое хранилище общего назначения.** Храним аудио и видео, отданные ради - **Файловое хранилище общего назначения.** Храним аудио и видео, отданные ради
речи в них. Складом произвольных файлов, папками и общим доступом к чужим речи в них. Складом произвольных файлов и папками сервис не становится. Общего
записям сервис не становится. доступа к чужим записям целью тоже нет — но **сегодня он есть**: владельца у
записи в модели данных не существует, и всякий вошедший видит все записи
([security.md](security.md), «Периметр»). Это состояние, а не решение;
закрывает его `record-ownership`.
- **Учёт денег.** Считаем объём, минуты и токены по каждому пользователю и - **Учёт денег.** Считаем объём, минуты и токены по каждому пользователю и
показываем их владельцу. Цен, счетов и отказов по исчерпании квоты не делаем: показываем их владельцу. Цен, счетов и отказов по исчерпании квоты не делаем:
пользователя, потратившего слишком много, останавливает разговор или отзыв пользователя, потратившего слишком много, останавливает разговор или отзыв
@@ -95,8 +98,10 @@
отличает их по MIME-типу и расширению. Работает сегодня. отличает их по MIME-типу и расширению. Работает сегодня.
5. **Загрузка по HTTP.** Программа шлёт `POST /api/audio` со своим токеном, 5. **Загрузка по HTTP.** Программа шлёт `POST /api/audio` со своим токеном,
получает идентификатор задачи и опрашивает `GET /api/status/:id`, пока не получает идентификатор задачи и опрашивает `GET /api/status/:id`, пока не
увидит `done` и текст. Работает сегодня, но без токена и без разграничения увидит `done` и текст. Сегодня доступно только предъявившему сессию OIDC:
доступа. анонимный запрос обоими адресами отклоняется. Своего входа у программы нет —
его заводит `api-tokens`, — как нет и разграничения записей между
пользователями.
6. **Отказ на середине.** Конвертация или распознавание не удались — задача 6. **Отказ на середине.** Конвертация или распознавание не удались — задача
переходит в `failed`, а пользователь получает сообщение о том, что именно не переходит в `failed`, а пользователь получает сообщение о том, что именно не
вышло, и предложение повторить. вышло, и предложение повторить.
@@ -109,7 +114,10 @@
которой пользуемся: она и задаёт потолок по длине записи и формату. которой пользуемся: она и задаёт потолок по длине записи и формату.
- **Whisper и его серверные обёртки** — запасной путь, если внешний сервис - **Whisper и его серверные обёртки** — запасной путь, если внешний сервис
перестанет устраивать по цене или по качеству русской речи. перестанет устраивать по цене или по качеству русской речи.
- **PocketBase** — хранилище взамен сегодняшнего SQLite, решено 2026-08-11 **PocketBase** из референсов ушла: она больше не кандидат — в стек её перевела
([adr](adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)). Учётные задача `pocketbase-storage` 2026-08-12
записи оно хранит и получает от Authelia своим провайдером OIDC, но источником ([adr](adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)); там она
их не становится: заводит и проверяет людей по-прежнему Authelia. держит хранилище, файлы и панель владельца. Схема и
раскладка — [database.md](database.md). Учётные записи она хранит и получает от
Authelia своим провайдером OIDC, но источником их не становится: заводит и
проверяет людей по-прежнему Authelia.
+15 -8
View File
@@ -38,12 +38,16 @@
- вырожденный ответ (пустой, усечённый, без ожидаемого поля) не превращает в - вырожденный ответ (пустой, усечённый, без ожидаемого поля) не превращает в
успех молча. успех молча.
**Репозиторий SQLite** (`adapter/repo/sqlite`): **Репозиторий хранилища** (`internal/adapter/repo/pocketbase`):
- список колонок совпадает во всех четырёх запросах файла; - список колонок совпадает во всех четырёх местах — `applyToRecord`,
- `NULL` в колонке разбирается в указатель, а не роняет `Scan`; `recordToJob`, `acquireColumns`, `acquiredRow` — и в шаге схемы (инвариант
- захват задачи не выдаёт одну строку двум вызывающим; [CLAUDE.md](../CLAUDE.md), «Инварианты»);
- ошибка драйвера транслируется в доменную у источника. - захват задачи не выдаёт одну строку двум вызывающим, а результат пишет только
держатель захвата;
- репозиторий кладёт время в сыром запросе тем же видом, каким хранилище пишет
свои `created`/`updated` ([database.md](database.md), «Представление данных»);
- отказ хранилища не выходит наружу дословно: он несёт ключ файла целиком.
**Обёртка над внешним процессом** (`adapter/converter/ffmpeg`, **Обёртка над внешним процессом** (`adapter/converter/ffmpeg`,
`adapter/metaviewer/ffmpeg`): `adapter/metaviewer/ffmpeg`):
@@ -95,9 +99,12 @@
- **«Файлы и объекты не удаляются, диск растёт».** Факт верный и записан в - **«Файлы и объекты не удаляются, диск растёт».** Факт верный и записан в
[database.md](database.md); срок хранения не задан сознательно, задачи на него нет. [database.md](database.md); срок хранения не задан сознательно, задачи на него нет.
Новой находкой это не считается, пока не измерен рост. Новой находкой это не считается, пока не измерен рост.
- **«HTTP API открыт без аутентификации».** Известно и записано первой строкой - **«У записи нет владельца: вошедший видит чужие записи».** Не дефект и не
[security.md](security.md). Находкой считается только новая поверхность, новость: приём, опрос и файл закрыты сессией OIDC с 2026-08-12, а
выставленная наружу, а не повторение этого факта. разграничения по владельцу нет сознательно — [security.md](security.md),
«Периметр», и `openspec/specs/access`, «Purpose». Находкой считается новая
поверхность, выставленная наружу, либо путь к содержимому записи **без**
сессии, а не повторение этого факта.
### Вопросы по темам ### Вопросы по темам
+8 -6
View File
@@ -53,8 +53,8 @@
| Вход | Канал | Кто может слать | | Вход | Канал | Кто может слать |
| --- | --- | --- | | --- | --- | --- |
| Аудиофайл и его имя | `POST /api/audio`, multipart-поле `audio` | Любой из интернета | | Аудиофайл и его имя | `POST /api/audio`, multipart-поле `audio` | Любой вошедший через OIDC; без сессии — `401` до чтения тела |
| Идентификатор задачи | `GET /api/status/:id` | Любой из интернета | | Идентификатор задачи | `GET /api/status/:id` | Любой вошедший через OIDC; без сессии — `401`, одинаковый для заведённой и незаведённой задачи |
| Голосовое, аудио, документ | Telegram, длинный опрос | Любой пользователь Telegram; обрабатывается только из белого списка | | Голосовое, аудио, документ | Telegram, длинный опрос | Любой пользователь Telegram; обрабатывается только из белого списка |
| Имя файла в Telegram | Поле `file_path` ответа Bot API | Telegram, а через него — отправитель | | Имя файла в Telegram | Поле `file_path` ответа Bot API | Telegram, а через него — отправитель |
| Содержимое аудио | Файл, скармливаемый `ffmpeg` и `ffprobe` | Отправитель по любому из каналов | | Содержимое аудио | Файл, скармливаемый `ffmpeg` и `ffprobe` | Отправитель по любому из каналов |
@@ -283,10 +283,12 @@ Telegram отправителю.
- **Стойкость к целенаправленной нагрузке.** Ограничения по числу запросов и по - **Стойкость к целенаправленной нагрузке.** Ограничения по числу запросов и по
размеру файла нет, и защищаться от исчерпания диска мы сейчас не пытаемся. размеру файла нет, и защищаться от исчерпания диска мы сейчас не пытаемся.
- **Исчерпание диска приглашёнными.** Записи и тексты хранятся бессрочно - **Исчерпание диска приглашёнными.** Записи и тексты хранятся бессрочно
(паспорт, 2026-08-11), шестичасовая запись весит единицы гигабайт — оценка, а не (паспорт, 2026-08-11). Шестичасовая запись весит единицы гигабайт — оценка, а
замер: `research/` пуст, потолок длины стоит открытым вопросом не замер: распределения длин у сервиса нет, а самая длинная проверенная запись
`architecture.md`, «Долгие записи», — а квот нет и не будет: решено считать расход и показывать его владельцу, а не отказывать — 9,6 МБ ([research/pocketbase-defaults.md](research/pocketbase-defaults.md)).
(цель `usage-stats`). Перебравшего останавливает разговор или отзыв доступа в Потолок длины стоит открытым вопросом `architecture.md`, «Долгие записи». Квот
нет и не будет: решено считать расход и показывать его владельцу, а не
отказывать (цель `usage-stats`). Перебравшего останавливает разговор или отзыв доступа в
Authelia. Рост каталога данных при этом ничем не наблюдается — Authelia. Рост каталога данных при этом ничем не наблюдается —
открытый вопрос `architecture.md`. открытый вопрос `architecture.md`.
- **Перерасход денег на внешних сервисах.** Распознавание и языковая модель - **Перерасход денег на внешних сервисах.** Распознавание и языковая модель
+1 -1
View File
@@ -199,7 +199,7 @@ Telegram делит с ним общий шаг заведения задачи,
- **GIVEN** источник метаданных читает запись и отдаёт её длительность - **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **WHEN** программа шлёт запись с именем, чей хвост после последней точки не - **WHEN** программа шлёт запись с именем, чей хвост после последней точки не
принадлежит перечню known-форматов принадлежит перечню известных форматов
- **THEN** метка метрики принимает значение `other` - **THEN** метка метрики принимает значение `other`
- **AND** имя файла в хранилище сохраняет пришедшее расширение - **AND** имя файла в хранилище сохраняет пришедшее расширение
+10 -1
View File
@@ -1,7 +1,16 @@
# storage Specification # storage Specification
## Purpose ## Purpose
TBD - created by archiving change pocketbase-storage. Update Purpose after archive.
Где живут запись, её метаданные и её файл: раскладка каталога данных, приведение
схемы при подъёме, отдача файла ссылкой по токену, собственная поверхность
хранилища и панель владельца.
Приём и опрос готовности нормирует `intake`, вход и сессию — `access`.
Сознательно не описаны: перенос прежних данных — его нет по решению задачи
`pocketbase-storage`; удаление записей и файлов — сервис объявлен архивом
2026-08-11, а удаление приносит задача `delete-record`.
## Requirements ## Requirements
### Requirement: Сервис поднимается на чистом каталоге данных ### Requirement: Сервис поднимается на чистом каталоге данных
+4 -4
View File
@@ -162,10 +162,10 @@ Capability нормирует **не поведение сервиса** для
и MUST не зависеть от рабочего каталога, из которого запущен. Шаг MUST только и MUST не зависеть от рабочего каталога, из которого запущен. Шаг MUST только
читать: файлов он не правит и разошедшихся мест не чинит. читать: файлов он не правит и разошедшихся мест не чинит.
Коды выхода MUST следовать словарю прочих проверочных шагов проекта: 0 сошлось, Коды выхода MUST следовать общему словарю проверочных шагов проекта; словарь
1 расхождение, 2 ошибка употребления, 3 окружение. Своего словаря шаг MUST не объявляет раздел «Гейт» в `CLAUDE.md`, и здесь он не повторяется. Своего словаря шаг
заводить: раздел «Гейт» в `CLAUDE.md` объявляет словарь общим, и четвёртый шаг с MUST не заводить: четвёртый шаг с собственной семантикой сделал бы это
собственной семантикой сделал бы это утверждение неверным. утверждение неверным.
Место, где числа не нашлось вовсе, MUST считаться отказом с именем этого места. Место, где числа не нашлось вовсе, MUST считаться отказом с именем этого места.
«Нечего сравнивать» исходом MUST не быть: пропавшая строка иначе выглядела бы «Нечего сравнивать» исходом MUST не быть: пропавшая строка иначе выглядела бы