## 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` в плане стройки стоит **ниже** этой задачи, хотя «Рамки» постановки называют её предшествующей. Порядок расставил владелец, и решение о нём принято.