внутренняя модель перестроена вокруг аудиозаписи

- audiorecords вместо transcribe_jobs: приложения (texts, structures,
  recognitions, record_events, topics) живут своими коллекциями, ссылки на
  исходник и на приведённую копию перестали переставляться
- рубеж называет достигнутое, отказ стал признаком остановки с причиной, а
  сторожей стало двое: число отказов и время в рубеже
- воркеры потеряли специализацию, их число задаётся [pipeline] workers, шаг
  выбирается по рубежу, а захват отдаёт идентификатор и признак захвата
This commit is contained in:
av
2026-08-14 20:20:33 +03:00
parent d079f03350
commit 1576d06735
84 changed files with 8973 additions and 2865 deletions
@@ -0,0 +1,51 @@
# Остановка записи — признак, а не рубеж
- **Дата:** 2026-08-14
- **Источник:** openspec/changes/archive/2026-08-14-record-centric-model/design.md,
раздел «Остановка — признак, а не рубеж»
## Решение
Прежние состояния отказа и смерти (`failed`, `dead`) схлопнуты в **признак
остановки** с причиной: `halted_at`, `halt_reason`, `error_text`. Достигнутый
рубеж при остановке не стирается, и снятие признака продолжает работу с того
места, где запись встала.
## Почему
Цитата источника:
> **Признак** — принято: рубеж переживает остановку, продолжение идёт с места
> остановки, массовый перезапуск после выкатки правки делается одним
> обновлением, а различие «мы рассудили» против «мы перестали пробовать»
> остаётся причиной, которую человек читает.
Отвергнуты два варианта, оба с названной ценой:
> **Отдельное состояние на каждую причину** — отвергнуто: перечень состояний
> закрыт схемой, и каждая новая причина стоила бы необратимого шага.
>
> **Оставить как есть** — отвергнуто: именно из-за этого перезапись состояния
> руками в панели остаётся единственным способом вернуть запись в работу, и
> делается он наугад.
Прежняя модель описана решением
[ADR-2026-08-11-queue-as-pocketbase-collection](ADR-2026-08-11-queue-as-pocketbase-collection.md):
там состояние «мертва» заводилось взамен признака `is_error`, и довод был тот
же — «два способа вывести задачу из выборки расходятся». Довод устоял, а
носитель сменился: теперь единственный способ вывести запись из выборки — этот
признак, и состояние его больше не дублирует.
## Последствия
- `+` перезапуск перестал быть догадкой: запись продолжает с сохранённого
рубежа, а не начинает конвейер заново.
- `+` новая причина остановки стоит значения в закрытом перечне причин, а не
нового состояния и не нового шага схемы.
- `+` массовый возврат в работу после выкатки правки делается одним обновлением
колонки.
- `` в выборке захвата появилось четвёртое условие, и рубеж перестал быть
единственным, что выводит запись из работы: читать состояние записи теперь
надо двумя полями.
- `` перечень причин закрыт схемой, то есть новая причина всё же требует шага
схемы — дешевле прежнего, но не бесплатно.
@@ -0,0 +1,47 @@
# Ответ распознавателя хранится дословно, двоичной формой и вложением
- **Дата:** 2026-08-14
- **Источник:** openspec/changes/archive/2026-08-14-record-centric-model/design.md,
раздел «Сырой ответ провайдера хранится вложением, а не колонкой»
## Решение
Ответ SpeechKit сохраняется целиком — сообщения потока подряд, каждое своей
двоичной записью с длиной впереди, — и лежит **вложением** коллекции попыток
распознавания, а не колонкой.
## Почему
Цитата источника:
> Хранится он вообще потому, что **результат операции у провайдера не
> переспрашивается**. Отвергнутый вариант — не хранить и разобрать на лету:
> дешевле сегодня, но связь реплики с говорящим мы строить пока не умеем, и
> когда научимся, архив пересчитать будет не из чего, а повторная операция стоит
> денег за каждую запись.
Вложением, а не колонкой:
> Ответ на многочасовую запись — мегабайты. Хранилище читает запись целиком, а
> шаг опроса читает строку попытки раз в несколько секунд: положенный колонкой,
> ответ ехал бы в память при каждом опросе — тот же промах, что расшифровка в
> перечне колонок захвата сегодня.
Двоичной формой, а не текстовой, — решение ревью кода того же изменения. Замер:
текстовое представление собирается по нашей скомпилированной схеме и **молча
выбрасывает поля, которых в ней нет**, а провайдер добавляет их без
предупреждения. Двоичная форма неизвестные поля переносит: они переживают запись
и чтение и станут читаемыми, когда схема обновится. Ради этого архив и заводился.
## Последствия
- `+` архив пересчитывается из сохранённого без единого рубля: связь реплики с
говорящим станет доступна, когда мы научимся её читать.
- `+` шаг опроса читает строку попытки, не поднимая мегабайты в память.
- `` **формат файла на диске объявлен необратимым**: сохранённое не читается
глазами и не разбирается ничем, кроме нашего же кода, а прочесть архив без
сервиса нельзя вовсе.
- `` каталог данных растёт быстрее прежнего: ответ многословнее самой
расшифровки — несёт альтернативы, время каждого слова и разбор говорящих.
Потолок в 256 МиБ на вложение назван строкой в `database.md`, а сколько там на
деле у шестичасовой записи, не мерил никто.
@@ -0,0 +1,45 @@
# Предел простоя остаётся часом, хотя он короче самой работы
- **Дата:** 2026-08-14
- **Источник:** openspec/changes/archive/2026-08-14-record-centric-model/design.md,
раздел «Сторожей двое, и предела времени — два числа»
## Решение
Сторож застревания ограничивает время записи в рубеже двумя числами: **час** на
свою работу, **сутки** на ожидание чужой операции. Час меньше времени, которое
многочасовая запись занимает на приведении, и это принято сознательно.
## Почему
Ревью дизайна показало, что число противоречит расчётному потолку записи:
> Расчётный потолок записи — шесть часов, приведение такой записи идёт дольше
> часа по построению, а срок захвата шага приведения стоит сегодня восемью
> часами. Значит длинная запись, отказавшая один раз и ждущая повтора дольше
> часа, будет остановлена сторожем застревания вместо расшифровки.
Предложено было вывести предел из срока захвата — двенадцать часов на свою
работу. Владелец решил оставить час, и довод записан цитатой:
> Оставляем час. Тут нужно принять, что это скорее про зависшую задачу, потому
> что пока идёт обработка даже длинной записи мы всегда можем проверить, жив ли
> процесс конвертера.
Довод держится на том, что остановка теперь **обратима**: она не стирает рубежа,
и снятие признака возвращает запись туда, где она стояла (см.
[ADR-2026-08-14-halt-is-a-flag-not-a-stage](ADR-2026-08-14-halt-is-a-flag-not-a-stage.md)).
Цена ложной остановки поэтому равна одному движению владельца, а не потерянной
записи.
## Последствия
- `+` зависшая запись обнаруживается за час, а не за восемь.
- `+` число живёт в настройках и правится без шага схемы, если класс начнёт
всплывать.
- `` длинная запись, отказавшая один раз и прождавшая повтора дольше часа,
останавливается как застрявшая — владельцу приходится снимать признак руками.
- `` предел этот работает только по записи, вернувшейся в выборку. У держателя,
погибшего жёстко, запись невидима сторожу до истечения **срока захвата** её
рубежа, то есть восьми часов у приведения; замер и оговорка стоят строкой в
`database.md`.
+3
View File
@@ -35,6 +35,9 @@
| Дата | Запись | Статус |
| --- | --- | --- |
| 2026-08-14 | [Предел простоя остаётся часом, хотя он короче самой работы](ADR-2026-08-14-stuck-limit-stays-an-hour.md) | |
| 2026-08-14 | [Ответ распознавателя хранится дословно, двоичной формой и вложением](ADR-2026-08-14-provider-payload-stored-verbatim.md) | |
| 2026-08-14 | [Остановка записи — признак, а не рубеж](ADR-2026-08-14-halt-is-a-flag-not-a-stage.md) | |
| 2026-08-14 | [Учётная запись с записями не удаляется, и это осознанный тупик](ADR-2026-08-14-account-with-records-is-not-deleted.md) | |
| 2026-08-13 | [Намерение объявляется признаком, а не выводится из ключа доступа](ADR-2026-08-13-telegram-intent-declared-not-inferred.md) | |
| 2026-08-13 | [Недоступность Telegram подъёму сервиса не мешает](ADR-2026-08-13-telegram-outage-does-not-block-startup.md) | |
+49 -23
View File
@@ -32,6 +32,11 @@
- [storage](../openspec/specs/storage/spec.md) — где живут запись, её метаданные
и её файл, как файл отдаётся и что видит владелец: задача `pocketbase-storage`
2026-08-12;
- [recognition](../openspec/specs/recognition/spec.md) — **попытка распознавания
у внешнего провайдера**: что о ней хранится, почему сырой ответ сохраняется
целиком и вложением, как из сохранённого строится структура реплик без
повторной оплаты и почему разбор формата провайдера не доходит до конвейера.
Задача `record-centric-model` 2026-08-14;
- [access](../openspec/specs/access/spec.md) — кто пришёл в сервис и пускают ли
его дальше: вход через внешнего провайдера OIDC, чем предъявляется сессия, что
её прекращает и какие адреса остаются открытыми. Задача `oidc-login`
@@ -78,22 +83,22 @@
| --- | --- | --- |
| Telegram-бот | `internal/controller/tg` | Принимает голосовые, аудиофайлы и документы с аудио, скачивает их, заводит задачу |
| HTTP API | `internal/controller/http` | Приём файла и опрос статуса задачи |
| Воркеры | `internal/controller/worker` | Крутят по одному шагу конвейера, опрашивая базу |
| Сервис расшифровки | `internal/service` | Конвейер: приём, конвертация, распознавание, отдача результата |
| Воркеры | `internal/controller/worker` | Пул одинаковых потоков: каждый берёт любую пригодную запись и опрашивает базу. Число — настройкой, ноль законен |
| Сервис расшифровки | `internal/service` | Конвейер: приём, приведение, отправка, опрос, завершение. Шаг выбирается по рубежу записи |
| Конвертер и метаданные | `internal/adapter/{converter,metaviewer}/ffmpeg` | `ffmpeg` в ogg/vorbis, `ffprobe` для длительности |
| Распознаватель | `internal/adapter/recognizer/yandex` | Заливка в Object Storage и отложенное распознавание SpeechKit |
| Распознаватель | `internal/adapter/recognizer/yandex` | Заливка в Object Storage и отложенное распознавание SpeechKit; разбор ответа в реплики со временем |
| Отправитель Telegram | `internal/adapter/telegram` | Отправка текста, деление длинного по словам |
| Репозитории | `internal/adapter/repo/pocketbase` | Задачи и файлы коллекциями хранилища; захват — сырым запросом |
| Репозитории | `internal/adapter/repo/pocketbase` | Записи, файлы, тексты, структура, попытки распознавания и журнал событий — коллекциями хранилища; захват — сырым запросом |
| Шаги схемы | `internal/adapter/repo/pocketbase/migrations` | Файл на шаг, имя файла — имя шага; там же имена коллекций |
| Панель владельца | `internal/adapter/repo/pocketbase`, `panel.go` | Панель хранилища; правила правки задачи нормирует [storage](../openspec/specs/storage/spec.md), «Владелец видит записи в панели» |
| Панель владельца | `internal/adapter/repo/pocketbase`, `panel.go` | Панель хранилища; правила правки записи нормирует [storage](../openspec/specs/storage/spec.md), «Владелец видит записи в панели» |
<!-- канон: поведение → openspec/specs/pipeline; ещё НЕ переехало: цепочка переходов состояний -->
Конвейер: `created``converted``transcribe``done` либо `failed`. Каждый
переход двигает свой воркер, и каждый опрашивает базу раз в секунду. Что
делает задача, исчерпавшая попытки, нормирует
[pipeline](../openspec/specs/pipeline/spec.md), «Число попыток и состояние
«мертва»».
Цепочка рубежей — `uploaded``normalized``submitted``transcribed`
`done`; рубеж называет достигнутое, а не предстоящее, и нормирует его
[pipeline](../openspec/specs/pipeline/spec.md), «Рубеж записи называет
достигнутое». Отказ рубежом не является: он ставит признак остановки, а рубеж
сохраняется — там же, «Остановка записи — признак, а не рубеж». Шаг выбирается
по рубежу одним местом, воркеры к шагам не привязаны, а их число приходит
настройкой.
## Внешние границы и форматы
@@ -136,6 +141,17 @@
поделят один длинный опрос и часть ответов до людей не дойдёт.
Ревью кода воспроизвело порядок на прежней версии, живой прогон — на нынешней.
- **Откат образа через шаг схемы `202608140002` не работает и не говорит об
этом.** Шаг удаляет прежнюю коллекцию задач, а библиотека накатывает только
те шаги, которые знает сам бинарь: прежний образ шагов новее не видит,
поднимается **без единой ошибки** и отвечает зелёной пробой здоровья — после
чего всякое обращение к очереди отказывает «коллекции нет». Проверено прогоном
двух бинарей на одном каталоге данных.
Значит штатное средство владельца на инциденте — «вернём прошлый образ» — с
этого шага делает хуже и молчит. Лечится повторной выкладкой нового образа;
обратного шага схемы нет и не планируется. Порог перехода назван прямо: до
выкладки `record-centric-model` откат образа работает, после — нет.
- **Внешние зависимости поимённо и чем каждая отказывает.** Столбец «отвечает
медленно» читается вместе с тем, что таймаута нет ни у одного обращения
наружу — [database.md](database.md), «Настройки с числовым значением»:
@@ -145,17 +161,23 @@
| Зависимость | Падает | Отвечает медленно | Молчит | Отдаёт мусор |
| --- | --- | --- | --- | --- |
| Telegram Bot API | Сервис поднимается без Telegram и работает по HTTP; старт роняют только ошибки настройки — ответ «такого бота нет» и включённый вход с пустым ключом доступа. Норму держит [intake](../openspec/specs/intake/spec.md), «Признак включения решает, поднимается ли вход Telegram» | На старте — ждём не дольше срока, дальше поднимаемся без Telegram. У поднятого сервиса скачивание файла висит бесконечно: там срока нет | То же, что «отвечает медленно»: на старте — подъём без Telegram по истечении срока, у поднятого — длинный опрос пуст и новые задачи не заводятся | Файл скачался битым, отказ вылезет на конвертации |
| Yandex SpeechKit | Шаг возвращает ошибку, задача остаётся на повтор | Захват держится час, задача не двигается | Операция вечно `in progress`, повтор каждые 5 секунд | Пустой текст — задача завершается заглушкой «на записи нет текста» |
| Yandex SpeechKit | Шаг возвращает ошибку, запись остаётся на повтор | Захват держится час, запись не двигается; по истечении предела простоя она останавливается с причиной «застряла», не теряя идентификатора операции | Операция вечно `in progress`, повтор каждые 5 секунд — до предела простоя в сутки | Пустой текст — запись завершается заглушкой «на записи нет текста» |
| ↳ *остановка сервиса* | Принятие операции от отмены защищено своим пределом в 10 секунд: операцию там могли принять и начать считать деньги, а потерянный идентификатор заставил бы повтор оплатить ту же запись второй раз. Заливка в Object Storage отменяется штатно — её повтор бесплатен, объект ложится под тем же ключом | — | — | — |
| Yandex Object Storage | Заливка падает, задача остаётся в `converted` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции |
| ffmpeg, ffprobe | Задача уходит в `failed` с текстом «сбой конвертации файла». Остановка сервиса — исход другой: процесс убивают контекстом, и задача остаётся на повтор, не тратя попытки | Конвейер стоит: вызов синхронный | — | Выходной файл пуст, отказ вылезет на распознавании |
| Yandex Object Storage | Заливка падает, запись остаётся на рубеже `normalized` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции |
| ffmpeg, ffprobe | Запись останавливается признаком с текстом «сбой конвертации файла» — рубеж при этом сохраняется, и снятие признака продолжает с него. Остановка сервиса — исход другой: процесс убивают контекстом, запись остаётся на повтор и отказа не тратит | Конвейер стоит: вызов синхронный | — | Выходной файл пуст, отказ вылезет на распознавании |
| Хранилище (файл на диске) | Приложение не стартует либо шаг падает на каждом запросе | Блокировка записи держит воркеры | — | — |
| Диск | Запись файла падает, задача не заводится | — | — | — |
- **Кто заметит отказ и когда:** пользователь Telegram — сразу, по молчанию бота
или по сообщению об ошибке. Владелец — по метрике
`transcriber_worker_job_count` с меткой `error="true"` и по логам контейнера.
Отдельного оповещения нет.
`transcriber_worker_job_count` с меткой `error="true"`, и метка `stage`
называет рубеж, с которого запись взята: с появлением пула одинаковых воркеров
имя потока перестало что-либо значить, а разрез по шагу — единственное, чем
«падает приведение» отличается от «падает распознавание». Плюс логи
контейнера. Отдельного оповещения нет.
- **Журнал событий записи** — второй канал наблюдения, `record_events`. Пишется
на смену рубежа, на остановку и на снятие остановки; читает его человек в
панели, ни один шаг конвейера на него не смотрит. Экрана у него пока нет.
- **Характер потока:** непрерывный, но разреженный. Бот держит длинный опрос,
воркеры опрашивают базу вхолостую с паузой из
[database.md](database.md), «Настройки с числовым значением».
@@ -164,12 +186,15 @@
| Что | Где |
| --- | --- |
| Приём аудио и заведение задачи | `TranscribeService.createTranscribeJob` — через него идут оба входа |
| Правка задачи владельцем | панель хранилища; правка запросом проходит правила перехода (`pocketbase.BindPanelRules`), а шаг конвейера пишет только свои поля и правку владельца не стирает |
| Захват задачи воркером | `TranscriptJobRepository.FindAndAcquire` — один запрос с `RETURNING` |
| Приём аудио и заведение записи | `TranscribeService.createRecord` — через него идут оба входа |
| Правка записи владельцем | панель хранилища; правка запросом проходит правила перехода (`pocketbase.BindPanelRules`), а шаг конвейера пишет только свои поля и правку владельца не стирает |
| Захват записи воркером | `AudioRecordRepository.FindAndAcquire` — один запрос с `RETURNING`, отдаёт идентификатор и признак захвата |
| Объявление рубежа | `internal/entity/stage.go` — выбор шага, отбор захвата, срок протухания и предел простоя выводятся отсюда |
| Выбор шага по рубежу | `TranscribeService.stepFor` — таблица, а не привязка к воркеру |
| Рабочая копия файла на диске | `FileRepository.Localize`, `Stage`, `StageEmpty` — они же дают единственный способ её убрать (`WorkFile.Close`); зовёт его шаг |
| Переход задачи в состояние | `entity.TranscribeJob.MoveToState` — чистит служебные поля прошлого состояния |
| Завершение и отказ | `TranscribeService.completeJob` и `failJob` — они же отвечают пользователю |
| Переход записи на рубеж | `entity.AudioRecord.MoveToState` — чистит служебные поля прошлого рубежа и ставит время входа |
| Откладывание работы | `entity.AudioRecord.Postpone` — ставит паузу и снимает захват, рубежа не трогая |
| Остановка и перезапуск | `entity.AudioRecord.Halt` и `Resume`; ответ отправителю — `TranscribeService.halt`, одно место на все причины |
| Разбор конфигурации | `internal/config.LoadConfig` |
| Чтение времени | `internal/clock``Now` даёт метку в UTC, `Start` — начало измерения длительности; `time.Now` вне пакета запрещён правилом линтера |
| Метрики | `internal/metrics`, префикс имени `transcriber_` |
@@ -243,7 +268,8 @@
- **Видео.** Дорожку из видеофайла бот принимает по MIME-типу `video/`, но
конвертер этот случай не проверялся.
- **Очередь.** Модель очереди сделана задачей `pocketbase-storage` 2026-08-12
([ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md)) и нормирована
([ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md)), перестроена
вокруг аудиозаписи задачей `record-centric-model` 2026-08-14 и нормирована
спекой `pipeline`. Не решено, отказываться ли от холостого опроса: он
даёт сотни тысяч запросов к базе в сутки — расчёт из числа воркеров и их
паузы, а не замер
+6 -4
View File
@@ -49,10 +49,12 @@
- Enum-поля (`state`, `source`, …) — обычный `TEXT` без `CHECK`; допустимые
значения держит код.
*Расхождение:* перечень состояний задачи закрыт схемой (`SelectField`), а не
кодом — ради панели владельца: правка руками не должна заводить состояние,
которого конвейер не знает. Цена названа: шестое состояние потребует нового
шага схемы.
*Расхождение:* перечни, по которым панель владельца правит запись руками,
закрыты схемой (`SelectField`), а не кодом: правка руками не должна заводить
значение, которого сервис не знает. Закрыты рубеж записи, причина её
остановки, вид текста, источник и исход события журнала. Цена названа: новое
значение любого из них потребует нового шага схемы, а применённый шаг не
переписывается. Прочие перечни остаются обычным `TEXT`.
- Временные метки — `TEXT` в **RFC 3339, UTC (суффикс `Z`)**, например
`2006-01-02T15:04:05Z` (секундная точность). Фиксированная ширина сохраняет
лексикографическую сортировку TEXT, то есть хронологию (`ORDER BY created_at`).
+2 -1
View File
@@ -91,7 +91,8 @@
| Ядро (`internal/service`) не знает ни адаптеров, ни транспортов | `internal/archrules``TestЯдроНеЗнаетОбАдаптерах`, `TestЯдроНеЗнаетОТранспортах` |
| Транспорты (`controller/http`, `controller/tg`, `controller/worker`) не знают друг о друге | `internal/archrules``TestТранспортыНеЗнаютДругОДруге` |
| Адаптер не знает ни ядра, ни транспортов | `internal/archrules``TestАдаптерыНеЗнаютНиЯдра_НиТранспортов` |
| Колонки очереди согласованы: перечень захвата ↔ структура захвата ↔ шаг схемы ↔ запись коллекции ↔ перенос поля в задачу | `internal/archrules` → правила о захвате. Закрывает инвариант «колонки правятся в четырёх местах» (CLAUDE.md, major), которого компилятор не держит. Литерал колонки ищется в телах нужных функций: по файлу целиком условие выполнялось бы тегами `db:"…"` самой структуры, и правило было бы зелёным всегда |
| Колонки записи согласованы: что пишет отображение ↔ что читает обратное ↔ что заводит шаг схемы | `internal/archrules` → правила о колонках. Закрывает инвариант «колонки записи правятся в двух местах» (CLAUDE.md, major), которого компилятор не держит. Литерал колонки ищется в телах нужных функций, а не в файле целиком |
| Рубежи согласованы: дескриптор ↔ таблица выбора шага, в обе стороны | `internal/archrules` → правила о рубежах. Закрывает инвариант «рубеж объявляется одним дескриптором» (CLAUDE.md, major). Рубеж без шага останавливает запись, не начав работы; шаг без рубежа недостижим — захват такую запись не выдаст никогда |
### Отмена и внешний собеседник
+10 -10
View File
@@ -28,22 +28,22 @@ OpenSpec.
`jq` без регулярных выражений.
```json
{"time":"2026-08-10T11:23:45.123456Z","level":"INFO","msg":"job accepted","capability":"intake","job_id":"…","source":"telegram","duration_seconds":137}
{"time":"2026-08-10T11:23:45.123456Z","level":"INFO","msg":"record accepted","capability":"intake","record_id":"…","source":"telegram","duration_seconds":137}
```
*Расхождение:* `main.go` ставит `slog.NewTextHandler(os.Stdout, …)`.
## Сообщение
- `msg` — короткая константа в нижнем регистре: `job accepted`,
- `msg` — короткая константа в нижнем регистре: `record accepted`,
`recognition done`, `conversion failed`. Данные — в атрибутах:
`log.Info("job accepted", "job_id", id, "source", "telegram")`.
`log.Info("record accepted", "record_id", id, "source", "telegram")`.
- `msg` — чистая категория без префикса подсистемы: `recognition done`, а не
`recognize: done`. Подсистему выносим в поле `capability`, не в текст.
- **Смена состояния задачи — единая категория `state transition`** с полями
`from`, `to` и причиной. Любой переход пишет этот `msg`, чтобы весь
жизненный цикл собирался одним отбором:
`jq 'select(.msg=="state transition" and .job_id=="…")'`. Физический эффект
`jq 'select(.msg=="state transition" and .record_id=="…")'`. Физический эффект
сверх перехода — отдельная запись своей категории (`file converted`,
`text delivered`), она запись перехода не подменяет.
@@ -100,13 +100,13 @@ OpenSpec.
| Когда добавляем | Поля |
| --- | --- |
| на входящий HTTP-запрос | `transport` (`http`, `telegram`), `http.method`, `http.route`, `http.status_code`, `duration_ms` |
| на задачу | `capability` (значения — по именам заведённых capability в `openspec/specs/`), `job_id`, `file_id`, `source` |
| на задачу | `capability` (значения — по именам заведённых capability в `openspec/specs/`), `record_id`, `file_id`, `source` |
| на запись об ошибке | `error` |
| на вызов внешнего сервиса | `ext.service`, `ext.operation`, `ext.status_code`, `duration_ms`, `retry` |
Не заводим `service.*` и `host.*` — для одного бинарника на одном хосте это шум.
*Расхождение:* в коде встречаются `job_id`, `file_id`, `operation_id`,
*Расхождение:* в коде встречаются `record_id`, `file_id`, `operation_id`,
`worker`, `path`, `src_path`, `dest_path` — то есть словарь сложился сам и
пересечён с этим лишь частично.
@@ -120,16 +120,16 @@ OpenSpec.
чтобы ключ дописывался на каждую запись сам:
```go
log := log.With("job_id", job.Id, "capability", "conversion")
log := log.With("record_id", record.Id, "capability", "conversion")
```
- Все записи одной задачи собираются одним отбором:
`jq 'select(.job_id=="…")' app.jsonl`.
`jq 'select(.record_id=="…")' app.jsonl`.
## Ошибки
Ошибки Go логируем как атрибут, а не как текст сообщения:
`log.Error("conversion failed", "error", err, "job_id", id)`. Ключ — `error`.
`log.Error("conversion failed", "error", err, "record_id", id)`. Ключ — `error`.
- Идиома Go — **либо лог, либо возврат, не оба**. Промежуточные слои только
оборачивают и возвращают (`fmt.Errorf("…: %w", err)`), не логируя — контекст
@@ -277,5 +277,5 @@ Bot API, поэтому чистка на месте употребления з
## Анализ
- Повседневно — `jq`: `jq 'select(.job_id=="…")' app.jsonl`.
- Повседневно — `jq`: `jq 'select(.record_id=="…")' app.jsonl`.
- Тяжёлое (сведение, соединение) — DuckDB поверх JSONL прямо из файла.
+184 -52
View File
@@ -38,85 +38,191 @@ CGO сборке не нужен.
### `files`
Один файл на одну физическую копию: исходник, результат конвертации и копия в
Object Storage — каждая своей записью.
Одна запись на одну физическую копию. Копий у аудиозаписи ровно две: принятая и
приведённая к рабочему формату. Копия во внешнем хранилище файлом записи не
считается — она существует только потому, что провайдер распознавания читает
аудио по адресу, и её ключ живёт в строке попытки распознавания.
| Поле | Тип | Что |
| --- | --- | --- |
| `id` | TEXT PK | Идентификатор записи, выдаёт хранилище |
| `file` | file | Сам файл; пусто у копии в Object Storage |
| `file` | file | Сам файл |
| `owner` | relation → `users` | Владелец файла; пусто у файлов записи, принятой ботом |
| `location` | select | `local` или `s3` |
| `object_key` | TEXT | Ключ объекта; пусто у местной копии |
| `object_key` | TEXT | Ключ объекта; заведён прежним шагом и новым путём не заполняется |
| `size` | INTEGER | Размер в байтах |
| `format` | TEXT | Расширение без точки, в нижнем регистре |
| `duration_ms` | INTEGER | Длительность, если её удалось прочитать |
| `created`, `updated` | DATETIME | Проставляет хранилище |
Поле названо `location`, а не `storage`: последним словом зовут само хранилище и
capability, и третий смысл развёл бы одно слово по разным вещам.
### `transcribe_jobs`
### `audio_records`
Задача расшифровки и она же очередь.
Аудиозапись — центральная сущность сервиса. Домен, поля очереди и ссылки на
приложения лежат здесь; содержимое — по ссылкам, отдельными строками.
| Поле | Тип | Что |
| --- | --- | --- |
| `id` | TEXT PK | Идентификатор записи, выдаёт хранилище |
| `owner` | relation → `users` | Владелец записи; пусто у записей, принятых ботом |
| `state` | select | `created`, `converted`, `transcribe`, `done`, `failed`, `dead`; перечень закрыт схемой |
| `source` | select | `api`, `telegram`, `unknown` |
| `file` | relation → `files` | **Текущий** файл задачи: шаг конвейера переставляет ссылку на свой результат |
| `delay_time` | DATETIME | Не брать задачу раньше этого времени |
| `acquisition_id` | TEXT | Кто захватил задачу |
| `acquire_time` | DATETIME | Когда захватил; по нему считается протухание |
| `attempts` | INTEGER ≥ 0 | Число попыток: растёт при захвате, обнуляется на шаге без отказа |
| `recognition_op_id` | TEXT | Идентификатор операции в Yandex Cloud |
| `transcription_text` | editor | Результат распознавания |
| `title`, `brief` | TEXT | Заголовок и краткое описание: читаются вместе со списком |
| `state` | select | Рубеж: `uploaded`, `normalized`, `submitted`, `transcribed`, `done`; перечень закрыт схемой |
| `state_entered_at` | DATETIME | Время входа в рубеж — сторож застревания |
| `halted_at` | DATETIME | Признак остановки; рубеж при ней не стирается |
| `halt_reason` | select | `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 | Проставляет хранилище |
Индекс один — по `state`: выборка воркера идёт по нему, паузе и сроку захвата.
Прежней колонки `is_error` нет: задача выбывает из выборки состоянием, и способ
этот один.
Индекс один — по паре «рубеж и признак остановки»: выборка захвата идёт по ним,
паузе и сроку протухания.
**Состояния `failed` и `dead` — разные приговоры**, и чей это приговор, нормирует
[pipeline](../openspec/specs/pipeline/spec.md), «Число попыток и состояние
«мертва»». Схеме принадлежит только закрытость перечня: шестое состояние
потребует нового шага.
**Ссылки на файлы две и порознь.** Прежняя модель держала одну и переставляла её
каждым шагом: у прошедшей конвейер записи она вела на копию во внешнем
хранилище, и принятого человеком файла не найти было ничем.
**Остановка — признак, а не рубеж.** Прежние состояния `failed` и `dead`
схлопнуты в `halted_at` с причиной: обе восстанавливаются одинаково — снятием
признака, — и различие между ними перестало быть структурным. Рубеж при
остановке сохраняется, поэтому запись продолжает с места остановки.
**Сторожей двое.** `attempts` ограничивает повторы внутри шага,
`state_entered_at` — застревание. Прежде обе обязанности несло одно число, и не
справлялось ни с одной.
### `texts`
| Поле | Тип | Что |
| --- | --- | --- |
| `record` | relation → `audio_records` | Чья это расшифровка |
| `kind` | select | `transcript` или `literary` |
| `contents` | editor | Сам текст |
Пара «запись и вид» уникальна: повтор прерванного шага не заводит второй строки.
Поле зовётся `kind`, а не `format`: словом `format` в этой же схеме зовут формат
файла.
### `structures`
| Поле | Тип | Что |
| --- | --- | --- |
| `record` | relation → `audio_records` | Чья это структура |
| `version` | INTEGER | Версия вида разбора |
| `contents` | JSON | Реплики со временем |
Пара «запись и версия разбора» уникальна. Номер версии нужен потому, что разбор
сохранённого ответа изменится раньше, чем архив пересчитают.
### `recognitions`
Попытка распознавания у внешнего провайдера — всё, что зависит от него.
| Поле | Тип | Что |
| --- | --- | --- |
| `record` | relation → `audio_records` | Чья это попытка |
| `provider`, `model` | TEXT | Кем и какой моделью считано |
| `external_id` | TEXT | Идентификатор операции у провайдера |
| `source_uri` | TEXT | Адрес, по которому провайдер читает аудио |
| `payload` | file, **защищённое** | Сырой ответ провайдера целиком |
| `started_at`, `finished_at` | DATETIME | Границы операции |
**Сырой ответ лежит вложением, а не колонкой.** Шаг опроса читает эту строку раз
в несколько секунд, а хранилище читает запись целиком: ответ на многочасовую
запись ехал бы в память при каждом опросе. Хранится он потому, что результат
операции у провайдера не переспрашивается.
Поле вложения помечено защищённым: сырой ответ — это полный текст речи, и
умолчание библиотеки отдавало бы его по ссылке любому, кто её знает.
### `record_events`
| Поле | Тип | Что |
| --- | --- | --- |
| `record` | relation → `audio_records` | Чьё это событие |
| `origin` | select | `pipeline` или `human` |
| `step` | TEXT | Имя шага |
| `outcome` | select | `done`, `failed`, `halted`, `resumed` |
| `outcome_text` | TEXT | Причина, если она есть |
| `duration_ms` | INTEGER | Сколько шаг занял |
Колонка текста зовётся `outcome_text`, а не `error_text`: последнее имя названо
поимённо инвариантом о секрете, и две колонки с этим именем сделали бы инвариант
двусмысленным.
Журнал пишется на смену рубежа, на остановку и на снятие остановки — не на
каждое откладывание опроса. Ни один шаг конвейера его не читает, чтобы решить,
что делать дальше.
### `topics`
| Поле | Тип | Что |
| --- | --- | --- |
| `owner` | relation → `users` | Чей это словарь |
| `name` | TEXT | Название темы |
Пара «владелец и название» уникальна: словарь тем свой у каждого человека.
Коллекцией, а не набором строк в записи, потому что перечень тем нужен целиком
перед каждым обращением к языковой модели. Ни один шаг сегодняшнего сервиса тем
не пишет и не читает — место заведено вперёд, чтобы задача, считающая темы, не
платила вторым необратимым шагом схемы.
### Чего в схеме больше нет
Коллекция `transcribe_jobs` удалена шагом `202608140002`. Данных под ней не было:
сервис на сервере остановлен, а прежние записи удалены решением владельца
2026-08-14 — переноса это изменение не делало. Оставленная пустая коллекция
висела бы в панели вторым домом для понятия, которого больше нет.
**Владелец записи** заведён шагом `202608140001` — связью с коллекцией `users` в
обеих таблицах. Пустое значение допустимо, и это решение с ценой: записи,
принятые ботом, владельца не имеют вовсе, потому что связи чата Telegram с
учётной записью сервис не ведёт. Обязательность для приёма по HTTP держит
поэтому сам приём, а не схема.
учётной записью сервис не ведёт. Обязательность для приёма по HTTP держит поэтому
сам приём, а не схема.
Выборка по владельцу сужает **чтение задачи**: чужая, ничья и несуществующая
Выборка по владельцу сужает **чтение записи**: чужая, ничья и несуществующая
дают один и тот же отказ. Выборку воркера владелец не сужает — конвейер
обрабатывает записи всех. Тот же шаг сужает правило просмотра коллекции
`files` владельцем: прежнее правило пускало всякого вошедшего, и знание
идентификатора файловой записи равнялось праву скачать чужое аудио.
обрабатывает записи всех. Тот же шаг сузил правило просмотра коллекции `files`
владельцем: прежнее правило пускало всякого вошедшего, и знание идентификатора
файловой записи равнялось праву скачать чужое аудио.
**Учётная запись с задачами не удаляется.** Каскадное удаление у связи выключено,
**Учётная запись с записями не удаляется.** Каскадное удаление у связи выключено,
но одного этого мало: при выключенном каскаде хранилище снимает ссылку и
сохраняет запись без проверок — задачи остались бы, но стали бы ничьими, а ничья
задача не достаётся никому. Отказ ставит слой приложения `GuardOwnerDeletion`,
сохраняет запись без проверок — записи остались бы, но стали бы ничьими, а ничья
запись не достаётся никому. Отказ ставит слой приложения `GuardOwnerDeletion`,
а не правило коллекции: панель ходит правами суперпользователя, и правило её не
судит. Цена названа прямо — владелец панели упирается в отказ, а удаления
записей в сервисе пока нет вовсе.
судит. Считаются все коллекции с колонкой владельца — `audio_records`, `files` и
`topics`, — и перечень живёт одним местом: пропущенная коллекция пропускает
удаление вперёд, а наружу приезжает подсказка библиотеки про обязательную связь
вместо нашего отказа с причиной.
**Правила доступа задач пусты**, то есть перечислять и читать их может только
владелец панели. Проверено прогоном: анонимный запрос к
`/api/collections/*/records` отвечает `403`, к `/api/logs`, `/api/backups`,
`/api/settings` и `/api/crons``401`.
**Правила доступа новых коллекций пусты**, то есть перечислять и читать их может
только владелец панели. Содержимое записи отдаёт собственный адрес сервиса, а не
поверхность хранилища; непустое правило открыло бы перечисление коллекции впрок.
Проверено прогоном: анонимный запрос к `/api/collections/*/records` отвечает
`403`, к `/api/logs`, `/api/backups`, `/api/settings` и `/api/crons``401`.
## Представление данных
Чем физически лежит запись и что происходит при чтении и записи.
- **Расшифровка лежит целиком в поле `transcription_text`** одной строкой.
Запись длиной в час даёт десятки килобайт в одной ячейке; читается она
целиком при каждом чтении задачи и при каждом захвате.
- **Расшифровка лежит отдельной строкой `texts`**, а не колонкой записи. Захват
её не тянет вовсе: он возвращает **идентификатор и признак своего захвата**, а
колонки шаг читает отдельным чтением. Прежде расшифровка стояла колонкой той
же строки и читалась при каждом опросе очереди.
- **Аудио лежит в раскладке хранилища:**
`data/storage/<коллекция>/<запись>/<имя>` рядом с файлом атрибутов. Имя задаёт
сервис — `<uuid><расширение>`; собственного суффикса хранилище не дописывает,
@@ -135,19 +241,28 @@ capability, и третий смысл развёл бы одно слово п
Без этого сужения закрытие API обходится двумя запросами — завести себе
запись и войти паролем. Продление сессии закрыто слоем в приложении, а не
настройкой коллекции: библиотека выдаёт сессию продлеваемой всегда.
- **Захват задачи — один запрос с `RETURNING`**, мимо записей коллекции.
- **Захват записи — один запрос с `RETURNING`**, мимо записей коллекции.
`app.DB()` направляет всё, кроме выборок, в пул с единственным соединением,
поэтому захваты выстраиваются в очередь. Порядок выборки — по времени
заведения **и по ключу**: время неуникально, и без ключа порядок обработки
невоспроизводим.
невоспроизводим. Отбор идёт по рубежам из дескриптора, паузе, сроку протухания
захвата и отсутствию признака остановки; срок протухания выбирается по рубежу
самой записи прямо в запросе — воркер, ещё не знающий, что вытянет, подставить
его не может.
- **Запись результата условна по признаку захвата** — инвариант «Результат пишет
только держатель захвата» в [CLAUDE.md](../CLAUDE.md), «Инварианты» (major);
норма — [pipeline](../openspec/specs/pipeline/spec.md). Здесь названо потому,
что условие проверяется тем же запросом, что и сам захват.
- **Список колонок задан четырьмя местами** — `applyToRecord`, `recordToJob`,
константой `acquireColumns` и структурой `acquiredRow`, — плюс шагом схемы.
Все четыре лежат в одном пакете, но компилятор видит два: правило правки и его
серьёзность (critical/major) — инвариант в [CLAUDE.md](../CLAUDE.md), «Инварианты».
- **Список колонок задан двумя местами** — `applyOwnedByPipeline` вместе с
`applyToRecord` и `recordToAudioRecord`, — плюс шагом схемы. Мест было четыре,
пока захват перечислял колонки поимённо; теперь он возвращает идентификатор, и
перечень перестал расти с моделью. Правило правки и его серьёзность —
инвариант в [CLAUDE.md](../CLAUDE.md), «Инварианты»; сверку держат правила
`internal/archrules`.
- **Перечень рубежей объявлен одним дескриптором** — `internal/entity/stage.go`.
Из него выводятся выбор шага, отбор захвата, срок протухания и предел простоя:
рубеж, забытый в отборе, не выдаётся ни одному воркеру никогда, а пустой прогон
по инварианту проекта не пишется в журнал и не считается в метрику.
- **Отказ хранилища наружу не выходит дословно.** Он несёт ключ файла целиком, а
ключ — последняя часть ссылки на скачивание; поэтому чтение и укладка отдают
свой текст с идентификатором записи, а цепочку `%w` обрывают. То же у выгрузки
@@ -157,14 +272,22 @@ capability, и третий смысл развёл бы одно слово п
| Настройка | Значение | Где | Откуда число |
| --- | --- | --- | --- |
| Предел попыток | 5 | `service/transcribe.go` | обычное умолчание, не замер |
| Пауза перед повтором | `2^(попытка−1)` с, потолок 5 минут | там же | то же |
| Срок захвата, конвертация | 8 часов | там же | потолок записи 6 часов плюс запас |
| Срок захвата, распознавание | 8 часов | там же | то же |
| Срок захвата, проверка операции | 1 час | там же | опрос идёт секунды |
| Задержка перед первой проверкой операции | 10 секунд | там же | как было |
| Предел отказов | 5 | `service/transcribe.go` | обычное умолчание, не замер |
| Пауза перед повтором | `2^(отказ1)` с, потолок 5 минут | там же | то же |
| Срок захвата, приведение | 8 часов | `entity/stage.go` | потолок записи 6 часов плюс запас |
| Срок захвата, отправка на распознавание | 8 часов | там же | то же |
| Срок захвата, опрос операции | 1 час | там же | опрос идёт секунды |
| Срок захвата, завершение | 1 час | там же | запись текста и ответ идут секунды |
| Число воркеров конвейера | 3 | конфиг, `[pipeline] workers` | решение владельца; ноль — законное значение |
| Предел простоя, своя работа | 60 минут | конфиг, `[pipeline] own_work_limit_minutes` | решение владельца 2026-08-14: сторож ловит зависание, а не долгую работу. Число **меньше** времени приведения многочасовой записи, и цена названа прямо — остановка обратима. Предел этот работает только по записи, вернувшейся в выборку: см. строку ниже |
| Предел простоя, чужая операция | 1440 минут | конфиг, `[pipeline] foreign_work_limit_minutes` | сколько идёт распознавание долгой записи, никто не мерил: ошибаемся в сторону долгого |
| Версия вида структуры реплик | 1 | `entity.StructureVersion` | первая |
| Потолок тем на запись | 5 | `entity.MaxTopicsPerRecord` | решение владельца: без него часовой разговор даёт два десятка тем |
| Потолок сохранённого ответа провайдера | 256 МиБ | шаг `202608140002` | ответ многословнее расшифровки: несёт альтернативы, время каждого слова и разбор говорящих |
| Потолок структуры реплик | 16 МиБ | там же | шестичасовой разговор даёт порядка мегабайта текста с временем |
| Задержка перед первой проверкой операции | 10 секунд | `service/transcribe.go` | как было |
| Задержка между проверками операции | 5 секунд | там же | как было |
| Пауза воркера между попытками | 1 секунда | `controller/worker/worker.go` | как было |
| Пауза воркера между прогонами | 1 секунда | `controller/worker/worker.go` | как было |
| Предел длины сообщения Telegram | 4000 символов | `adapter/telegram/sender.go` | предел Telegram |
| Таймаут мягкой остановки | 5 секунд | конфиг, `[server] shutdown_timeout` | — |
| Таймаут жёсткой остановки | 20 секунд | конфиг, `[server] force_shutdown_timeout` | — |
@@ -177,6 +300,15 @@ capability, и третий смысл развёл бы одно слово п
| Потолок времени на вход у провайдера | 10 минут | `controller/http/auth.go` | дольше носитель состояния не нужен |
| Таймаут обмена кода у провайдера | 15 секунд | там же | молчащий провайдер иначе держит обработчик возврата открытым |
**У сторожа простоя есть второй потолок, и он не тот, что в настройке.** Предел
простоя проверяется в момент захвата, а захват не выдаёт запись, чей срок
протухания ещё не истёк. Значит для держателя, погибшего жёстко — контейнер убит
по нехватке памяти или `docker kill`, — запись невидима сторожу до истечения
**срока захвата** её рубежа, то есть восьми часов у приведения и отправки.
Замерено прогоном: до истечения срока повторный захват записи не выдаёт, и
остановка «застряла» наступает только после него. Мягкая остановка сюда не
подпадает: она снимает захват сама.
**Потолок размера назван числом в двух местах сразу** — у поля файла в схеме и у
тела запроса приёма, — и оба умолчания пришлось перекрыть: нулевой потолок поля
библиотека читает не как «без предела», а как свои 5 МиБ, а роутер отсекает тело
+13 -8
View File
@@ -69,8 +69,8 @@
**Репозиторий хранилища** (`internal/adapter/repo/pocketbase`; шаги схемы —
подпакетом `migrations`):
- список колонок совпадает во всех четырёх местах — `applyToRecord`,
`recordToJob`, `acquireColumns`, `acquiredRow` — и в шаге схемы (инвариант
- список колонок совпадает в обоих местах — `applyOwnedByPipeline` вместе с
`applyToRecord` и `recordToAudioRecord` — и в шаге схемы (инвариант
[CLAUDE.md](../CLAUDE.md), «Инварианты»);
- захват задачи не выдаёт одну строку двум вызывающим, а результат пишет только
держатель захвата;
@@ -109,11 +109,15 @@
приведение типа на этом месте — настоящий дефект, закрытый 2026-08-11 задачей
`errors-as-instead-of-typecast`. Появилось снова — это регрессия, и выбрасывать
её как известную нельзя.
- **«Захват задачи не в транзакции — гонка двух воркеров».** По построению её
нет: три воркера читают три разных состояния, и одну строку они не делят.
Механика захвата и её слабые места — [database.md](database.md),
«Представление данных». Находка становится настоящей ровно тогда, когда
появится второй экземпляр процесса или второй воркер на то же состояние.
- **«Захват записи не в транзакции — гонка двух воркеров».** ~~По построению её
нет: три воркера читают три разных состояния, и одну строку они не делят.~~
**Отменено 2026-08-14 задачей `record-centric-model`:** построение снято. Пул
одинаковых воркеров конкурирует за один и тот же набор записей, и второй
воркер на тот же рубеж теперь есть всегда, когда их больше одного. Находка о
гонке захвата стала настоящей и выбрасывается только по существу — механика
захвата и её слабые места в [database.md](database.md), «Представление
данных». Строка оставлена отменённой, а не удалена: прогон, помнящий прежнюю
редакцию, иначе выбросил бы настоящую находку как известную.
- **«Файлы и объекты не удаляются, диск растёт».** Факт верный и записан в
[database.md](database.md); срок хранения не задан сознательно, задачи на него нет.
Новой находкой это не считается, пока не измерен рост.
@@ -165,7 +169,8 @@
первые две описаны частично. Поведение прочих узлов, включая
приём из Telegram, живёт в обзоре под маркерами долга, а соблазн дописать туда
ещё — самый большой.
- `conventions`: новая колонка правится во всех четырёх местах репозитория
- `conventions`: новая колонка правится в обоих местах репозитория, а новый
рубеж — одним дескриптором
(CLAUDE.md, «Инварианты»).
- `autotests`: покрыт ли изменённый шаг конвейера хоть одним **проходящим**
тестом. Что уже закрыто проверками, видно по журналу дефектов ниже и по
+36 -7
View File
@@ -112,7 +112,17 @@ Telegram отправителю.
списком; `filepath.Ext` режет по последней точке и не пропускает разделитель
каталогов, но это единственное, что стоит между входом и именем файла.
- **Ключ объекта в Object Storage** — то же имя файла, то есть UUID с
расширением. Бакет один на все записи, префикса по пользователю нет.
расширением. Бакет один на все записи, префикса по пользователю нет. С
2026-08-14 копия там файлом записи не считается: она существует лишь потому,
что провайдер читает аудио по адресу, и её ключ живёт в строке попытки
распознавания.
- **Вторая раскладка файла на диске** появилась 2026-08-14 вместе с сохранённым
ответом провайдера: `data/storage/<recognitions>/<попытка>/<имя>.payload`. Имя
задаёт сервис, как и у аудио. Содержимое там — **полный текст речи**, а не
метаданные, поэтому поле помечено защищённым, правило просмотра коллекции
оставлено пустым, и ссылка на вложение подпадает под тот же запрет, что и
ссылка на аудио: в журнал она не пишется. Проверено прогоном: без сессии, с
чужим и со своим токеном файла ссылка отвечает «не найдено».
- **Ссылка на файл** — `/api/files/<коллекция>/<запись>/<имя>`. Поле файла
помечено защищённым задачей `oidc-login` 2026-08-12: пройти по ссылке теперь
можно только с коротким токеном файла, который выдаётся по сессии, и запрос
@@ -121,12 +131,14 @@ Telegram отправителю.
остаётся: **имя файла в хранилище в журнал не пишется**
— иначе строка журнала вместе с идентификатором записи собирала бы ссылку
целиком и работала бы бессрочно. В журнал идёт расширение своим полем.
- **Идентификатор задачи** — 15 знаков, выдаёт хранилище. Он же единственное,
- **Идентификатор записи** — 15 знаков, выдаёт хранилище. Он же единственное,
что защищает `GET /api/status/:id`.
- **Поверхность самого хранилища.** Вместе с переводом наружу выходят
`/api/collections/...`, `/api/logs`, `/api/backups`, `/api/settings`,
`/api/crons` и панель `/_/`. Правила доступа коллекций оставлены пустыми, то
есть доступны они только владельцу панели; коды, снятые прогоном, —
есть доступны они только владельцу панели, — и коллекции, заведённые
2026-08-14, тоже: содержимое записи отдаёт собственный адрес сервиса, а не
поверхность хранилища. Коды, снятые прогоном, —
[database.md](database.md), «Коллекции», норма —
[storage](../openspec/specs/storage/spec.md), «Наружу хранилище отдаёт только
то, что заказано».
@@ -210,7 +222,13 @@ Telegram отправителю.
## Что чувствительнее чего
1. **Содержимое записей и расшифровок.** Голосовые сообщения — личная переписка;
это самое чувствительное, что здесь есть.
это самое чувствительное, что здесь есть. С 2026-08-14 оно живёт не одной
колонкой, а шестью коллекциями: сама запись (заголовок и краткое описание),
`texts` (расшифровка и вычитанный текст), `structures` (реплики со временем),
`recognitions` (**сырой ответ провайдера вложением — полный текст речи**),
`record_events` (журнал событий, содержимого не несёт) и `topics` (словарь
тем человека). Всякая новая коллекция, куда содержимое переезжает, закрывается
наравне с записью — норму держит спека `storage`.
2. **Токен бота Telegram.** Даёт полный доступ к боту и к перепискам с ним.
3. **Ключи Yandex Cloud**`speech_kit_api_key` и пара ключей Object Storage.
Утечка оплачивается деньгами и доступом к бакету.
@@ -336,6 +354,17 @@ Telegram отправителю.
вовсе. С 2026-08-11 это уже не недосмотр, а следствие решения хранить
бессрочно, и тем же днём заведена задача `delete-record`: своя запись
убирается вместе с файлом, объектом в Object Storage и всеми уровнями текста.
Пока она не сделана, единственный способ убрать запись — руками в базе и в
каталоге на сервере. Учёт расхода удалению не подлежит по решению человека:
деньги потрачены, а строки потребления текста не содержат.
Учёт расхода удалению не подлежит по решению человека: деньги потрачены, а
строки потребления текста не содержат.
**Руками запись сегодня не удаляется, и прежняя строка об этом была неверна.**
Проверено прогоном 2026-08-14: содержимое живёт в коллекциях, перечисленных
выше («Что чувствительнее чего»), связи приложений с записью обязательны и
каскада не имеют, поэтому удаление самой
строки записи отвергается хранилищем, а удаление её файлов проходит молча.
Владелец, выполнивший прежнюю процедуру, стирает аудио и **оставляет полный
текст речи** — расшифровку, разбивку по репликам и сырой ответ провайдера
файлом на диске. Порядок, которым запись убирается на самом деле: сперва
строки приложений — журнал событий, попытка распознавания вместе с её
вложением, структура, тексты, — потом сама запись, потом её файлы. До
`delete-record` это единственный способ, и он ручной целиком.