## Context

Сегодня в хранилище одна строка несёт всё разом: домен записи (владелец, файл),
поля очереди (захват, пауза, попытки) и содержимое (расшифровка целиком, в
колонке). Отсюда четыре следствия, и все они наблюдаемы:

- **захват тянет содержимое.** Запрос захвата перечисляет колонки поимённо и
  читает среди них расшифровку; часовая запись едет в память при каждом опросе;
- **ссылка на файл одна, и её переставляет каждый шаг.** У прошедшей конвейер
  записи она ведёт на копию во внешнем хранилище, и принятого человеком файла не
  найти ничем — задача `play-recording-in-app` упирается именно в это;
- **отказ стирает достигнутое.** Переход в состояние отказа чистит служебные
  поля и не оставляет рубежа: продолжить с места остановки не с чего, и владелец
  правит состояние в панели наугад;
- **один счётчик несёт две обязанности.** Число попыток ограничивает и повторы
  внутри шага, и застревание. Опрос чужой операции обнуляет его переходом в то
  же самое состояние — значит зависшая операция опрашивается вечно; перестань он
  обнуляться, и здоровая долгая запись умирала бы на шестом опросе.

Замысел выработан разговором 2026-08-14 (запись `record-centric-model`), там же
разобраны и закрыты четыре развилки: где живут темы, дробить ли задачу, как
зовётся конечный рубеж и чем ограничивается застревание. Открытых вопросов
постановка не оставила.

Ограничения, которые эта работа не выбирает: хранилище — встроенная PocketBase,
применённый шаг схемы не переписывается, публичный контракт HTTP API объявлен
необратимым. Записей на сервере при этом нет: сервис остановлен, а прежние
данные удалены решением владельца 2026-08-14 — новая модель заводится с чистого
листа, и переноса данных эта работа не делает.

## Goals / Non-Goals

**Goals:**

- Аудиозапись становится центральной сущностью, приложения к ней живут
  отдельными строками, а поля очереди перестают соседствовать с содержимым.
- Рубеж называет достигнутое, остановка становится признаком, и запись
  продолжает с места остановки.
- Два сторожа вместо одного: отказы ограничивают повторы, время в рубеже —
  застревание.
- Воркеры теряют специализацию, их число задаётся настройкой.
- Схема заводится одним шагом; переноса прежних данных нет.

**Non-Goals:**

- Резать длинную запись на фрагменты — конвейер остаётся цепочкой
  (`long-audio-chunking`).
- Выносить доставку из конвейера: ответ в Telegram остаётся хвостом последнего
  шага.
- Считать уровни текста: сам шаг обращения к языковой модели делает
  `llm-insights-adapter`, вычитанный текст — `literary-text-level`. Здесь
  заводятся только места, куда они лягут.
- Размечать говорящих в структуре: связь реплики с разбором говорящего у
  провайдера не выяснена.
- Ставить таймауты внешним вызовам — `external-call-timeouts`.
- **Заводить обязательность и необязательность шага.** Ревью дизайна показало,
  что носителя у неё нет ни одного: все пять рубежей обязательны, а первый
  необязательный шаг приносит `llm-insights-adapter`. Норма без экземпляра
  проверяется либо подставным шагом ради теста — что запрещено запретом на
  проверки над проверками, — либо галочкой без оракула; вернётся вместе с первым
  своим шагом.
- **Давать владельцу записи снимать остановку.** Постановка этого просила, но
  поверхности нет: адресов у сервиса два (приём и опрос), экранов нет вовсе, а
  открыть правку коллекции запросом запрещает действующая норма `storage`
  («Наружу хранилище отдаёт только то, что заказано»). Сегодня остановку снимает
  владелец панели; владельцу записи это даст задача, заводящая экраны.
- Нарастающая пауза опроса и отступ на пустой очереди: первое —
  `speechkit-callback-fit`, второе при единицах записей в день не нужно.
- Экраны.

## Decisions

### Разрез сущностей идёт по зависимости от провайдера

Границ для разреза было три, и выбрана одна.

- **По зависимости от провайдера распознавания** — принято. Всё, что перестанет
  быть верным при смене провайдера (идентификатор операции, адрес, по которому
  провайдер читает аудио, имя модели, сырой ответ), уезжает в строку попытки;
  всё остальное остаётся доменом записи. Смена провайдера тогда не трогает
  доменную сущность вовсе.
- **По изменчивости** (что правит конвейер против того, что правит человек) —
  отвергнуто: граница проходит внутри одной колонки. Рубеж правят оба.
- **По частоте чтения** — отвергнуто как единственная граница: она объясняет,
  почему тексты уезжают из записи, но ничего не говорит про идентификатор
  операции, который читается ровно так же часто, как рубеж.

Частота чтения при этом остаётся **вторым** разрезом, внутри домена: `title` и
`brief` читаются сотней штук разом и остаются колонками записи, расшифровка и
вычитанный текст читаются по открытию одной записи и уезжают строками `texts`.

### Сырой ответ провайдера хранится вложением, а не колонкой

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

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

### Остановка — признак, а не рубеж

Прежние `failed` и `dead` схлопываются в признак `halted_at` с причиной, а
`state` не стирается.

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

### Сторожей двое, и предела времени — два числа

`attempts` считает **отказы** и ограничивает повторы внутри шага;
`state_entered_at` считает **время** и ограничивает застревание.

Пределов времени два, и граница проходит не по рубежам, а по тому, чью работу
ждём: своя (`uploaded`, `normalized`, `transcribed`) и чужая (`submitted`).

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

Сколько идёт распознавание долгой записи, никто не мерил (`speechkit-limits`,
`intake-limits-measure`), поэтому ошибаемся в сторону долгого: ложная остановка
хуже поздней.

**Час на свою работу меньше самой работы, и это принято сознательно.** Расчётный
потолок записи — шесть часов, приведение такой записи идёт дольше часа по
построению (`docs/review.md`, запись 2026-08-13), а срок захвата шага приведения
стоит сегодня восемью часами. Значит длинная запись, отказавшая один раз и
ждущая повтора дольше часа, будет остановлена сторожем застревания вместо
расшифровки. Ревью дизайна предлагало вывести предел из срока захвата (12 часов
на свою работу); владелец решил 2026-08-14 оставить час, потому что сторож ловит
**зависание**, а живая работа наблюдается по самому процессу конвертера, и
остановка теперь обратима — снятие признака возвращает запись на её рубеж, и
цена ложной остановки равна одному движению владельца. Оба числа в конфиге,
ключи — `[pipeline] own_work_limit` и `[pipeline] foreign_work_limit`.

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

### Признак захвата — значение, а не занятость

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

Причина проверяемая: панель по требованию `storage` очищает признак захвата,
когда человек снимает остановку. Условие, проверяющее лишь непустоту признака
или срок протухания, пропустило бы обоих — шаг A, потерявший запись, и шаг B,
её подобравший, — и оба записали бы результат и оба ответили бы отправителю.
Гонки часов для этого не нужно: достаточно одного движения человека в панели.

### Имена, которые уезжают необратимым шагом

Коллекция зовётся `audio_records`, а не `audiorecords`: соседи по схеме —
`transcribe_jobs`, `files`, `record_events` — в snake_case, и одно исключение
разошлось бы молча по константе имён, запросу захвата, правилам панели и запрету
удаления.

Вид текста лежит колонкой `kind`, а не `format`: словом `format` в этой же схеме
зовут формат файла (`files.format`), и третий смысл у одного слова проект уже
разводил однажды — комментарий к `location` в `entity/file.go` называет довод.

Текст отказа в журнале событий зовётся `outcome_text`, а не `error_text`:
последнее имя названо поимённо инвариантом проекта о секрете, и две колонки с
этим именем сделали бы инвариант двусмысленным.

### Переход и откладывание — разные операции

Сегодня шаг опроса зовёт переход с **тем же** состоянием, и мнимость этого
перехода обнуляет счётчик. `Postpone(delay)` ставит паузу и снимает захват, а
рубежа и времени входа в него не трогает; отказы обнуляет — ожидание чужой
операции отказом не является.

Без разделения `state_entered_at` сбрасывался бы на каждом опросе и повторил бы
ровно тот промах, ради которого заводится.

### Захват возвращает идентификатор

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

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

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

### Воркеры теряют специализацию

Пул одинаковых потоков, число из настроек, шаг выбирается по рубежу таблицей
диспетчеризации.

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

`N = 0` — законное значение: записи принимаются и не двигаются. Это нужный режим
для местного запуска и для выкладки, где конвейер надо остановить, не роняя
приём.

### Темы живут коллекцией со словарём на каждого владельца

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

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

Отвергнуто: темы набором строк в самой записи (перечень не собрать) и общий
словарь на всех (тема — слепок того, о чём человек говорит, и общий словарь
показал бы одному темы другого).

### Конечный рубеж зовётся `done`

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

### Задача делается одним заходом

Швы у неё есть — сущности со схемой, цепочка рубежей, провайдерская таблица,
обобщение пула, — но резать по ним значит платить **четырьмя** необратимыми
шагами схемы вместо одного и держать на сервере промежуточные раскладки. Решение
владельца 2026-08-14.

## Risks / Trade-offs

- **Шаг схемы уезжает на сервер и не переписывается** → под ним пусто: сервис
  остановлен и прежние данные удалены, поэтому цена ошибки в шаге ограничена
  повторным пересозданием каталога данных, а не потерей чужого архива.
- **Ложная остановка длинной записи** сторожем застревания принята решением
  владельца (см. решение о пределах) → цена ограничена обратимостью остановки;
  если класс начнёт всплывать, число правится настройкой без шага схемы.
- **Публичный контракт HTTP API ломается**: перечень значений `status` меняется
  целиком → ломка объявлена прямо в спеке `intake`; имена полей сохранены.
  Потребителей у контракта сегодня двое — свои же будущие экраны и внешняя
  программа, которой ещё нет (`api-tokens`).
- **Таймаутов у внешних вызовов по-прежнему нет** (`external-call-timeouts` не
  сделана) → срок захвата остаётся единственным пределом. Молчащий провайдер
  держит шаг до конца срока захвата, а протухший захват на шаге отправки даёт
  вторую платную операцию. Смягчение частичное: шаг отправки проверяет сделанное
  прежде, чем платить, и укладку не повторяет. Полностью закрывается только той
  задачей.
- **Захват стал двумя обращениями к базе вместо одного** → между захватом и
  чтением запись может измениться. Запись результата остаётся условной по
  признаку захвата, поэтому шаг, потерявший захват, ничего не пишет; худший исход
  — потерянная работа шага, а не порча записи.
- **Сторожей стало двое, и оба надо сбрасывать в правильных местах** → правило
  проверяется тестом на подставных часах: сотня откладываний подряд не двигает
  время входа в рубеж и не обнуляет отсчёт.
- **Инвариант проекта о колонках очереди меняет форму** → он съёживается до трёх
  мест, и `CLAUDE.md` правится тем же изменением; забыть об этом нельзя, иначе
  инвариант станет требовать несуществующего.
- **Класс «перечень, которого не видит компилятор» не исчезает, а переезжает с
  колонок на рубежи** → рубеж, забытый в отборе захвата, не выдаётся никому и не
  пишет ни строки: пустой прогон по инварианту не логируется. Смягчение —
  дескриптор рубежа одним объявлением, из которого выводятся выбор шага, отбор
  захвата и оба предела; сканеры `internal/archrules`, стоящие сегодня на
  `acquireColumns` и `acquiredRow`, перенацеливаются на этот дескриптор, а не
  удаляются.
- **Обрыв процесса между ответом провайдера и записью идентификатора операции**
  → строка попытки заводится **до** обращения, и повторный шаг начинает с
  проверки, не заведена ли операция. Жёсткий обрыв (`SIGKILL`, OOM) окна всё
  равно не закрывает: это остаточный риск, названный здесь и **не** записанный
  нормой — норма, обязывающая к недостижимому, зеленела бы на тесте мягкой
  остановки и объявляла бы защиту сделанной.
- **Метка счётчика работы воркера теряет смысл вместе со специализацией** →
  метка переводится с имени потока на рубеж, иначе единственный сигнал отказа у
  владельца сервиса перестаёт отличать «падает приведение» от «падает
  распознавание». `docs/architecture.md`, раздел «Эксплуатация», правится тем же
  изменением.
- **Журнал событий `record_events` — второй канал наблюдаемости рядом с
  метриками**, а колонки расхода в нём заходят на открытый вопрос «Учёт расхода»
  (`usage-accounting`) и на разведку `opentelemetry-fit` → читателя у журнала
  сегодня нет: экранов нет, конвейеру читать его запрещено требованием. Колонки
  расхода отложены до задачи, которая учёт заводит.
- **`topics` заводится вперёд своего потребителя** → писать и читать темы в этом
  изменении не будет ничто. Довод за включение — цена: коллекция, заведённая
  позже, стоит второго необратимого шага схемы задаче `llm-insights-adapter`.
  Цена включения — имена коллекции и колонок закрепляются раньше, чем известен
  запрос потребителя.

## Migration Plan

Переноса данных нет: сервис на сервере остановлен, прежние записи и файлы удалены
решением владельца 2026-08-14. Работа идёт так, будто выкладки не было ни разу.

1. Новый шаг схемы заводит `audio_records` и коллекции приложений, правит `files`
   и **удаляет** прежнюю `transcribe_jobs`: данных под ней нет, а оставленная
   пустая коллекция висела бы в панели вторым домом для того же понятия.
   Применённые шаги при этом не переписываются — изменение идёт новым файлом.
2. Порядок выкладки: сперва образ. Новых обязательных ключей настройки нет — у
   числа воркеров и обоих пределов времени есть умолчания.
3. **Откат.** Прежний образ ищет `transcribe_jobs`, которой уже нет, и работать
   не будет: откат означает пересоздание каталога данных, и это осознанная цена
   пустого старта. Обратного шага схемы нет и не планируется.
4. Резервную копию каталога данных перед выкладкой делает человек — не ради
   записей, а ради учётных записей и настроек провайдера входа.

## Open Questions

Четыре развилки постановки разобраны в записи задачи 2026-08-14 и закрыты
решениями владельца. Ревью дизайна открыло ещё несколько мест, и все они вынесены
человеку на чекпоинт; решения, записанные выше, — предложенные, а не принятые:

- **предел «час на свою работу»** — оставлен часом: сторож ловит зависание, а
  живая работа наблюдается по процессу; остановка обратима;
- **перенос данных** — отменён целиком: прод остановлен, прежние данные удалены;
- **снятие остановки владельцем записи** — намерение зафиксировано, реализация
  приходит с экранами и своим адресом API;
- **`topics`** — оставлены, как решено постановкой: один шаг схемы вместо двух;
- **обязательность шага** — отложена до первого необязательного шага (решение
  исполнителя по находке ревью, названо на чекпоинте);
- **имена ключей конфига** объявлены проектом необратимыми и потому названы
  дословно: `[pipeline] workers`, `[pipeline] own_work_limit`,
  `[pipeline] foreign_work_limit`.

Осталось названным риском, а не вопросом: `external-call-timeouts` в плане
стройки стоит **ниже** этой задачи, хотя «Рамки» постановки называют её
предшествующей. Порядок расставил владелец, и решение о нём принято.
